ask-bridge
Provides a single read-only tool for a remote MCP client to ask questions to a local Hermes Agent, leveraging its memory, session history, and search toolsets to answer.
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ask-bridgewhat did we decide about the API migration in our last session?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
hermes-ask-bridge
Give a remote agent a narrow, read-only way to ask your Hermes Agent a question.
One MCP tool. Streamable HTTP. Bearer protected. It answers questions and does nothing else: no terminal, no file writes, no messaging, no purchases, no trades.
remote agent internet your machine
+---------------+ +-----------+ +---------------------------+
| MCP client | HTTPS | nginx | loopback | ask-bridge -> hermes |
| + bearer key | -------> | TLS + | -------> | (one MCP tool) chat |
| | | exact | | | --oneshot |
+---------------+ | route | | v |
+-----------+ | memory + ledger context |
+---------------------------+Why
Agents are useful in proportion to what they can reach, and dangerous in proportion to how much they can touch. This bridge takes the middle path: a remote agent can ask the agent running on your machine, using the context that machine has - long-term memory, past sessions, a shared scratchpad - without getting a shell on it.
It is deliberately one tool with one argument, and everything else is refused upstream of the model.
Related MCP server: AgentHansa HTTP MCP
Quickstart
git clone https://github.com/<you>/hermes-ask-bridge
cd hermes-ask-bridge
python3 -m venv .venv && .venv/bin/pip install -r requirements.txt
# 1. bearer key. Generated, never chosen, never copied, never shared between
# deployments. It refuses to overwrite an existing key, and it never prints
# the value, so it cannot leak into your shell history.
.venv/bin/python server.py --generate-key --key-file ./bearer.key
# 2. isolation gate. Replace the interpreter path with the one that has Hermes
# installed, since the gate imports Hermes' own tool definitions. Install
# Hermes first if it is missing: the gate cannot run without it.
/path/to/hermes-env/bin/python verify_isolation.py
# 3. run it, loopback only
.venv/bin/python server.py --port 8646 --key-file ./bearer.keyPut a TLS reverse proxy in front (deploy/ask-bridge-nginx.conf), point the remote
platform's MCP configuration at that HTTPS URL with the same key, then check from
the outside:
BRIDGE_URL=https://your-host/mcp-ask BRIDGE_KEY_FILE=./bearer.key \
.venv/bin/python examples/ask.py "Reply with exactly: ok"For a persistent install, use deploy/ask-bridge.service (a systemd user
unit): systemctl --user enable --now, plus loginctl enable-linger $USER if it
must survive logout.
For Hermes agents: implementation runbook
This section is for an AI agent doing the install on behalf of its user, without human judgement calls. Every step ends in a check, and that check is the evidence to report. If a check fails, stop and report it verbatim rather than working around it.
Ask the user first
Two things are inputs, not guesses:
The public HTTPS hostname this will be served on. Do not invent one.
Confirmation that the user wants a remote agent to reach their local agent, and which remote agent that is.
Never ask for, accept, print, or store the bearer key value in chat. The user enters it into the remote platform themselves, through that platform's own secret field. If a user pastes a key into chat, tell them to rotate it.
Step 1. Locate the Hermes environment
The isolation gate imports Hermes' own tool definitions, so it needs the interpreter that has Hermes installed, not the bridge venv.
command -v hermes
python3 -c "import hermes_cli, model_tools; print('hermes import OK')"If that import fails under the system python, find the environment that owns the
hermes launcher and use its bin/python for step 3.
Step 2. Clone and install
git clone https://github.com/<you>/hermes-ask-bridge ~/bridges/ask-bridge
cd ~/bridges/ask-bridge
python3 -m venv .venv && .venv/bin/pip install -r requirements.txtUse an absolute path. A relative working directory breaks the unit later.
Step 3. Check the toolset, then run the gate
BRIDGE_TOOLSETS defaults to search,session_search. Keep the default unless the
user asks otherwise: it is the narrowest set that still answers real questions.
/path/to/hermes-env/bin/python verify_isolation.pyExpect a PASS: toolsets=... line. A BLOCKED line means the tool catalogue does
not match its declared expectation. Do not proceed, and do not edit EXPECTED to
make it pass: report the mismatch, because it means the safety claim in this README
no longer holds on that machine. If the user asks for a different toolset, add its
expectation deliberately and say so in your report.
Step 4. Generate the key without printing it
.venv/bin/python server.py --generate-key --key-file ./bearer.key
ls -l bearer.key # confirm -rw------- ; never cat itOne command. It writes a unique key at mode 0600, never prints the value, and
refuses to overwrite an existing key: silently replacing a live key breaks whoever
was already using it.
Step 5. Test
.venv/bin/python -m unittest discover -s tests
.venv/bin/python -m compileall -q server.py tests verify_isolation.py examples/ask.pyExpect OK and no other output. Do not continue past a failure.
Step 6. Smoke test on loopback, before any proxy exists
.venv/bin/python server.py --port 8646 --key-file ./bearer.key &
curl -s -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8646/mcp \
-H 'Content-Type: application/json' -d '{}'Expect 401. Then ask one authenticated question with examples/ask.py, pointed at
loopback. Expect a real answer, not an error string.
Step 7. Install the service
Copy deploy/ask-bridge.service into ~/.config/systemd/user/, replace the
interpreter path and the %h/... paths to match step 2, set the BRIDGE_* values
the deployment needs, then:
systemctl --user daemon-reload
systemctl --user enable --now ask-bridge.service
systemctl --user is-active ask-bridge.service
loginctl enable-linger "$USER" # only if it must survive logoutBRIDGE_HERMES_BIN must be an absolute path if hermes is not on the unit's
PATH, and a systemd user unit usually excludes ~/.local/bin. The failure symptom
is a generic error on every question with no hermes chat exited line in the log.
Step 8. Put the proxy in front
Add both exact locations from deploy/ask-bridge-nginx.conf to the TLS server block
for the user's hostname. The upstream Host header must be rewritten to the
loopback address, because the middleware trusts it. Apply with
nginx -t && nginx -s reload.
Step 9. Verify from outside, then hand over
Unauthenticated POST to the public URL must return 401. An authenticated question
must return an answer. Then give the user the key file path and tell them to enter
the value into the remote platform themselves.
Report in exactly this shape:
installed: <absolute path>, unit <name> (<active|failed>)
gate: PASS, or BLOCKED plus the line
tests: N passed
loopback: 401 unauthenticated, answered authenticated
public: 401 unauthenticated, answered authenticated
key: written to <path>, mode 0600, value never displayed
not done: <anything skipped, or "nothing">Never claim a step succeeded without the command output that shows it.
Step 10. Rollback
systemctl --user disable --now ask-bridge.service
# remove the two locations from the proxy config, then nginx -s reload
# delete bearer.key, and revoke the key on the remote platformHard rules
Never print, echo, log, or commit the bearer key value, and never pass it on a command line.
Do not add the
webtoolset. From a bridge host it can reach internal services and cloud metadata.Do not give the remote side any action capability: no terminal, no writes, no messaging, no spending.
Do not relax the
OriginorHostchecks to make a browser client work. A browser client is out of scope.One tool, one argument. If a task seems to need more, that task does not belong on this wire.
Configuration
Every value is an environment override with a generic default, so the source runs unchanged on someone else's machine. Put deployment values in the service unit.
Variable | Default | Purpose |
|
| Tool name the remote client sees and calls |
|
| Server name reported over MCP |
|
| Bearer key file, mode |
|
| Hermes CLI to invoke |
|
| Toolsets the answering agent gets |
| (unset) | Pin the answering model; unset inherits local config |
| (unset) | Pin the provider; unset inherits local config |
|
| Rolling hourly question cap |
|
| In-flight question cap |
|
| Seconds allowed per question |
|
| Bytes per generated key; floored at 32 so config cannot weaken it |
|
| Shared scratchpad; |
|
| Characters of ledger tail injected as context |
|
| Wall-clock budget passed to the runner |
| (unset) | Memory lookup endpoint; unset disables |
| (unset) | Extra headers for it, see below |
|
| Seconds allowed for the memory lookup |
|
| Records requested per question |
Relative defaults resolve against the directory holding server.py, not the
process working directory, so both path defaults are safe to leave unset.
The isolation gate and the example client carry their own settings:
BRIDGE_HERMES_LIB (where Hermes is installed, default
/usr/local/lib/hermes-agent) for the gate, and BRIDGE_URL, BRIDGE_KEY_FILE,
BRIDGE_TOOL_NAME, and BRIDGE_TIMEOUT for examples/ask.py.
BRIDGE_HERMES_BIN is the one to watch when installing. A systemd user unit's
default PATH usually does not include ~/.local/bin, so if Hermes lives there,
set it explicitly - otherwise every question fails with the generic error, which
tells you nothing.
Because systemd strips nested double quotes from Environment= values, set
BRIDGE_MEMORY_HEADERS in the simple form rather than as JSON:
Environment=BRIDGE_MEMORY_HEADERS=X-Account=hermes;X-User=default;X-Agent=agentA JSON object works too, if you set it from a shell that does not quote-strip.
What actually enforces the boundary
Be precise here; only some of these layers are real boundaries.
Layer | Enforces |
Bearer key, constant-time compared | Who may call the tool at all |
Loopback bind + exact proxy route | Who may reach the process |
| Which tools the answering agent can use |
| Refuses obvious action/secret requests |
Wrapper prompt around the remote text | Marks it untrusted data, not instructions |
The tool schema is not the boundary, and neither is the prompt filter - both are defence in depth. The boundary is the bearer key plus a toolset allowlist that is verified at boot. A static key grants any holder the question tool, so rotate it if it may have leaked.
The isolation gate fails closed: an unknown BRIDGE_TOOLSETS value, or a tool
catalogue that changed after a Hermes upgrade, refuses to start the service. It runs
from the bundled unit's ExecStartPre, so starting server.py by hand skips it:
run the gate yourself when testing outside the service. Add an
expectation to EXPECTED in verify_isolation.py only after deciding what the new
toolset may expose.
One notable non-goal: the web toolset is excluded on purpose. From a machine like
this, a URL-fetching tool can reach internal services and cloud metadata - an SSRF
hole. Understand that trade before widening toolsets.
Key hygiene
One key per deployment. The threat is not a lucky guess. secrets.token_urlsafe(48)
is 384 bits, so two independent runs land on the same key with probability around
2^-384: not zero, but far below the odds of the hardware failing first. Every
realistic way to end up sharing a credential is human:
Generate, do not choose.
server.py --generate-keywrites a unique key at mode0600and never prints the value, so it cannot land in shell history, a terminal log, or a chat transcript.Never reuse a value from a test, an example, or a doc. A key published anywhere is a public key.
_read_keyrefuses the placeholder values that used to sit in this repo's tests, so copying one will not start the service.Validation cannot do this part for you. The check rejects short and known placeholder values, but it cannot tell a real key from any other 32-plus character string. Someone determined to reuse a published identifier of that shape will still be accepted, which is why the rule matters more than the check.
A minimum length is enforced. Keys under 32 characters are rejected at startup, because a short key is a shared key.
One key, one bridge. Do not point two deployments at one key, and do not let a test share a key with a live bridge.
Rotate on suspicion, and rotate both ends. The key lives in the key file and in the remote platform's secret store. Changing one without the other breaks the wire; changing neither leaves the bridge open.
Fail closed on a bad key file. It must be a regular file, owned by the service user, mode
0600, and not a symlink. The bridge checks all of that at startup and refuses to run otherwise, rather than quietly accepting something predictable.
Behaviour
Throttle - 30 questions per rolling hour, 2 in flight. Both are held in memory and reset on restart, so a burst of test calls cannot lock a peer out.
Timeout - up to 120s of runner wall clock per question.
Errors - the caller always gets one generic message; the real reason goes to journald, never to the remote side. Keeping that server-side breadcrumb is deliberate: losing it is the biggest time sink when debugging a wire like this.
Question text - reaches the answering agent only through a private
--query-file, never through a shell, argv, or the environment.
Optional context: memory and ledger
Two features, both optional.
Shared ledger. The bridge appends a one-line Q/A summary after each answer and injects the recent tail as context before the next one, so two agents stop losing open loops between them. It is plain markdown, so either side can read it. Keep it out of version control - it accumulates whatever the two agents discuss.
Memory lookup. With BRIDGE_MEMORY_URL set, the bridge POSTs a query and
prepends the results as context:
// request
{"query": "…up to 512 chars…", "limit": 4}
// response - any of these shapes is accepted
{"result": {"memories": [ {"uri": "mem://a", "abstract": "…"} ]}}
{"memories": [ … ]}
{"results": [ … ]} {"data": [ … ]} {"items": [ … ]} {"hits": [ … ]}
[ {"uri": "…", "abstract": "…"} ]Each record needs a text-ish field (abstract, summary, text, or content).
An id-ish field (uri, id, or path) is shown when present and rendered as ?
when not, and a record with no usable text is skipped. Transport, HTTP, and JSON
failures are logged to the server log. A reply that simply matches none of the
shapes above yields no context and is not logged. Either way the question is still
answered from the rest of the context: memory is an enhancement, never a dependency.
Tested against http://127.0.0.1:1933/api/v1/search/find (OpenViking) using the
header form above.
Troubleshooting: "the remote agent cannot reach it"
Work the chain in order. The answer is always in one of these five places.
systemctl --user status <unit>- a crash loop shows as203/EXECor exit-code restarts, not a dead port. AModuleNotFoundError: ruamelhere meansExecStartPreran a barepython3instead of the Hermes environment's interpreter.journalctl --user -u <unit> -n 50- three line kinds, each prefixed with your tool name:admitted N/30 this hour(the question reached the runner),rejected, …(concurrency or the hourly cap), andhermes chat exited N: …(the runner's own provider error). No admitted line means the request never reached the runner, so the fault is above it: auth, origin, path, or proxy.The proxy's access log for the route - client IP, status, User-Agent. Zero rows while the caller reports failure means the request never left their side.
Unauthenticated
POSTto the route must return401. A403onGET, or on any request carrying a non-localOrigin, is by design. A connection failure means the proxy or the service is down.Runner liveness, independent of the bridge:
hermes chat -q 'Reply with exactly: ok' --oneshot --quiet --toolsets search,session_search. Exit 0 means the bridge is fine and the fault is elsewhere. Exit 1 with a provider complaint on stdout means the model, not the bridge, is broken.
Failure modes worth knowing before they bite:
A pinned model rots silently. A model id can stop being servable for an account - shared-plan caps, delisting, provider reshuffles - while the code looks fine. Every question then fails identically with the generic error. That is the argument for leaving
BRIDGE_MODELunset.A trailing slash can be answered by the wrong route. If the bridge is served from an exact-match location,
/path/misses it and falls through to the site default - which on a basic-auth site returns a401indistinguishable from a bad bearer key. Serve both exact paths.Non-local
Originis rejected. Server-side clients do not send it; browsers do. Intentional, but it means a browser client cannot use this bridge as-is.A stale key looks exactly like a wrong one (
401). The key lives in two places: the key file and the remote platform's secret store. Rotating one without the other breaks the wire.
Security notes
The key file must be a regular file, owned by the service user, mode
0600._read_keyrefuses anything else, including symlinks.The key is compared with
hmac.compare_digest, and never written to logs, source, argv, or the environment.Request bodies are capped, malformed bodies are rejected, and duplicate
Authorization/Host/Origin/Content-Typeheaders are refused outright.The listener binds
127.0.0.1only. The proxy is what makes it reachable, and the middleware trusts the upstreamHostvalue, so the proxy must rewrite it.
Tested with
Remote agent: Fo, calling over Streamable HTTP with a bearer key. Its client is server-side Go and does not send an
Originheader.Answering agent: Hermes Agent, invoked as a one-shot
hermes chatsubprocess.
Any MCP client that speaks Streamable HTTP and can set an Authorization: Bearer
header should work; these are simply what it was built and exercised against. A
client that sends Origin, or cannot set custom headers, will not work as-is.
Contributing
See CONTRIBUTING.md. Run the three checks - the isolation gate, the tests, and
compileall - and paste their raw output in the PR.
Licence
MIT. See LICENSE.
This server cannot be deployed
Maintenance
Related MCP Connectors
Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.
Read-only MCP server: let AI agents read your ORANO saved-video library, tasks, and memory.
Remote MCP server exposing SMI Aware tools, resources, and skills over Streamable HTTP.
AI Reasoning Cache & Consensus Layer with 11 MCP tools via Streamable HTTP.
Related MCP Servers
- AlicenseNot gradedqualityAmaintenanceLifts local stdio MCP servers into remote Streamable HTTP endpoints for cloud-hosted AI clients, with bearer-token auth and tool policy filtering.2 npmMIT
- AlicenseNot gradedqualityBmaintenanceExposes AgentHansa's MCP tools to remote clients over Streamable HTTP, enabling ChatGPT and desktop agents to use the AgentHansa service through a secure authenticated endpoint.MIT

brain-mcpofficial
AlicenseNot gradedqualityBmaintenanceGives MCP clients a small remote Streamable HTTP tool surface for a Brain memory database, letting agents store facts, retrieve current-truth answers with evidence, delete memories, and check their effective identity. Each call is scoped by a customer identifier for per-customer isolation, and the endpoint can optionally be secured as an OAuth 2.1 resource server.Apache 2.0- FlicenseNot gradedqualityAmaintenanceExposes a compiled Markdown knowledge base to MCP-capable coding agents through a single ask tool that returns cited answers grounded in whole wiki articles rather than embedded fragments. Supports local stdio and remote streamable-HTTP transports, with optional bearer-token authentication on the network transport.-