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 bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 20 additions & 0 deletions fly.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
app = "slop-bridge"
primary_region = "iad"

[build]
dockerfile = "packages/typescript/integrations/bridge-server/Dockerfile"

[env]
PORT = "8080"

[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = "off"
auto_start_machines = true
min_machines_running = 1

[[vm]]
memory = "512mb"
cpu_kind = "shared"
cpus = 1
26 changes: 26 additions & 0 deletions packages/typescript/integrations/bridge-server/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
FROM oven/bun:1-alpine AS build

WORKDIR /app

COPY package.json bun.lock ./
# Drop workspace globs that don't ship in this build context (apps/, examples/, website/, benchmarks/)
RUN bun -e "const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));p.workspaces=p.workspaces.filter(w=>w.startsWith('packages/typescript'));fs.writeFileSync('package.json',JSON.stringify(p,null,2));"
COPY packages/typescript ./packages/typescript

RUN bun install
RUN cd packages/typescript/integrations/relay-cli && bun run build
RUN cd packages/typescript/integrations/bridge-server && bun run build

FROM oven/bun:1-alpine

WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8080

COPY --from=build /app/package.json /app/bun.lock ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/packages/typescript ./packages/typescript

EXPOSE 8080

CMD ["bun", "packages/typescript/integrations/bridge-server/dist/cli.js"]
49 changes: 49 additions & 0 deletions packages/typescript/integrations/bridge-server/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# @slop-ai/bridge-server

Hosted bridge for SLOP relay clients and remote MCP hosts.

The bridge exposes:

- `POST /mcp`, `GET /mcp`, and `DELETE /mcp` for Streamable HTTP MCP clients.
- `GET /relay` as an authenticated WebSocket endpoint for `slop-relay`.
- `GET /healthz` for health checks.

## Configuration

`SLOP_BRIDGE_USERS` is required. It is a JSON object keyed by user id:

```json
{
"user_123": {
"mcpToken": "mcp-token-at-least-16-chars",
"relayToken": "relay-token-at-least-16-chars",
"label": "optional display label"
}
}
```

MCP hosts connect with `Authorization: Bearer <mcpToken>`.

Local relay agents connect with `Authorization: Bearer <relayToken>`.

## Local Development

```sh
SLOP_BRIDGE_USERS='{"dev":{"mcpToken":"dev-mcp-token-0001","relayToken":"dev-relay-token-0001"}}' \
bun run dev -- --port 8080
```

Point `slop-relay` at the bridge:

```sh
SLOP_RELAY_TOKEN=dev-relay-token-0001 \
slop-relay --url ws://localhost:8080/relay
```

Use `http://localhost:8080/mcp` as the remote MCP URL with the MCP token.

## Build

```sh
bun run build
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { SlopNode } from "@slop-ai/consumer";
import type { Down, Up } from "@slop-ai/relay-cli/protocol";
import WebSocket from "ws";
import { type BridgeServerHandle, createBridgeServer } from "../src/http";
import { parseTokenRegistry } from "../src/tokens";

const MCP_TOKEN = "test-mcp-token-0001";
const RELAY_TOKEN = "test-relay-token-0001";

const tree: SlopNode = {
id: "root",
type: "app",
properties: { title: "Fake App" },
children: [
{
id: "button",
type: "button",
properties: { label: "Increment" },
affordances: [
{
action: "click",
description: "Increment the counter.",
params: { type: "object", properties: {} },
},
],
},
],
};

describe("bridge server", () => {
let server: BridgeServerHandle | null = null;
let relay: WebSocket | null = null;
const relayFrames: Down[] = [];

beforeEach(async () => {
relayFrames.length = 0;
server = createBridgeServer({
port: 0,
hostname: "127.0.0.1",
tokens: parseTokenRegistry(
JSON.stringify({
user_test: { mcpToken: MCP_TOKEN, relayToken: RELAY_TOKEN },
}),
),
logger: { info: () => {}, error: () => {} },
idleTimeoutMs: 60_000,
});

relay = await connectRelay(server.port, relayFrames);
});

afterEach(async () => {
relay?.close();
relay = null;
server?.stop();
server = null;
});

test("lists relayed apps, opens state, invokes actions, and unsubscribes on session close", async () => {
if (!server) throw new Error("server not started");

const transport = new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${server.port}/mcp`), {
requestInit: {
headers: { Authorization: `Bearer ${MCP_TOKEN}` },
},
});
const client = new Client({ name: "bridge-test", version: "0.0.0" });
await client.connect(transport);

const listResult = (await client.callTool({ name: "list_apps", arguments: {} })) as CallToolResult;
const listPayload = listResult.structuredContent as { providers: { id: string; connected: boolean }[] };
expect(listPayload.providers).toContainEqual(expect.objectContaining({ id: "fake-app", connected: true }));

const openResult = (await client.callTool({
name: "open_app",
arguments: { app: "fake-app" },
})) as CallToolResult;
const openPayload = openResult.structuredContent as { selected: { id: string; tree: SlopNode } };
expect(openPayload.selected.id).toBe("fake-app");
expect(openPayload.selected.tree.id).toBe("root");
expect(relayFrames).toContainEqual(expect.objectContaining({ t: "subscribe", providerId: "fake-app" }));

const actionResult = (await client.callTool({
name: "app_action",
arguments: { app: "fake-app", path: "/button", action: "click", params: {} },
})) as CallToolResult;
expect(actionResult.isError).not.toBe(true);
expect(actionResult.content?.[0]?.text).toContain("Done");
expect(relayFrames).toContainEqual(
expect.objectContaining({ t: "invoke", providerId: "fake-app", path: "/button", action: "click" }),
);

await transport.terminateSession();
await waitFor(() => relayFrames.some((frame) => frame.t === "unsubscribe"));
});
});

async function connectRelay(port: number, received: Down[]): Promise<WebSocket> {
const relay = new WebSocket(`ws://127.0.0.1:${port}/relay`, {
headers: { Authorization: `Bearer ${RELAY_TOKEN}` },
});
relay.on("message", (raw) => {
const frame = JSON.parse(raw.toString()) as Down;
received.push(frame);
if (frame.t === "subscribe") {
sendRelay(relay, {
t: "snapshot",
reqId: frame.reqId,
subId: frame.subId,
tree,
version: 1,
seq: 0,
});
sendRelay(relay, {
t: "patch",
subId: frame.subId,
ops: [{ op: "replace", path: "/properties/title", value: "Fake App Updated" }],
version: 2,
seq: 1,
});
return;
}
if (frame.t === "invoke") {
sendRelay(relay, {
t: "result",
reqId: frame.reqId,
ok: true,
data: { invoked: frame.action },
});
}
});

await new Promise<void>((resolve, reject) => {
relay.once("open", () => resolve());
relay.once("error", reject);
});

sendRelay(relay, { t: "hello", relayVersion: "test", protocolVersion: 1 });
sendRelay(relay, {
t: "providers",
list: [
{
id: "fake-app",
name: "Fake App",
transport: "ws",
source: "local",
status: "connected",
capabilities: ["state", "invoke"],
},
],
});
return relay;
}

function sendRelay(relay: WebSocket, frame: Up): void {
relay.send(JSON.stringify(frame));
}

async function waitFor(predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const start = Date.now();
while (!predicate()) {
if (Date.now() - start > timeoutMs) {
throw new Error("Timed out waiting for condition");
}
await Bun.sleep(10);
}
}
41 changes: 41 additions & 0 deletions packages/typescript/integrations/bridge-server/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
{
"name": "@slop-ai/bridge-server",
"version": "0.1.0",
"type": "module",
"description": "Hosted bridge server for remote MCP hosts and local SLOP relay agents.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"slop-bridge-server": "dist/cli.js"
},
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"files": ["src", "dist", "Dockerfile", "fly.toml"],
"sideEffects": false,
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/devteapot/slop",
"directory": "packages/typescript/integrations/bridge-server"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --format esm --target bun --external '@slop-ai/*' --external '@modelcontextprotocol/*' --external 'zod' && bun build src/cli.ts --outdir dist --format esm --target bun --external '@slop-ai/*' --external '@modelcontextprotocol/*' --external 'zod' && bunx tsc --emitDeclarationOnly --outDir dist",
"dev": "bun src/cli.ts",
"clean": "rm -rf dist"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@slop-ai/consumer": "workspace:*",
"@slop-ai/relay-cli": "workspace:*",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.6.0",
"@types/ws": "^8.5.0",
"ws": "^8.18.0"
}
}
Loading