A plugin can now ask Hermes to gate its own MCP server


You install a community plugin from the catalog. It quietly registers an MCP server, and that server can place orders, move money, or send messages on your behalf. Hermes never asks you once. The only thing standing between a prompt-injected model and the action is a sentence in the plugin’s skill text asking the model to check with you first — and whether the model complies is a matter of mood, not of code.

That is not the plugin’s fault. It is a hole in the format: Agent Plugins v1 defines no trust field, so every MCP server loaded from a portable package ran at the default trust: full. PR #134524, merged on October 7, 2026, closes it: a package can ask Hermes to gate its own server, and cannot ask for anything wider.

Where the gap came from

Hermes supports two plugin shapes. Native plugins are a plugin.yaml plus a register(ctx) function, wired into the core. Portable packages target the Agent Plugins v1.0.0 format and look like this:

my-portable-plugin/
├── plugin.json
├── skills/
│   └── summarize/
│       ├── SKILL.md
│       └── references/
└── mcp.json

plugin.json is the manifest, mcp.json declares the MCP servers the package connects to, and skills/ carries the skills it ships. A freshly installed package stays disabled until you run hermes plugins enable <name> — good hygiene, but it only answers “should this load at all”, not “who hits the brakes while it runs”.

The real problem was the reader. Hermes’ portable mcp.json accepts only type, url, and headers (see hermes_cli/agent_plugins.py), while the trust tier lives in the native config at mcp_servers.<name>.trust in config.yaml. With no such field in the spec, the reader never translated one, and every package server came up at the default full.

What put this on the roadmap was a concrete case. A catalog sweep turned up a trading plugin whose hosted server pauses each trade on the user’s Hyperliquid account until resolve_approval is called. As the PR puts it, the only thing standing between a prompt-injected model and an approved trade was the skill text. Hermes itself never prompted.

How a package asks for a gate

plugin.json already carries a Hermes-specific extension block — the same one that holds app, requires, and liveness. It now accepts trust:

{
  "extensions": {
    "com.nousresearch.hermes": {
      "servers": {
        "trade": { "trust": "untrusted" }
      }
    }
  }
}

That untrusted is written onto the translated mcp.json server entry, and from there the existing gate in tools/mcp_tool_handlers.py applies unchanged:

  • Every tool call without a readOnlyHint: true annotation asks you first, through the standard approval surface;
  • where nobody can answer — cron jobs, hermes chat -q one-shots, an unattended gateway session — the call fails closed rather than sliding through;
  • an entry that declares trust and nothing else creates no availability gate; it just puts a lock on the server.

Narrow only, by construction

The design rule matters more than the feature. A package may declare exactly two values:

What the package writes What happens
untrusted Applied — every write-capable call on that server needs approval
full Accepted, but already the default, so it grants nothing
Anything else The package is refused entirely, the same fail-closed stance as an unrecognized value in native config

One more precedence rule backstops it: a config.yaml server with the same name replaces the package’s entry wholesale, trust included. A package can never override the user. Set a server to full yourself and no amount of untrusted in a manifest will change that.

The PR’s own framing is worth keeping: this exists so a package can narrow, never widen. The extension field is not a way for plugins to gain authority, it is a way for them to hand authority back.

Check before you enable

hermes plugins validate now lists the gated servers, so catalog reviewers — and you — can see the claim:

server trust: trade — untrusted (Hermes asks before every write-capable tool call)

When an unfamiliar portable package shows up, the order that costs you least:

hermes plugins validate /path/to/package   # what does it declare, including gated servers
hermes plugins install owner/repo          # installs disabled
hermes plugins capabilities <name>         # declared vs granted capabilities
hermes plugins enable <name>               # turn it on once you agree

If you publish plugins

Three things worth taking away:

  1. Gate your write servers. Any MCP server that spends money, trades, sends messages, or changes accounts should declare trust: "untrusted" in plugin.json. Do not rely on skill text telling the model to ask — that is a hint, not a gate.
  2. Never put credentials in mcp.json. The env values declared there are visible package data, not a secret store.
  3. “Installs disabled” is not a gate. Once a user enables the package, the extension block is the only safety claim you control.

For the wider trust model on remote MCP servers — why readOnlyHint is a server-supplied hint, and why unannotated tools return outcome_uncertain when a transport session expires mid-call — see the remote MCP catalog security model. For how far Hermes isolates a plugin from the host, read plugin isolation and the host boundary, and for how approvals behave when no human is around, the unattended approvals guide goes deeper. Command reference: hermes plugins.

The part worth remembering

readOnlyHint is a hint the server gives about itself — a lying server can at most skip a prompt for a tool it claims is read-only, never gain extra access. trust: untrusted removes the dependency on any self-description: anything not marked read-only asks first.

That difference is the whole point of the change. A security boundary should not be drawn by the party being checked, and until this PR a portable package could not draw one at all. Now it can, for the price of one extra line in its manifest.