One endpoint for all your MCP servers. endara.ai
Aggregate local and cloud MCP servers behind a single endpoint. Add servers, manage OAuth, connect any AI client — all from one place.
Works with Claude Desktop, ChatGPT, Cursor, Windsurf, VS Code, Zed, Continue, and any MCP-compatible client.
- One endpoint, not N — point every AI client at
localhost:9400instead of pasting the same MCP server config into each app. - Works out of the box — the
[relay]table is optional;machine_namedefaults to your system hostname, so a fresh install runs with nothing but a list of endpoints. - OAuth managed for you — Relay handles token storage and refresh for servers that need it, and signs you in just in time when an upstream returns
401. - Enterprise SSO (EMA) — Enterprise-Managed Authorization lets endpoints authenticate through your organization's identity provider (Okta, Entra, Google, Ping) with
[[organizations]]blocks and[endpoints.auth] type = "ema". - Resources & prompts too — not just tools: upstream MCP resources and prompts are merged and proxied with reversible namespacing, so MCP Apps and prompt catalogs work through the relay.
- Run servers in containers — opt-in Docker / Podman isolation for STDIO servers, with a direct-spawn fallback when no runtime is present.
- Hot-reload config — edit your TOML, save, and Relay picks up the change without a restart.
- Automatic restart on crash — flaky STDIO servers come back on their own with exponential backoff.
- Endpoint profiles — serve named subsets of your endpoints under their own
/mcp/{profile}URL so different agents can share one relay without sharing one catalog. - Live tool-call event stream — subscribe to every tool call (with the calling client's identity) via Server-Sent Events on the management API; powers the Endara Desktop overlay and Observability tab.
- Fully local — no cloud, no accounts, no telemetry. Everything runs on your machine.
Endara Relay is a single Rust binary that sits between your AI assistant (Claude Desktop, Cursor, or any MCP client) and all the MCP servers you use. Instead of configuring each server individually in your client, you point your client at one local endpoint — localhost:9400 — and Relay handles the rest.
It connects to each MCP server using the appropriate transport (STDIO, SSE, or HTTP), merges their tool, resource, and prompt catalogs into a unified view, and prefixes names to avoid collisions. If a server crashes, Relay restarts it automatically. If you edit the config file, Relay picks up the changes without a restart.
No cloud. No accounts. Everything runs on your machine.
┌──────────────────────────────────────────────────────┐
│ Endara Relay (single Rust process) │
│ │
│ ┌────────────────────┐ ┌──────────────────────────┐│
│ │ TCP loopback :9400 │ │ Unix socket / Named pipe ││
│ │ /mcp /healthz │ │ /api/* (per-user, 0600)││
│ │ /oauth/callback │ │ ││
│ └────────────────────┘ └──────────────────────────┘│
└──────────────────────────────────────────────────────┘
The TCP loopback listener serves MCP traffic, the health probe, and the OAuth callback. The management API (/api/*) is bound exclusively to a per-user OS-local Unix-domain socket (Linux/macOS) or Named Pipe (Windows) with 0600 permissions; it is not reachable over TCP. See Management API for the full endpoint list and the platform-specific socket paths.
# Homebrew — recommended (macOS / Linux)
brew install endara-ai/tap/endara-relay
# Or, with cargo:
cargo install endara-relay
# Or download a pre-built binary from GitHub Releases:
# https://github.com/endara-ai/endara-relay/releasesmkdir -p ~/.endara
cat > ~/.endara/config.toml << 'EOF'
# The [relay] table is optional — when omitted, machine_name defaults to your
# system hostname. The minimal config is just a list of endpoints.
[[endpoints]]
name = "filesystem"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-filesystem", "/Users/me/projects"]
[[endpoints]]
name = "github"
transport = "stdio"
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
env = { GITHUB_TOKEN = "$GITHUB_TOKEN" }
EOFendara-relay start --config ~/.endara/config.tomlPoint Claude Desktop (or any MCP client) to http://localhost:9400/mcp. You'll see tools from all configured endpoints in a single list, prefixed with the endpoint name:
filesystem__read_filefilesystem__write_filegithub__list_reposgithub__create_issue
The config file is TOML. Here's a complete reference:
# The entire [relay] table is optional. Omit it and every field below takes
# its default; machine_name falls back to your system hostname.
[relay]
machine_name = "my-laptop" # Optional — defaults to the system hostname
local_js_execution = true # Optional — enable JS execution mode (default: false)
toon_output = true # Optional — convert JSON tool responses to TOON (default: true)
validate_inputs = true # Optional — validate tools/call arguments against each
# tool's inputSchema before forwarding (default: true)
startup_init_timeout_secs = 60 # Optional — cap on how long the MCP listener waits for
# adapter init before binding 9400 anyway (default: 60)
log_retention_days = 7 # Optional — days of daily-rotated relay.log.YYYY-MM-DD
# files to keep; older files are pruned at startup.
# 0 disables pruning (default: 7). CLI: --log-retention-days
# STDIO endpoint — spawns a child process
[[endpoints]]
name = "github" # Required — unique name, used as tool prefix
transport = "stdio" # Required — "stdio", "sse", or "http"
command = "npx" # Required for stdio — command to run
args = ["-y", "@modelcontextprotocol/server-github"] # Optional — command arguments
env = { GITHUB_TOKEN = "$GITHUB_TOKEN" } # Optional — environment variables
# Optional container isolation (stdio only) — run the server in Docker / Podman
isolation = "container" # Optional — "container" or "none" (default: "none")
container_image = "ghcr.io/endara-ai/mcp-runner:latest" # Optional — defaults to mcp-runner
mounts = ["/Users/me/projects:/projects"] # Optional — host:container bind mounts
# SSE endpoint — connects to a Server-Sent Events MCP server
[[endpoints]]
name = "remote-server"
transport = "sse"
url = "http://localhost:3001/sse" # Required for sse/http — server URL
# HTTP endpoint — connects via JSON-RPC over HTTP
[[endpoints]]
name = "http-server"
transport = "http"
url = "http://localhost:4000/mcp" # Required for sse/http — server URL
# Optional — override the advertised server_type
# By default the relay derives the server_type from the upstream
# `serverInfo.name`, then strips one of the suffixes
# `-mcp-server`, `_mcp_server`, `-mcp`, `_mcp` (so `linear-mcp-server`
# becomes `linear`). When that produces an awkward name, set
# `server_type_override` to take control of what is advertised to the
# model. The override is sanitized to a valid identifier but is **never**
# auto-stripped — what you write is what gets used.
[[endpoints]]
name = "drive"
transport = "oauth"
url = "https://drivemcp.googleapis.com/mcp/v1"
oauth_server_url = "https://accounts.google.com"
client_id = "$GOOGLE_CLIENT_ID"
server_type_override = "google-drive" # Optional — overrides upstream-derived server_type
# Enterprise-Managed Authorization (EMA) — authenticate an endpoint through
# your organization's identity provider instead of a per-server OAuth flow.
# Declare the org once, then reference it from any number of endpoints.
[[organizations]]
name = "Acme Corp" # Required — stable key referenced by endpoints
provider = "okta" # Required — "okta", "entra", "google", "ping", or "custom"
idp = "https://acme.okta.com" # Required — IdP issuer URL
# client_id = "..." # Optional — pre-registered OAuth client_id for this IdP;
# when omitted, the relay falls back to CIMD → DCR
[[endpoints]]
name = "github-acme"
transport = "http"
url = "https://api.githubcopilot.com/mcp/"
[endpoints.auth]
type = "ema" # Required — currently the only supported auth type
organization = "Acme Corp" # Required — references an [[organizations]] entry
resource = "https://api.githubcopilot.com/mcp/" # Required — MCP server URL the token is scoped to
# Optional per-endpoint resource client_id/client_secret (needed by some MCP
# Authorization Servers) are never stored in config.toml — supply them via
# POST /api/endpoints/{name}/credentials on the management API.
# Endpoint profiles — serve named subsets of the endpoints above
# at /mcp/{path}. Clients pointed at the prefixed URL see only the
# tools from the listed endpoints. See "Endpoint profiles" below.
[[profiles]]
name = "Work"
path = "work"
endpoints = ["github", "drive"]
js_execution = true
toon_output = true
[[profiles]]
name = "Personal"
path = "personal"
endpoints = ["filesystem"]
js_execution = false
toon_output = falseEnvironment variables in env maps are resolved at startup:
| Syntax | Behavior |
|---|---|
$VAR |
Replaced with the value of VAR from the process environment |
$$VAR |
Literal string $VAR (escape with double $) |
plain |
Kept as-is |
- At least one endpoint must be configured
- Endpoint names must be unique and non-empty
stdiotransport requires acommandfieldsseandhttptransports require aurlfield
Connect to any MCP server regardless of how it communicates:
- STDIO — Spawns a child process and communicates over stdin/stdout. Ideal for local CLI-based MCP servers like the official
@modelcontextprotocol/server-*packages. - SSE — Connects to a remote server using HTTP + Server-Sent Events. Good for servers that push updates.
- HTTP — Standard JSON-RPC 2.0 over HTTP POST. The simplest remote transport.
Every tool is automatically prefixed with its endpoint name to prevent collisions. If endpoint github exposes a tool called list_repos, it becomes github__list_repos in the merged catalog. This means you can connect multiple servers that expose identically-named tools without conflicts.
Tools aren't the only thing the relay aggregates. Upstream MCP resources and prompts are merged and proxied too: resources/list, resources/read, resources/templates/list, prompts/list, and prompts/get all route through the relay. Prompt names are prefixed with the endpoint name using the same scheme as tools, and resource URIs are wrapped in a reversible per-endpoint namespace (mcp-relay://{endpoint}/{percent-encoded-uri}) so reads route back to the right server without a lookup table. This makes MCP Apps (ui:// resources) and prompt catalogs work through the relay, and it works in JS execution mode too — the meta-tool catalog reduction applies only to tools/list.
Relay watches your config file for changes using the notify crate. When you save the file, Relay automatically:
- Starts adapters for newly added endpoints
- Stops adapters for removed endpoints
- Restarts adapters for changed endpoints
- Leaves unchanged endpoints running
No restart required.
If a STDIO server process dies unexpectedly, Relay respawns it automatically with exponential backoff (1s doubling up to a 60s cap). After 3+ crashes in 60 seconds the endpoint is marked unhealthy — but Relay keeps retrying at the cap indefinitely, so a multi-minute outage (say, your container runtime restarting) self-heals without a manual restart. Once the server comes back, the merged tool catalog refreshes automatically. Plain HTTP servers get equivalent treatment: an upstream that stops responding is marked unhealthy after a few consecutive transport failures, and recovers automatically on the next successful request. This keeps your tool catalog available even when individual servers are flaky.
Two failure modes that used to require manual intervention now recover on their own:
- Stale dynamically-registered clients — if an authorization server purges the OAuth client the relay registered via DCR, Relay registers a fresh client on the next interactive authorize and discards stale credentials when a token endpoint answers
invalid_client. Manually-supplied credentials are never auto-discarded or re-registered. - Transient discovery failures — if the OAuth server is unreachable when an authorization flow starts, Relay reports a clear connectivity error (
discovery_unreachable) instead of composing a guessed authorize URL that lands you on a dead page. Servers that genuinely publish no RFC 8414 metadata still fall back to convention-based endpoints.
STDIO servers can run inside a container instead of directly on your machine. Set isolation = "container" on a stdio endpoint and Relay autodetects Docker or Podman, runs the server in the ghcr.io/endara-ai/mcp-runner image (override with container_image), and grants no host filesystem access unless you list mounts ("host/path:/container/path"). When no container runtime is present, Relay falls back to spawning the process directly so the endpoint keeps working. Container stderr is captured into the endpoint logs just like a direct spawn.
Before forwarding a tools/call to the upstream server, Relay validates the supplied arguments against that tool's advertised JSON Schema inputSchema. Malformed calls are rejected at the relay with a schema error instead of reaching the server. This is on by default; set validate_inputs = false under [relay] to bypass it for servers with deliberately loose schemas. The toggle is hot-reloadable.
Profiles are named subsets of your registered endpoints served under their own MCP URL. Pointing a client at http://localhost:9400/mcp/{profile} (or http://localhost:9400/mcp/sse/{profile} for legacy SSE clients) exposes only the tools from the endpoints in that profile's allow-list, so different agents or clients can share one relay without sharing one catalog. The unprefixed /mcp URL continues to serve the union of every enabled endpoint.
Each profile owns its own local_js_execution and toon_output values independent of the global [relay] defaults — one profile can serve raw JSON while another keeps TOON encoding on, from the same relay process. Profiles can be edited in TOML or managed through Endara Desktop's Profiles tab and the management API.
The management API exposes a Server-Sent Events stream at GET /api/events/tool-calls that publishes every MCP tool call routed through the relay, with lifecycle (in-flight, success, failure), duration, upstream endpoint, tool name, and the identity of the calling MCP client (captured from each session's initialize request). This is what powers the Endara Desktop tool-call overlay; you can subscribe directly for custom dashboards or telemetry pipelines.
Beyond the live event stream, Relay keeps a durable record of every tool call so you can review history after the fact. Metadata (endpoint, tool, timing, success/failure, byte counts) is written to an on-disk SQLite store, and full request/response payloads are held in an in-memory ring buffer for a configurable window. Configure it under [relay.observability] (retention, size caps, payload window — all optional with sensible defaults) and query it through the /api/observability/* management endpoints. This powers Endara Desktop's Observability tab.
When local_js_execution = true, Relay replaces the full tool catalog with three meta-tools:
| Meta-tool | Description |
|---|---|
list_tools |
List all available tools across all endpoints |
search_tools |
Search tools by name or description |
execute_tools |
Run a JavaScript script that can call any tool |
This dramatically reduces context window pollution. Instead of exposing hundreds of tools to the AI, it sees only three. The AI writes short JS scripts to discover and call the tools it needs.
Example: the AI calls execute_tools with:
const repos = await call("github__list_repos", { org: "endara-ai" });
const issues = await call("github__list_issues", { repo: repos[0].name });
return { repos: repos.length, firstRepoIssues: issues };The JS sandbox is powered by boa_engine and runs entirely in-process — no external runtime needed.
By default, Relay converts JSON tool responses to TOON (Token-Oriented Object Notation) before they reach your MCP client. TOON is an indentation-driven format with tabular array headers that produces ~40–60% fewer tokens than JSON on the structured shapes most MCP tools return, while remaining losslessly round-trippable.
Scalars, non-JSON text, image/resource content, structuredContent, and error responses pass through unchanged. To opt out, set toon_output = false under [relay] in your config, or pass --no-toon on the command line.
Relay exposes a management REST API for monitoring and control. The API is reachable only through an OS-local Unix-domain socket (Linux/macOS) or Named Pipe (Windows) created per-user with 0600 permissions; it is not bound to TCP.
| Platform | Path |
|---|---|
| Linux | $XDG_RUNTIME_DIR/endara-relay-<suffix>/api.sock (fallback: <data-dir>/api.sock) |
| macOS | $TMPDIR/endara-relay-<uid>-<suffix>/api.sock |
| Windows | \\.\pipe\endara-relay-<user-sid> |
<suffix> is a stable hash of the relay's data directory, so two relays running against different data dirs get distinct sockets. The exact path is logged at startup and is also reported by GET /api/status.
A curated subset of the API:
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/status |
Relay status, uptime, endpoint/health counts, resolved socket path |
GET |
/api/endpoints |
List all endpoints with health and transport info |
GET |
/api/catalog |
Full merged tool catalog with applied prefixes and current availability |
GET |
/api/endpoints/:name/tools |
List tools for a specific endpoint |
GET |
/api/endpoints/:name/logs |
View stderr logs for a STDIO endpoint |
POST |
/api/endpoints/:name/restart |
Restart a specific endpoint |
POST |
/api/endpoints/:name/refresh |
Re-fetch the tool catalog for an endpoint |
POST |
/api/endpoints/:name/disable / enable |
Hide or restore an endpoint without removing it |
GET |
/api/config |
View current config (env values redacted) |
POST |
/api/config/reload |
Trigger a config reload |
GET |
/api/events/tool-calls |
Server-Sent Events stream of every tool call (in-flight, success, failure, duration, calling client) |
GET |
/api/observability/calls |
Query recorded tool calls (filter by endpoint, tool, status, time window) |
GET |
/api/observability/calls/:request_uid |
Full request/response payload for one recorded call |
GET |
/api/observability/aggregates |
Time-bucketed call counts and latency aggregates |
POST |
/api/observability/purge |
Clear all recorded calls and buffered payloads |
OAuth flows (/api/endpoints/:name/oauth/*, /api/oauth/setup/*) and per-tool enable/disable endpoints are documented in full on the Endara Relay docs.
Example (Linux):
curl --unix-socket "$XDG_RUNTIME_DIR/endara-relay-<suffix>/api.sock" http://localhost/api/status- Rust (stable, 2021 edition)
- macOS, Linux, or Windows
git clone https://github.com/endara-ai/endara-relay.git
cd endara-relay
cargo build --releaseThe binary will be at target/release/endara-relay.
# Unit tests
cargo test
# All tests (including integration tests)
cargo test --all-targetsReleases are automated via GitHub Actions. To create a new release:
- Tag the commit:
git tag v0.1.8 && git push origin v0.1.8(release candidates usevX.Y.Z-rc.N) - The release workflow automatically:
- Builds release binaries for all platforms (Linux x86_64/aarch64, macOS x86_64/aarch64, Windows x86_64)
- Creates a GitHub Release with the tag
- Uploads platform binaries as release assets
Binary naming convention: endara-relay-{target_triple} (e.g. endara-relay-aarch64-apple-darwin, endara-relay-x86_64-pc-windows-msvc.exe)
The Endara Desktop release workflow downloads these binaries to bundle as a Tauri sidecar.
On every push and PR, the CI workflow runs:
cargo fmt --check— formattingcargo clippy -- -D warnings— lintingcargo test— unit testscargo test --test '*'— integration tests- Cross-platform build matrix (Linux, macOS, Windows)
Prefer a UI to running a binary from a terminal? Endara Desktop bundles Relay as a sidecar and adds an endpoint dashboard, log viewer, and one-click OAuth flows. It installs from the same Homebrew tap (brew install --cask endara-ai/tap/endara) and is built on top of this repo. More at endara.ai.
The relay's threat model — including its trust boundaries, the management API's UDS/Named-Pipe isolation, and the OAuth callback's localhost-only CSRF protections — is documented in THREAT_MODEL.md. See SECURITY.md for the responsible-disclosure process and response-time expectations.
Contributions are welcome! Here's how to get started:
- Fork the repository
- Create a branch for your feature or fix (
git checkout -b my-feature) - Make your changes and ensure tests pass (
cargo test) - Run formatting and lints (
cargo fmt && cargo clippy) - Submit a pull request with a clear description of your changes
Please open an issue first for large changes or new features so we can discuss the approach.
- Website: endara.ai
- Desktop app: endara-ai/endara-desktop
- Releases: github.com/endara-ai/endara-relay/releases
Licensed under the Apache License, Version 2.0.
Copyright 2025–2026 Endara AI
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.