Spanly Docs

Trust and safety

The exact set of changes Mend can make, what is deliberately impossible, the fail-open guarantees, and what Spanly can and cannot see or change.

This page is written for a security reviewer. It describes the exact boundary of what Mend can do to your MCP server, and every claim here is a property of the shipped code, not an aspiration. Where a guarantee comes from a specific mechanism, that mechanism is named.

The short version: Mend can only clarify or tighten what a client is told about your tools, only on the manifest version a fix was written for, only when you have allowed it, and never at the cost of blocking or delaying your traffic. Everything below is how that is enforced.

The operation allowlist

A patch is a list of typed overlay operations. There are exactly six, and the type of a patch is a discriminated union with no other members, so a parsed patch cannot instruct the wire layer to do anything outside this list:

OperationEffect
set_descriptionSet a tool's description text.
set_param_descriptionSet the description of one input parameter.
add_annotationSet one annotation hint: readOnlyHint, destructiveHint, idempotentHint, or openWorldHint.
add_param_constraintSet one JSON Schema keyword on a parameter: enum, pattern, minimum, maximum, or format.
add_requiredMark an already-declared parameter as required.
set_output_schemaSet a tool's outputSchema.

Two further operations exist only for patches you author (custom canaries, created from the Canaries page or over the Spanly MCP). Spanly never generates them, and the pipeline refuses a generated patch that carries one:

OperationEffect
rename_toolServe the tool under a different name. Calls to the served name are routed to the original.
remove_toolHide the tool from tools/list. Calls from clients that already know the name still pass.

Every operation is a plain "set", so applying the same patch twice is a no-op. Operations address tools by name and parameters by top-level name.

What is deliberately impossible

For everything Spanly generates on its own — templates and the LLM drafter — these cannot happen by construction:

  • No renames. No generated patch changes a tool name or a parameter name. The rename_tool operation exists only for experiments you write yourself.
  • No removals. No generated patch deletes a tool, a parameter, or a schema constraint. remove_tool is likewise customer-authored only.
  • No loosening. There is no operation, generated or hand-authored, that widens an enum, relaxes a pattern, or makes a required parameter optional.
  • No touching tools/call. Mend rewrites tools/list responses, and it never blocks, delays, or drops a tools/call. The single exception exists to keep your own experiments working: while a session is served a rename_tool you authored, a call to the served name has its name field mapped back to the original before it is forwarded. Arguments, ids, and everything else pass through byte-identical, and sessions without a served rename are never touched.

Because the manifest tells the client what the server can do, and the server behind it is untouched, the worst a generated patch can do is describe your tools more precisely than your manifest already does. A custom canary can go further — that is its point — but only because you wrote it, and it rolls back like any other canary.

Fail-open guarantees

Mend never blocks or delays your MCP traffic and never depends on Spanly being reachable. Any Mend error results in your original manifest being served untouched. Concretely:

  • No delivery state means pass-through. Until the collector has fetched a delivery-state document, and any time it has none, manifests are served exactly as your server produced them.
  • All fetches are background. The collector reads only an in-memory snapshot synchronously on the hot path. Fetching delivery state is fire-and-forget and never delays a flush or an MCP message.
  • Every fetch failure keeps the last known-good state. A missing endpoint, a network error, a 10-second timeout, malformed JSON, a 404 for a stale digest, or a digest that does not match the document it addresses all leave the current state in place. A digest that just failed is not retried for a cool-down window, so a broken digest cannot turn every flush into a failing request.
  • The rewrite itself cannot throw. Rewriting a tools/list response is synchronous local JSON work. Any unexpected shape or internal error returns the original message object unchanged.
  • A patch that no longer parses is simply not served. On the backend, composing the delivery-state document skips any patch whose stored operations no longer validate, rather than failing the whole document.
  • A missing Pulse header is never an error. When the sync header is absent, the collector keeps its current state and re-reads the digest on a later flush.
  • The Pulse header costs one cache read. On the ingest hot path, attaching the header is at most a single Redis read; a miss or error skips the header and the client keeps its state.

The Patch delivery page documents the sync mechanism and its fail-open rules in full.

Manifest-hash anchoring

Every patch is authored against exactly one version of your manifest, identified by its content hash (the same toolListContentHash the scanner already stores). When the collector is about to apply a patch, it hashes the tool list your server actually returned and compares:

  • Hash matches: the patch is applied to a private deep copy of the response.
  • Hash does not match: the patch is not applied, and the response is served as your server produced it.

This is the drift-safety invariant. If you change your manifest, a patch written against the old version stops applying on the wire instantly, and server-side the affected shim is auto-suspended and reclassified on the next scan (see the Upstreaming guide). A patch can never land on a manifest version it was not written for.

Session stickiness

A session never sees the manifest change mid-conversation. The delivery-state snapshot and the canary arm are pinned at the session's first tools/list and reused for the session's entire lifetime, regardless of how the environment's state moves afterward. This holds even for a session that started before the collector finished its first fetch: it stays pass-through for its whole life.

Exposure logging

Spanly records what was actually served, not what was assigned. Each rewritten tools/list response carries a record of the shims applied, the canary and arm if any, the delivery-state digest in effect, and whether the overlay actually applied. When a patch does not apply (for example a hash mismatch), that is recorded as such, and those sessions are excluded from candidate metrics. Canary verdicts and gates read from this served-truth log.

Kill switch

Every environment has a kill switch, off by default. When it is on, the delivery-state document carries no shims and no canaries, and the collector serves every manifest untouched.

  • Who can flip it. The kill switch and all Mend settings are gated to your organization's admins in the Spanly app. This is your app admin, not Spanly staff.
  • How fast it takes effect. Flipping the kill switch refreshes the delivery state through the same single choke point every Mend state write uses, so the digest changes at once. Every collector picks up the new digest on its next flush and fetches the empty state. The propagation bound is one Pulse cycle, which is one flush. New sessions stop receiving any overlay within that bound; sessions already in flight keep the manifest they were pinned to, by session stickiness, until they end.

Modes and defaults

Nothing changes on your wire without an action you took. Every environment starts in suggest mode, with the kill switch off and no policy. In suggest mode Mend opens faults and drafts patches and stops there: a shim or a canary only exists because a person applied a patch or launched a canary. Moving to auto-canary or autopilot is an explicit, audited choice that widens what Mend may do within a policy you set (severity ceiling, maximum concurrent canaries, which operation kinds are allowed, and gate thresholds).

The audit log

Every Mend state write flows through a single choke point that records an audit event and refreshes the delivery-state cache in the same step, so a change can never be recorded by one and forgotten by the other. That includes a shim activated or suspended, a canary started, stopped, or rolled back, the kill switch toggled, and settings changed. Each event records the actor, the action, the subject, an optional detail payload, and a timestamp, scoped to the environment. You can read it from the Mend audit view in the dashboard.

What Spanly can and cannot see or change

Can see. Spanly sits in-line on your JSON-RPC stream through the SDK or CLI you run, so it observes your MCP traffic. That is the observability product, and it is unchanged by Mend.

Can change. Only tools/list responses, only through the operations above, only when the manifest hash matches, and only when your mode and your actions have allowed a shim or canary to exist. Tool names and tool presence change only through a patch you authored yourself.

Cannot change. Server behavior, parameter names, the looseness of a schema, or the outcome of any tools/call (a hand-authored rename maps the called name back to the original; nothing else is touched). It cannot apply a patch to a manifest version it was not written for, and it cannot serve anything while the kill switch is on.

On this page