Metadata-Version: 2.4
Name: zenyard-relay
Version: 0.1.1
Classifier: Operating System :: POSIX :: Linux
Classifier: Operating System :: MacOS
Classifier: Operating System :: Microsoft :: Windows
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: 3
License-File: LICENSE
Summary: Per-plugin sidecar that exposes local MCP servers to a Zenyard backend over an outbound WebSocket.
Home-Page: https://zenyard.ai
Author: Zenyard
License-Expression: AGPL-3.0-only
Requires-Python: >=3.9
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Homepage, https://zenyard.ai
Project-URL: Repository, https://github.com/zenyard/zenyard-relay

# zenyard-relay

`zenyard-relay` connects an MCP server running on a user's machine to the
Zenyard backend, even when that machine is behind a NAT or firewall. It
dials out to the backend so the server can be reached without exposing any
inbound ports.

Run one `zenyard-relay serve` process per MCP server you want to expose.
Each process is a transparent passthrough — it forwards traffic between the
backend and the MCP server unchanged. Multiple servers on the same machine
appear to the backend as a single device.

The MCP server can run as a local subprocess (`stdio`) or a remote HTTP
endpoint (`streamable-http`).

## Install

The relay ships as a Python wheel built by [maturin](https://www.maturin.rs/);
the wheel installs the native `zenyard-relay` binary into the environment's
`bin/` (or `Scripts\` on Windows):

```
pip install zenyard-relay
```

Or build from source with a stable Rust toolchain (≥ 1.75):

```
cargo build --release
```

The binary lands at `target/release/zenyard-relay`. Wheels for the common
platforms are published from `.github/workflows/ci.yml` (tests gate the
wheel publish step).

## One-time device setup

`serve` runs without any config file as long as `--control-endpoint` (or
`--api-url`) and the `ZENYARD_RELAY_TOKEN` env var are supplied. For
convenience, a shared `~/.zenyard/relay.json` can hold the control endpoint
(and optionally the token) so individual sidecars don't have to pass it on
the command line:

```
zenyard-relay config init      # optional; writes a starter ~/.zenyard/relay.json
zenyard-relay config path      # prints the config path
zenyard-relay config validate  # validates the file
```

`~/.zenyard/relay.json` example:

```json
{
  "config": {
    "control_endpoint": "wss://relay.zenyard.dev/relay/control",
    "device_description": "user-laptop (Linux)"
  }
}
```

Plugins that already have the backend HTTP API URL configured can pass it
as-is — the relay swaps `http`/`https` for `ws`/`wss` and appends the
fixed `/relay/control` path:

```
zenyard-relay serve --id <ID> --api-url http://localhost:30465/ ...
```

The same field is accepted in `relay.json` as `"api_url"` (mutually
exclusive with `"control_endpoint"`).

The auth token is preferred from `ZENYARD_RELAY_TOKEN` (keeps secrets out
of the JSON file); `config.token` is the fallback.

## Sidecar contract

```
zenyard-relay serve --id <UPSTREAM_ID> [--display-name <NAME>] [--description <TEXT>] [--tag K=V...]
    [--control-endpoint <URL> | --api-url <URL>]
    [--config <PATH>] [--log-level <LEVEL>] [--log-format <FMT>]
    (--command <CMD> [--arg <V>...] [--env K=V...] [--cwd <DIR>])
  | (--url <URL> [--header "Name: value"...])
```

`--control-endpoint` / `--api-url` may be omitted when `~/.zenyard/relay.json`
(or the file pointed at by `--config`) supplies one. `ZENYARD_RELAY_TOKEN`
in the environment is preferred over `config.token`.

Example — stdio upstream:

```
zenyard-relay serve --id fs --display-name "Filesystem" \
    --command npx --arg -y --arg @modelcontextprotocol/server-filesystem --arg /tmp
```

Example — remote upstream:

```
zenyard-relay serve --id api --display-name "Example API" \
    --url https://api.example.com/mcp \
    --header "Authorization: Bearer ghp_..."
```

After connecting, the sidecar sends a single `Hello` frame carrying the
connection identity (`relay_id` / `upstream_id`) and the initial metadata
snapshot (display_name / description / tags). It then reads stdin
line-by-line for `update` ops that mutate the announced metadata:

```json
{"op":"update", "display_name":"Echo (renamed)"}
{"op":"update", "tags":{"file":"sample.exe"}}
{"op":"update", "description":"Decompilation tools for sample.exe"}
```

Each `update` op replaces the supplied fields and re-sends
`UpstreamUpdated`. The sidecar logs lifecycle events (connections, sessions,
auth failures) to stderr per `--log-level` / `--log-format`, and exits when
stdin closes.

For "same resource → same upstream across restarts" semantics, derive
`--id` deterministically from the resource (e.g.
`ida-<sha256(idb_path)>`). Conflicting live claims on the same id from
two sidecars trigger takeover at the backend: the newer connection wins,
the older one's sessions continue until they close naturally.

## Exit codes

| Code | Meaning |
|------|---------|
| 0    | Ok |
| 1    | Generic error |
| 2    | Invalid configuration / fatal auth rejection |
| 3    | Superseded — another relay took over this `(relay_id, upstream_id)` |

## License

This project is licensed under the **GNU Affero General Public License v3.0 only**.

See the [LICENSE](LICENSE) file in this repository for the full license text.

