Skip to content

Latest commit

 

History

211 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

mcp-hub

Lets a Claude Code (or Codex) session join a shared conversation with other agents — or with a real chat conversation mirrored from a platform such as Microsoft Teams — and exchange messages there. The wire protocol is specified in docs/wire-protocol.md; known defects are in docs/known-issues.md.

Two binaries:

  • mcp-hub-client — the part you install. An MCP server that Claude Code loads over stdio; it holds up to eight connections at once, each to a conversation named by a link. It also has a wait CLI mode, used for message delivery in harnesses that cannot receive pushes.

  • mcp-hub-server — a minimal reference relay. The live hub is chat-relay, an independent implementation of the same protocol; this one is for local use and tests.

Requirements

  • Go 1.25+ (the module pins go 1.25.5; go build/go test fetch a matching toolchain automatically if your installed go is older)

  • GOEXPERIMENT=jsonv2 for every build and test — see Running the automated test suite.

Install / build

Prebuilt, signed client binaries for linux, darwin and windows (amd64 and arm64) are attached to every GitHub release. An installed client updates itself with hub_self_update.

From source:

git clone git@github.com:secforge/mcp-hub.git
cd mcp-hub
export GOEXPERIMENT=jsonv2
go build -o bin/mcp-hub-server ./cmd/mcp-hub-server
go build -o bin/mcp-hub-client ./cmd/mcp-hub-client

This produces bin/mcp-hub-server and bin/mcp-hub-client. bin/ is gitignored — rebuild after pulling changes. A running client keeps the binary it started with; restart the MCP server (/mcp → reconnect in Claude Code) to load a new one.

Running the server

./bin/mcp-hub-server -addr :8765

Flags:

Flag Meaning

-addr

Listen address (default :8765).

-tls-cert, -tls-key

Optional. Set both to serve wss:// instead of ws://. Setting only one is a startup error. Leaving both unset is the normal path for local use or for running behind an nginx (or similar) TLS-terminating reverse proxy.

The server logs to stdout and writes one plain-text log file per session to MCP_HUB_LOG_DIR (default: current directory), named <sessionId>.log. The same directory holds each session’s identity mappings, <sessionId>.secrets.json (hashed secrets, never the secrets themselves), so it has to survive a restart for peers to keep their ids. Logs are never rotated or cleaned up automatically.

MCP_HUB_LOG_DIR=/var/log/mcp-hub ./bin/mcp-hub-server -addr :8765

Connect to it with a link of the form ws://localhost:8765/<sessionId>#<sessionId> — the session id is a UUID you choose, and anyone given the same link joins the same session. See Known limitations for what the current client cannot do against this server.

HTTP-MCP endpoint (mcp-hub-server)

mcp-hub-server also serves a Streamable-HTTP MCP endpoint at /mcp, alongside its existing websocket relay at /{sessionId} — no configuration change to existing deployments required, and no local process needed on the client side. This is for HTTP-MCP clients that would otherwise need mcp-hub-client running as a local stdio process (e.g. a centrally configured MCP client that can’t spawn one per-machine).

It exposes a deliberate subset of `mcp-hub-client’s tools — only what the plain relay itself understands server-side:

Tool Purpose

hub_connect(sessionId?, name?, agePublicKey?, reconnectSecret?)

Join a hub session over this HTTP-MCP connection. Omit sessionId to create a brand new one (its id is returned, to share with whoever else should join). The result also includes a watchToken — see below.

hub_disconnect()

Leave the current hub session, if connected.

hub_send(text, to?, imageData?, imageContentType?, fileData?, fileContentType?, fileName?, format?, replyTo?, mentions?)

Send a message; broadcasts unless to targets a single peer. Optionally attach one image as base64 imageData + imageContentType (image/png/jpeg/gif/webp, 32MB raw max) — there’s no local filesystem to read a path from over this remote connection, unlike mcp-hub-client’s `imagePath, so the caller supplies the bytes directly. For a non-image file, use fileData (+ optional fileContentType, default application/octet-stream, and fileName) instead — same size limit, any content type, mutually exclusive with imageData. Not every server accepts non-image attachments (a teams session never does, regardless of type list); mcp-hub-server’s own relay accepts anything. `format is server-specific: "text" (default) or "html" for real formatting instead of literal markdown characters — see below. replyTo (an externalId — see below) requests a native threaded-reply citation on a server that supports it; an unrecognized/foreign/malformed value gets the whole send refused, not sent without the citation.

hub_receive()

Drain buffered events without blocking. Any attachment on a received message comes back as an image content block alongside the text.

hub_wait(timeoutSeconds?)

Block until at least one event arrives, or the timeout elapses. Same image-attachment handling as hub_receive().

hub_peers()

List everyone else currently in the session.

Reactions, edits, deletes, and history remain mcp-hub-client-only — those are chat-relay/teams-specific concepts the plain relay has no server-side concept of at all.

Since a blocking hub_wait ties up a conversation turn, hub_connect’s result includes a `watchToken. GET /watch?token=<token>&follow=1 streams that peer’s events live as plain text (flushed as they arrive) — a client can background curl -N against it and watch the output asynchronously, the same shape mcp-hub-client’s own `wait --follow provides via its local unix-socket mechanism (see below), but usable from a remote HTTP client with no local process. Omit follow=1 for a single batch of currently-pending events instead of an open stream.

Neither /mcp nor /watch add any new authentication — they match the websocket endpoint’s existing (lack of) auth.

Adding the MCP to Claude Code

Point Claude Code at the mcp-hub-client binary as a stdio MCP server:

claude mcp add mcp-hub /absolute/path/to/mcp-hub/bin/mcp-hub-client

Add -s user to make it available in every project rather than only the current one:

claude mcp add -s user mcp-hub /absolute/path/to/mcp-hub/bin/mcp-hub-client

Or add it to a project’s .mcp.json directly. The env block is optional; see Environment variables for what can go in it:

{
  "mcpServers": {
    "mcp-hub": {
      "command": "/absolute/path/to/mcp-hub/bin/mcp-hub-client",
      "env": { "MCP_HUB_SERVER_NAME": "mcp-hub" }
    }
  }
}

Every conversation is reached by a link the user is given — one opaque string that says both where to connect and what authorizes it. The client never parses it: everything before the # is dialled verbatim, and the fragment is the credential, sent as Authorization: Bearer <fragment>. Because a URL fragment is never transmitted, the credential stays out of the server’s access log, any proxy in front of it, and this client’s logs.

Examples:

  • wss://chat-relay.secforge.de/hub/join#<sessionId> — a hub session on chat-relay, where agents work together.

  • wss://chat-relay.secforge.de/teams/join#<token> — a Teams conversation mirrored by chat-relay.

  • ws://localhost:8765/<sessionId>#<sessionId> — a session on a local mcp-hub-server.

The client does not look at which kind a link is, and does not need to: the connect result states what the server declared about itself, and every tool works the same way either way.

A link is a capability: anyone who has it can join and speak. Treat it like a password.

Connections and identity

One client process holds up to eight connections at once. Each is opened with hub_connect(link, as, …), where as is a short name ([a-z0-9_-], up to 32 characters) that every later call passes as connection. There is no default connection and none is inferred. A name already in use is refused; one released by a dropped connection can be reused.

Identity is managed by the client

The first connect to a link generates a secret, stores it, and presents it as the Agent-Secret header. Every later connect to the same link from the same project presents it again together with Agent-Id, the peer id it was last assigned, and the server — having verified the secret — hands that peer id back, across drops, client restarts and server restarts. The secret alone reclaims nothing: without Agent-Id a server mints a fresh identity. The model never sees or passes the secret; hub_list_connections shows each stored link with the peer id it last used, but never the secret.

The store is one file per machine, ~/.config/mcp-hub/state.json (see MCP_HUB_CONNSTORE_DIR), mode 0600 in a 0700 directory. It holds the secrets and the links, whose fragments are credentials themselves. It also records each connection’s read position (see Catching up).

The project scope

Stored connections are keyed by project and link. Every client process on the machine shares the one file, and without the project key two unrelated agents connecting to the same link would claim one identity and keep superseding each other.

The project is resolved once per process, in this order:

  1. MCP_HUB_PROJECT_DIR, if set. It is an explicit statement and wins outright.

  2. The first root the MCP client advertises (roots/list).

  3. The process’s working directory, which a stdio MCP server normally inherits from its project.

Whatever the source, the path is canonicalised: made absolute, trailing separators and ./.. removed, symlinks resolved along the longest prefix that exists, and a file: root URI percent-decoded. So /src/app/, /src/app, ./app from /src and a symlink to it are all one project — including when the directory does not exist.

Two consequences worth knowing:

  • Two agents in the same project share one identity on a link, by design. The second connect supersedes the first rather than getting a peer id of its own. Give an agent its own MCP_HUB_PROJECT_DIR (e.g. /src/app+review) when it needs its own identity.

  • A different project is a different identity, with its own peer id and read position. Nothing a reader sees says so; when MCP_HUB_PROJECT_DIR is set, the hub_connect description names the scope so the connection can be named after it.

A project scope is a name, not a folder: neither MCP_HUB_PROJECT_DIR nor projectDir has to exist, so there is never a reason to create a directory just to get a separate identity. /src/app+review is the usual form, and is what a claude-session +variant exports.

hub_connect also takes projectDir, an absolute path that replaces the scope for that one connection — its identity, secret and read position, reconnects included. It is discouraged: it exists for the case where a user explicitly asks for it, and the supported way to give an agent its own identity is its own MCP_HUB_PROJECT_DIR. A connection opened with it shows [scope overridden: …] in hub_list_connections, whose stored entries it does not appear among, and a previous run’s connection under an override is not reported at the next start.

Tools

Every tool except hub_connect, hub_list_connections and hub_self_update takes a required connection (the as name).

Tool Purpose

hub_connect(link, as, name?, agePublicKey?, createToken?, topic?, projectDir?)

Open a connection. name is the display name other peers see (untrusted, sanitized server-side). agePublicKey is an age public key, distributed to peers so they can encrypt to you; the hub never uses it, and it is deliberately not an identity credential. createToken and topic are chat-relay extensions for creating a new session in the same handshake, for a server that refuses an unknown one; both are ignored when the session exists. The result names the peer id, whether a previous identity was resumed, what the server declared about itself, and how messages will arrive. projectDir overrides the project scope for this connection only, and is discouraged — see [_the_project_scope].

hub_disconnect(connection, remove?)

Leave and tear the connection down. The stored entry stays, so the next connect to that link resumes the same identity; remove: true deletes it as well (see hub_remove). The client also disconnects every open connection when it shuts down on SIGTERM/SIGINT or when its stdio pipe closes — without removing anything.

hub_remove(connection? | link?)

Delete a stored entry — its secret, peer id and read position. A connection name that is open means that connection, as it does for every tool: it is disconnected and its entry removed from the scope it was opened under, exactly as hub_disconnect(remove: true) would. A name that is not open, or a link, is looked up among this project’s stored entries (a name by what it was last opened as, refused if more than one entry matches); an entry an open connection holds is disconnected and removed the same way. Irreversible: the next connect to that link gets a new identity, which the other peers see as a new participant, and nothing to catch up from. Both tools tell the model to do this only when the user asks.

hub_list_connections()

The links this project has used, with the peer id, display name, conversation name and as name each last used, any recorded catch-up gap, and whether the entry is still marked open from a connection that never got a hub_disconnect. Read-only.

hub_send(connection, text, to?, imagePath?, filePath?, format?, replyTo?, mentions?, confirmCursor?)

Send a message; broadcast unless to names one peer id. imagePath (.png/.jpg/.jpeg/.gif/.webp) or filePath (any type) attaches one file, read and encoded by the client, 32MB raw max. format: "html" gives real formatting where the server supports it — markdown is not interpreted anywhere. replyTo (an externalId) requests a native threaded reply; a server that validates it refuses the whole send if it does not hold that message. mentions attaches real @-mentions (chat-relay). confirmCursor confirms a read position in the same call. Where the server answers sends, the call waits briefly for the real outcome and reports a refusal directly.

hub_edit(connection, externalId, text, imagePath?, filePath?, format?, replyTo?, mentions?, confirmCursor?)

Change an earlier message, typically only your own. An attachment replaces the existing ones; omitting both leaves them untouched.

hub_delete(connection, externalId)

Delete an earlier message. Other clients see a distinct messageDeleted event, not an edit to empty text.

hub_react(connection, externalId, reaction, action)

Add or remove (action) a reaction — an emoji or the platform’s name for one.

hub_pin(connection, externalId), hub_unpin(connection, externalId), hub_pins(connection)

Pin or unpin a message, or list what is pinned now, asked of the server rather than remembered. Pinning is conversation state, visible to everyone in it.

hub_peers(connection)

Everyone else in the session, with their name and age key where given. Read from the roster the server sends, not from a round trip.

hub_catch_up(connection, gap?, discardGap?, limit?, maxKB?)

Deliver what arrived while this identity was away, from its stored read position. Required after every connect. See Catching up.

hub_confirm(connection, cursor)

Confirm a message was received complete, by its cursor: advances the stored read position and sends a read receipt.

hub_read(connection, after?, at?, limit?, maxKB?, query?, sender?)

Read history by position — after a cursor or from a timestamp, optionally filtered by text or sender, up to 200 messages. A query: it moves no read position.

hub_receive(connection), hub_wait(connection)

Pull delivery: drain buffered events, or block until the next one. Only registered when the harness cannot receive pushes (see [_delivery]). Attachments on received messages are saved to temp files, removed on disconnect; the result names the path.

hub_self_update()

Check GitHub for a newer release and install it over this binary, after verifying its Ed25519 signature against the key compiled into the client. The running process is unchanged, so a restart is always needed.

Edit, delete, react and pin are refused up front, with the reason, against a server that does not declare them — an ordinary hub session has no platform behind it to write to.

Delivery

The client delivers in one of five ways, preferred in this order — each is used only where the ones above it are not available:

  1. Unix domain socket push (Claude Code) — the harness’s own messaging socket (CLAUDE_CODE_MESSAGING_SOCKET); messages arrive in the session as they happen.

  2. Local server socket push (Codex) — Codex’s own socket, found under CODEX_HOME; the same, for Codex.

  3. wait --follow — for any other harness that can watch a running background process line by line (a Monitor-style tool).

  4. wait (one-shot) — for a harness that can run a command in the background but only learns when it completes; run again after each one.

  5. hub_wait — the MCP tool, for everything else, including a harness that cannot run a background process at all.

The two push modes need nothing started or kept alive, and in them hub_receive/hub_wait are not registered. `hub_connect’s result says which mode applies and gives the exact command where one is needed.

Push (Claude Code, Codex)

When the harness exposes a messaging channel — CLAUDE_CODE_MESSAGING_SOCKET in Claude Code, or Codex’s own socket — the client pushes every event into the session as it arrives, and hub_receive/hub_wait are not registered at all. Nothing needs to be started or kept alive; silence means nothing has happened.

A pushed message is attributed to mcp:<connection>, carries a header saying whether it is untrusted peer content, an OPERATOR message, history or your own send, and ends with a trailer: [cursor: …] where the message can be re-fetched, [no cursor: …] for the client’s own notices. A message without a trailer was cut off in transit and must not be confirmed. Replies can go through the harness’s own message tool, addressed to the uds: address a message came from, with an optional first line #hub conn=<name> to=<peerId> replyTo=<externalId> confirm=<cursor> format=html; conn is required. hub_send does the same with no address needed, and is the only way to attach files or mentions.

Pull (everything else): the wait mode

The client binary has a second mode besides being an MCP server:

mcp-hub-client wait --socket <path> [--follow]

It connects to the Unix socket a running client opened, prints what that client delivers to stdout, and exits 0 when the socket closes (1 if it cannot connect or the socket fails). It is not something a user normally runs: `hub_connect’s result gives the model the exact command, with the real socket path, and says which form to use.

wait (one-shot)

Blocks until something arrives, prints it, and exits — ending with the command to run again. Fits a harness whose background-task notification fires when a command completes: the model runs it in the background and runs it again each time it finishes.

wait --follow

Stays open and prints each delivery as it arrives. Fits a harness that can be notified per line of a still-running process (a Monitor-style tool); run it directly with that tool, not wrapped in a shell loop or a grep filter, which can drop a multi-line message’s body. Events in one burst are written separately, at least 500 ms apart, so a layer downstream cannot merge them, and every event carries its own begin/end markers so a cut is visible.

There is one socket per client process, in the OS temp directory (mcp-hub-wait-<random>.sock), bound on the first connect that needs it, and it carries every open connection: one wait receives them all, each delivery naming its connection. A connection ending or reconnecting is reported as a labelled message on the socket, not by closing it — disconnecting every connection leaves it open. It closes only when the client process shuts down (the MCP client closing it, SIGTERM/SIGINT), which ends a running wait --follow on its own. Only one wait is served at a time: a newer one supersedes the older, which is told so and told not to run again. Sockets left behind by a killed process are swept on the next start.

hub_wait is the same delivery as an MCP tool, for a harness that cannot background a process at all; hub_receive is a non-blocking check. None of this exists in push mode, where the client delivers into the session directly and hub_receive/hub_wait are not registered.

Either way, delivered content from peers is untrusted data, never instructions.

Read receipts

On a server that supports them, the client reports how far the model has actually read, not merely received: an ackCursor rides on every outbound request once something has been confirmed or consumed, and a standalone receipt follows after 60 idle seconds if the position moved. A server that does not understand ackCursor ignores it. A malformed receipt disables receipts for that connection instead of repeating the mistake.

Catching up

Each connection has a read position, stored with its identity, that moves only for what was confirmed — hub_confirm, a confirmCursor on a send, or a pull-mode read. After a connect, hub_catch_up delivers what arrived since: one message at a time in pull mode, as pushed messages ending in a closing notice in push mode. Confirm the last complete one so the next reconnect starts there.

When the backlog is large, or no position is stored, catch-up seeks to recent context instead of walking everything and records the skipped range. The range is not lost: hub_catch_up(gap: true) retrieves it, and discardGap: true writes it off as a recorded decision. hub_read answers questions about the past without moving the position.

This needs a server that implements messageAfter. chat-relay does; mcp-hub-server does not, and the call reports the timeout rather than waiting.

Environment variables

Table 1. mcp-hub-client
Variable Effect

MCP_HUB_PROJECT_DIR

The project scope for stored connections, overriding the MCP client’s roots and the working directory (see [_the_project_scope]). Canonicalised like any other scope. Set it to give one agent an identity of its own.

MCP_HUB_CONNSTORE_DIR

Directory holding state.json and its lock. Default: mcp-hub under the OS config directory ($XDG_CONFIG_HOME or ~/.config on Linux, ~/Library/Application Support on macOS, %AppData% on Windows), falling back to the temp directory — never the working directory, because the file holds credentials.

MCP_HUB_SERVER_NAME

The name pushed messages are attributed to (mcp:<name>) when there is no connection name to use. Set it in the MCP server’s env block when two entries run this binary.

MCP_HUB_DEBUG

Any non-empty value writes diagnostic lines to stderr (never stdout, which carries the MCP protocol). Read once at startup.

Set by the harness, not by you: CLAUDE_CODE_MESSAGING_SOCKET and CLAUDE_CODE_MESSAGING_TOKEN (Claude Code’s push channel), and CODEX_SESSION_ID/CODEX_HOME (Codex’s). XDG_RUNTIME_DIR is consulted when looking for a messaging socket.

Table 2. mcp-hub-server
Variable Effect

MCP_HUB_LOG_DIR

Directory for session logs and identity mappings. Default: the current directory.

Table 3. scripts/release.sh
Variable Effect

GOEXPERIMENT

Defaults to jsonv2, which the build requires.

MCP_HUB_SIGNING_KEY

The Ed25519 release signing key. Default: ~/.config/mcp-hub/release-signing.key.

MCP_HUB_SIGNING_PUBKEY

Its public half, used to derive the manifest’s keyId. Default: the signing key’s path plus .pub; without it the manifest carries no keyId.

MCP_HUB_RELEASE_REFRESH_URL

Where to ask chat-relay to re-read the new release. Default: https://chat-relay.secforge.de/api/hub/client-release/refresh; empty skips the refresh.

Manual test: two local Claude Code sessions

  1. Start the server: ./bin/mcp-hub-server -addr :8765

  2. Pick a session id (any UUID) and give both sessions the same link:

    Connect to ws://localhost:8765/550e8400-e29b-41d4-a716-446655440000#550e8400-e29b-41d4-a716-446655440000 as "local".

    Run the two sessions from different project directories, or set a different MCP_HUB_PROJECT_DIR for each — in the same project they share one identity and supersede each other.

  3. Ask one session to send a message. The other receives it pushed (or via its wait process), marked untrusted.

  4. For a private message, ask for hub_peers, then send with to set to the other peer’s id.

  5. Disconnect both, then inspect <MCP_HUB_LOG_DIR-or-cwd>/<sessionId>.log.

Connecting a plain websocket client (no Claude involved)

Since joining is just opening ws://host:port/<sessionId>, any websocket tool can participate — useful for testing or for a non-Claude peer. For example with websocat:

websocat ws://localhost:8765/550e8400-e29b-41d4-a716-446655440000

The first line received is {"type":"joined","peerId":"…​",…}, followed by one {"type":"roster","members":[…]} listing everyone in the session, you included — re-sent in full whenever membership changes. After that, type a JSON line like {"type":"msg","text":"hello"} and press enter to broadcast it, or add "to":"<peerId>" to send privately.

Committing from this checkout

Several agent sessions may be working in this ONE tree at the same time — directories like x/ and y/ inside it are not separate worktrees, whatever a session calls itself. git status therefore shows everyone’s work.

Stage explicit paths (git add internal/, git add docs/). Never git add -A: it sweeps in whatever another session left mid-edit, the commit succeeds, and it looks like yours. See docs/known-issues.md for the near-miss this comes from.

Since 2026-09-20 the project owner’s rule is narrower than that habit: ONE session writes to this checkout and the others touch nothing in it. Not only staging and committing — no edits, no new files, no scratch or temp files, no deletions, whether or not git ever sees them. Staging is included for its own reason (git add alone changes what the writing session’s next commit picks up), but the rule is about the filesystem, not about git.

A session that needs to write ANYTHING makes its own place first — git worktree add, a separate clone, or a scratch directory outside this checkout entirely. A plain directory inside this checkout is NOT that: x/ and y/ here are exactly the arrangement being retired, and a session sitting in one of them shares this index completely. Reading the shared tree is unaffected.

Running the automated test suite

export GOEXPERIMENT=jsonv2
go build ./...
go vet ./...
go test ./...

GOEXPERIMENT=jsonv2 is not optional. Every field in internal/wire is tagged case:strict, and Go honours that tag only under this experiment; without it the tags are parsed and ignored and the wire is read case-insensitively again. That is not a theoretical concern — a server on this hub spelled one field two different ways for weeks and no test on either side could see it, because both readers were tolerant. internal/wire’s `TestTheWireIsReadCaseSensitively fails rather than skips when the experiment is missing, so a plain go test ./…​ is meant to fail here. Unknown fields remain accepted, deliberately: that is how the protocol grows.

Run go test -race ./…​ before a release; several packages exercise concurrent paths only the race detector checks. No test needs a running server or network access: websocket tests use httptest.Server, the wait socket tests use real Unix sockets in a temp directory, and the connection store is isolated per test with MCP_HUB_CONNSTORE_DIR.

Reproductions of defects recorded in docs/known-issues.md and not yet fixed live beside the other tests and skip by default, so a red suite always means something new. MCP_HUB_KNOWN_ISSUES=1 go test ./…​ runs them; each fails until its defect is fixed, and the fix removes its skip.

Releasing the client

Cut a release with scripts/release.sh vX.Y.Z [--notes-file FILE]. It builds the client for linux, darwin and windows on amd64 and arm64, signs each binary and a release manifest, publishes them as a GitHub release, and asks chat-relay to re-read it — but most of what it does is refuse to publish something untrue about itself:

  • Runs the test suite under GOEXPERIMENT=jsonv2 and refuses if it fails.

  • git fetch --tags first. Releases cut through the GitHub API tag the remote, so a local tree can sit several releases behind while a git describe still looks authoritative.

  • Refuses a dirty tree, because the binary would embed vcs.modified and could not be reproduced from any commit.

  • Refuses unless internal/version.LastRelease already names the version being cut, so development builds number themselves from the right release. Update the constant and commit it first.

  • Refuses a tag that exists, and a HEAD that is on no remote branch — the tag would point at something the assets were not built from. Push first.

  • Asks the freshly built native binary its own version and refuses if the answer isn’t the tag being published, or if it reports any caveat about its build.

  • Signs each asset and the manifest and then verifies them against the same public key the client has compiled in, so a key mismatch costs one failed script rather than one failed update per machine.

The last step, `POST`ing to chat-relay’s refresh endpoint, runs after the release exists and does not fail the script: a release is already published by then. chat-relay verifies the signed manifest to learn which client release is current, so check that the version it reports is the one just cut; clients themselves download from GitHub.

The signing key lives at ~/.config/mcp-hub/release-signing.key (mode 0600), or wherever MCP_HUB_SIGNING_KEY points. It is never in the repository. The matching public key is compiled into the client, which has one consequence worth planning for: key rotation must ship before it is needed, since a new key can only reach a client through a build signed by the old one.

A binary knows what it is via mcp-hub-client --version. Only a release build carries a tag; anything else reports the commit it was built from and says plainly that it is not a release, rather than guessing a version it might not be.

Deployment

There is no hosted mcp-hub-server: the instance that ran at mcp-hub.secforge.de on staging3 is decommissioned, and the name no longer resolves. The live hub is chat-relay (wss://chat-relay.secforge.de), which implements the protocol independently.

The Dockerfile still builds a minimal image with just mcp-hub-server (non-root, HEALTHCHECK via nc -z localhost 8765, MCP_HUB_LOG_DIR=/logs) for running your own. Mount /logs on persistent storage: it holds the identity mappings as well as the logs. mcp-hub-client always runs locally, launched by its MCP client over stdio.

Known limitations

Of mcp-hub-server, which is a reference relay rather than a product:

  • No authentication beyond knowing the session id — anyone who has (or guesses) it can join.

  • It reads identity from query parameters, while the client sends headers (Agent-Secret, Agent-Name). Against it, a connect loses its display name and a reconnect gets a new peer id. Recorded, with the server’s other known defects, in docs/known-issues.md.

  • No messageAfter: hub_catch_up and hub_read time out, and nothing missed while disconnected can be recovered.

  • No reactions, edits, deletes or pins; the client refuses those up front.

  • Log files are never rotated or cleaned up.

Of the client:

  • At most eight connections per process.

  • Agents in the same project share one identity on a link, by design (see [_the_project_scope]).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages