An MCP server has more outputs than tool results

·9 mins

I designed a local stdio MCP server in Go that gives an LLM scoped access to a shared password vault. Two facts set the threat model. Any member of a shared vault can write an entry comment that another member’s agent later reads, so stored text is attacker-controlled. And the model can call every tool in the manifest, so whoever steers the model gets that authority too.

The plan started with a prompt-injection fence around user strings. Most of the work ended up elsewhere.

Keep the secret off the transcript

Once get_password returns plaintext, the value sits in the model’s context and the host’s chat history, and no scope rule can take it back. So the everyday path never crosses that boundary:

  • copy_password_to_clipboard takes an ID, writes the value to the OS clipboard from inside the server, and returns Password (id=42) copied to clipboard. Auto-clear compares a SHA-256 of the current clipboard first, so it can’t wipe something the user copied later.
  • create_password_generated generates the value server-side and returns only the new row’s ID.
  • get_password returns the literal <redacted; revealPlaintext=never> by default, and its description points the model at the clipboard tool.

The confirmation first included the fenced entry title. That was removed: the model already has the title, and “action just completed” is a bad moment to hand it attacker-written text. A tool result should echo as little as possible.

Plaintext in chat remains for cases where the model has to reason about the value, such as a reuse audit. It sits behind a setting: never, per_call or always.

That setting began as a boolean, and the trap was in the annotations. A read is correctly readOnlyHint=true, so the host’s confirmation never fires, and one injected instruction could walk list_passwords → get_password across every entry in scope. Marking the read as destructive doesn’t help: annotations are static at registration, and hosts may ignore the cue on a read-only tool.

So per_call is an OS-native modal raised inside the server before the value is serialized, with a 30-second timeout that fails closed. If no GUI is available, the server refuses to start in that mode.

Two more manifest details matter. destructiveHint and openWorldHint default to true in the spec, which is why the Go SDK types them as *bool, and every tool here sets them explicitly. And the manifest itself is a control: if the clipboard probe fails, the tool is never registered, so the model can’t try it.

Authority lives in code

Tool descriptions are instructions to something an attacker may be steering, so they count as guidance, not control. Scope is a deterministic filter on (vault, [tags], mode), and the type system gates writes:

func (s *scopedOps) DeletePassword(ctx context.Context, rule *scope.ResolvedRule, id ID) error

A rule only comes from the scope resolver, so a handler that skips the check has nothing to pass and doesn’t compile. A runtime check catches the wrong rule: another vault, or a read-only mode. The scope itself lives in the OS keychain, and the server only reads it so that no tool call can widen it.

The input schema enforces less than it looks like

With the Go SDK, mcp.AddTool derives the input schema from struct tags. An earlier tag format, jsonschema:"required,description=...", failed at registration because the jsonschema tag is now the property description and nothing else. The tags were rewritten as prose:

Mode   string `json:"mode,omitempty" jsonschema:"Strategy. (Schema hints: enum=memorable; enum=random.)"`
Length int    `json:"length,omitempty" jsonschema:"Length. (Schema hints: minimum=6; maximum=32.)"`

That reads like a constraint and enforces nothing. The generated schema has only type and description, and a call with mode=evil, length=999 reaches the handler. The handler’s own validation is the only check, and it has to reject: the generator below it silently clamps, so the model would ask for one thing and get another.

The schema does give you types, required fields, and additionalProperties: false. When it rejects a call, the SDK answers before your handler runs, so your audit middleware never sees it, and the message echoes the offending value.

Beyond that, arguments are IDs, enums, and bounded integers. Omitted and [] mean different things on an update, so that field is a *[]int64. The model can’t push a row out of its own visibility, because one allowed tag must remain.

Stored content is deliberately not filtered. A comment reading “ignore previous instructions” is legal data, and the regular clients show it verbatim. Escaping happens when text leaves for the model.

The fence, and the encoder after it

Read tools wrap each user string, and their descriptions tell the model that fenced text is data:

<user_content>{escaped}</user_content>

&, < and > are HTML-escaped so a stored payload can’t close the tag. C0 controls are escaped too, because a stored ESC paints ANSI sequences on the terminal of anyone inspecting raw traffic, and the nine bidi overrides are replaced so stored text can’t reorder what a human approves.

“Ignore previous instructions” passes through unchanged. Against natural-language injection, the fence makes the boundary explicit. The real limit is what a steered model can reach.

Two gaps remain, both visible only by running it:

  • The tags don’t arrive as written. Returning a typed Out lets the SDK serialize it with encoding/json, which HTML-escapes by default. A title reaches the model’s text block as <user_content>..., and &amp; becomes &amp;. The payload still can’t close the fence, but the literal the tool description promises isn’t the literal on the wire. To control it, set result.Content yourself using an encoder with SetEscapeHTML(false).
  • The escape table works on bytes. It covers bytes below 0x20, so DEL and U+009B, the 8-bit CSI encoded as C2 9B, pass through. A rune-based check with unicode.IsControl catches both.

Each sink needs its own treatment

SinkDataTreatment
Tool resultFree textEscape, replace bidi overrides, fence. Keep ZWJ and RLM, which Arabic, Indic scripts, and emoji need
Tool resultVault and tag namesStrip control and format runes, cap at 256, then fence, which still protects a handler that forgets the fence
Startup errorBackend-supplied versionAllowlist regex, otherwise unknown
Consent dialogEntry titleReject controls, line separators and bidi; truncate to 80 runes
SubprocessEntry titleEnvironment variable, never script source
Audit logContent fieldsDropped; a search query is logged as its length
Tool errorAnythingFixed text

The consent dialog shows why one sanitizer isn’t enough. The AppleScript is a constant, and the title arrives as data:

set t to system attribute "ENTRY_TITLE"
display dialog "Reveal password \"" & t & "\" to your AI assistant?" -- ...

That stops code injection and leaves another problem. The dialog still renders the title, so newlines, bidi overrides, or a long payload could bury the warning and harvest a click on Allow. Data-not-code and safe-to-display are separate properties. The interpreter is called by absolute path as well, because a PATH-shadowed binary would receive the title in its environment.

The error path talks to the model

In the Go SDK, a non-nil error from a tool handler becomes isError content, so err.Error() goes to the model. Every handler here runs through a middleware that classifies the error, returns fixed text, and sends the reason to the audit log:

func wrapHandler[In, Out any](s *Server, name string,
	inner func(context.Context, In) (Out, error),
) mcp.ToolHandlerFor[In, Out] {
	return func(ctx context.Context, _ *mcp.CallToolRequest, args In) (
		result *mcp.CallToolResult, out Out, err error, // named: recover writes these
	) {
		defer func() {
			if r := recover(); r != nil {
				result, out, err = errGeneric(), *new(Out), nil
			}
		}()
		// deadline, session check, inner(), classify, audit
	}
}

The named returns matter. Without them, a recovered panic returns (nil, zero, nil), which the host reads as an empty success. The panic value is logged by type, and by content only if it’s a string or error, because %v on a struct could print a password.

What still had to leave the error text:

  • Scope details. A bypass error included the rule’s vault ID and mode, so a steered model could map the scope one call at a time. Those fields moved to a typed error for the audit log.
  • Subprocess output. fmt.Errorf("pbcopy: %w: %s", err, out) could carry clipboard content. Stdout now goes to /dev/null, and stderr never joins the error.
  • Identity. An “account changed” error named the username and backend. It can fire on every tool call, putting N copies of an email address into a transcript that may get exported or summarized. The final text names nobody.
  • Tamperable integers. A version-mismatch error quoted a schema number from a local file, whose author then controlled a value the model reads as authoritative.

The audit log is an output too. Its redactor is a key-name denylist over any, and it needed six fixes: nested maps, []map[string]any, pointers and typed maps, nested structs, Password/URL casing, and map keys in result metadata. A new field called token would still be logged. An allowlist of loggable fields changes that failure from a leaked secret to a missing log value.

A stdio child outlives the login

The host spawns the server once and keeps it alive for the chat. An ADR said the session is read on every call, so a login as a different account gets refused. The implementation read it once at startup, and the error sentinels existed with no code path producing them. A second login with a chat open left the server acting as the old account, with no warning.

The check now runs in the middleware, before each handler. It came with a race: a check could pass, then a sibling refresh could swap the credentials before the handler used them. The check and the credential snapshot now happen in one critical section, and the handler receives that snapshot through its context.

What stays open

  • Natural-language injection is contained, not solved. The scope rules limit the blast radius.
  • The tag filter runs on the server after the backend returns the whole vault, so the binary is trusted. A server-issued scoped token is the real fix.
  • Auto-clear dies with the process, and clipboard managers keep their own history.

Takeaway

Treat the SDK as part of your attack surface: read what your schema really contains, and look at the bytes a tool result becomes. Then list every place a value can go, including errors, dialogs, and logs. Remove the sinks you can, as the clipboard tool did for secrets, and give each remaining sink the treatment its grammar calls for.