Skip to content

15 min read

The input surface of a relay server

On this page

A relay server is one whose returned text originates outside your trust boundary: an issue tracker, a wiki, a CRM, a mail client, a documentation search. Its own code can be impeccable, and impeccable code is exactly what returns a support ticket's body faithfully. That makes two separate reviews necessary for one server. One asks whether the code does something it should not — exfiltrates its environment, ships a postinstall script, posts your tool arguments to its own analytics endpoint — which is the review topic 6.14, on trusting third-party servers, sets out. The other asks what a faithful server delivers into an agent's context, and who wrote it.

The second review is the one that gets skipped, because the first one is what a security questionnaire asks for and it produces a document. This chapter is about what belongs in the second: counting who can write into the server's inputs, and then deciding what the agent on the other end is allowed to hold while it reads them.

Counting who can write into the input

Take a server exposing four tools for searching and reading an issue tracker and a wiki. knowledge_search returns tickets. The tracker has a public-facing project wired to the customer support portal, so anyone with a support account can file a ticket, and the body of that ticket becomes the body of a search result. Running the numbers on one such instance: 412 people have write access to the internal projects, of whom 38 are contractors and 9 are partner-org accounts; the portal has 31,400 registered external accounts, of which roughly 2,100 filed something in the last year; and self-service signup on the portal is open, so the true population is everyone. The wiki is narrower and not narrow — 380 internal accounts, plus a partner space with 24 external editors, plus a page-comment feature enabled on every space.

That is the input surface of a perfectly honest server. It reads text and returns it faithfully, and faithfully is the problem, because the text was written by 31,400 people plus anyone who signs up this afternoon. An attacker does not need to compromise the maintainers, the registry, the build, or the container. They need a support account and a paragraph, and the paragraph from chapter 2, where a planted ticket comment moved a share_file call, works fine.

Two of those figures answer different questions, and the wrong one is the one that gets quoted. Roughly two thousand accounts filed something in the past year against thirty-one thousand registered, which is about one in fifteen. That ratio describes how the portal is used and contributes nothing to the bound being computed here. An attacker is not required to be a typical user, or an active one, or one of many. So the statistic that matters is the smallest number of accounts the attack requires, which is one, and the population figure only says how many people are already holding that one without having to ask anybody. Open self-service signup then makes even thirty-one thousand a floor rather than a measurement, because the count of people permitted to write into this server's input is everybody with an email address, this afternoon, on request. Counting registrations answers how many there are. The review needs to know who is allowed to, and on this deployment those two questions have very different answers.

Flowchart: External signup (31,400 accounts) → Support portal ticket; Support portal ticket → Tracker DB; Tracker DB → knowledge_search (honest server); knowledge_search (honest server) → On-call agent context; On-call agent context → trigger_deploy.

Every hop in that chain is working as designed. Nobody in it is behaving badly except the person who opened the ticket, and the reason the chain is dangerous is the last arrow, which exists because someone decided one agent should be able to do both jobs.

It is worth noticing how the reputability of the maintainers interacts with this, because "reputable maintainers, open source" is the sentence that usually closes these discussions. Reputation raises your confidence that the code does what it says. The code says it returns the contents of tickets and wiki pages, and a more reputable maintainer implements that more faithfully. There is a version of the argument that inverts: a sloppier server that truncates aggressively, strips markdown, and returns only the first 200 characters of a description would deliver less attacker-controlled text into your context than the careful one that preserves full comment threads with formatting intact. Nobody should choose a server on that basis, and the fact that the argument runs backwards is a good indication that maintainer quality is orthogonal to this risk rather than a mitigation for it.

Set that out as a test rather than as an observation, because the test is reusable on the next reassurance somebody offers. If maintainer quality were a mitigation for this risk, lowering it would raise the risk. So lower it: picture a sloppier server that truncates descriptions at a couple of hundred characters, strips formatting and drops comment threads entirely. The volume of attacker-authored text arriving in your model's context goes down. The implication runs the wrong way, so the premise is false. Orthogonal is not the same as worthless — maintainer quality is exactly the right instrument for the other review, the one asking whether the code does something it should not — but it is not a weak form of protection against this, and treating it as one is how a deployment gets approved on the strength of a document about a different property.

The same test disposes of the artefact the whole discussion tends to lean on. An eight-page vetting document is offered as the defence, so ask whether it reads differently in the two cases it has to tell apart. Connect this server to a documentation agent that can only search and read, and the document is correct and the deployment is fine. Connect the identical server, at the identical digest, to an agent holding trigger_deploy, and the document is still correct word for word, and the deployment is not fine. The document cannot move, because every question in it is about the server while the variable is the agent. That is not a weak review. It is a thorough review of one half of a pair.

Four controls, and the one that addresses this

The obvious response to all this is a paragraph in the integration document: the knowledge server is intended for read-only reference and must not be used in workflows that trigger deployments. Sentences of that form appear in a great many architecture documents and enforce exactly nothing, because no component reads them. What determines whether a ticket body can reach trigger_deploy is which tools are registered in which agent's session, and that is a config file.

Prose is not a control, and the reason is mechanical rather than cultural. A control occupies a position in the path a request takes, so that the request cannot complete without passing through it. A sentence in a document occupies no such position: there is no moment during a tool call at which any process opens the integration document, and there could not be, since the document is written for people. Its effect at runtime is therefore exactly zero, and its effect on behaviour is whatever a human happens to remember at the moment they edit a config file — which is not nothing, and is not a property anybody can test. That is the distinction the four controls below are sorted by.

The controls that hold are the ones a machine checks, and there are four worth separating, because they cover different failures and teams routinely install one and believe they have all four.

ControlEnforced byWhat it stopsWhat it does not stop
Capability-scoped credentialThe upstream API's token scopesThe server reading or writing beyond its jobAnything the server legitimately reads reaching context
Tool-set separationAgent session config: which tools are registered togetherTicket text reaching trigger_deploy at allDamage within the remaining tool set
Network boundaryEgress policy on the server's namespaceThe server fetching attacker-controlled URLsPayloads already inside the tracker
Approval on sensitive pathsThe tool implementation, out of band of the agentUnattended execution of the dangerous callAn approver who trusts the model's justification

Read the last column downward and the table says what survives all four together, which is a more useful output than four separate reassurances. A scoped credential leaves everything the server legitimately reads. Tool-set separation leaves whatever damage the remaining tools can do. The network boundary leaves payloads already sitting in the tracker. Approval leaves an approver who trusts the model's justification. Compose those four residues and what is left is a payload already in the tracker, reaching the tools that stayed in the session, in front of a person reading a sentence the payload helped write. That is the honest end state, and it also identifies which row is load-bearing: only tool-set separation has an entry in the stops column naming the actual path from a ticket body to a deploy. The other three narrow what sits at either end of that path without touching the path itself.

Scoping the credential is the least interesting and the easiest to get wrong in the direction of convenience. The token the server holds should be read-only, project-scoped, and issued to a service identity that is not a person:

bash
# knowledge-mcp service identity — tracker token scopes
scopes = read:issue, read:project, read:comment     # not write:*, not read:user
projects = PAY, SUP, INFRA                          # not the SEC project
ttl = 30d, rotated by the platform vault

The scopes bound what an attacker gets if the server is the thing that goes wrong. They do nothing about the payload path, since a read-only token is precisely what is needed to fetch a poisoned ticket.

Tool-set separation is the control that actually addresses this deployment, and it is a decision about session composition rather than about the server. The on-call agent can hold knowledge_search or it can hold trigger_deploy. Holding both means that every string in every ticket is one hop from a deploy argument, which is the union property from chapter 2: once two servers share an agent, the union of their tools is available to text returned by either. Splitting it costs an extra step in the workflow — the reference agent reads and summarises, a human reads the summary, and the deploy agent is invoked separately with the release identifier the human typed. That handoff is the boundary, and it is a boundary because a human retyped the value rather than because anyone asked the model to be careful.

The network boundary matters more than it looks, because it is what stops the second half of most attacks. A payload that moves an argument still needs a way to get data out, and a server that can reach arbitrary hosts is that way.

yaml
# NetworkPolicy: knowledge-mcp
egress:
  - to:
      - ipBlock: { cidr: 10.42.7.0/24 }     # tracker + wiki, internal
    ports: [{ protocol: TCP, port: 443 }]
  # no default-allow rule; everything else is denied

Four lines of YAML, checked on every packet by the CNI, the cluster's network plugin that decides which connections a container is allowed to open, and it does not depend on anybody remembering anything. A control and a statement of intent differ in exactly that respect: the statement lives in a document that no runtime reads, and the policy lives in the path.

Approval on the sensitive path is the weakest of the four and the one most often listed first, because it is cheap to add and it feels decisive. It holds only under conditions that are easy to state and hard to keep. The prompt has to be out of band of the agent, so that nothing in the context can generate an approval; the arguments have to be shown resolved to their final values rather than as the model described them, per chapter 3, on where a destination argument may come from; and the number of approvals per engineer per day has to stay low enough that reading them is realistic. On one team, the deploy agent was producing about 14 approval prompts a day per on-call engineer during a release week, and an internal spot check found approvals arriving with a median of 1.9 seconds between display and click. At that latency the control is a click, not a review, so the useful version of approval is one that fires rarely enough to still be surprising.

Multiply the two figures and the control disappears entirely. Fourteen prompts in a day, each disposed of in a median of about two seconds, is well under half a minute of human attention spent on a whole day's approvals. Nobody reads a set of arguments in two seconds; two seconds is how long it takes to recognise a familiar dialog and move the pointer. What the measurement captures is habituation, and habituation is a function of frequency, so this control gets weaker precisely as the thing it guards becomes routine — the one direction nobody would choose if they were choosing. That is what makes rarity part of the control rather than a nice property of it, and why adding an approval step makes a design safer only alongside a decision about how often it will fire.

What should happen by itself in three months

The part of this that teams handle worst is not the initial decision. It is the fact that the server changes after the decision is made. Three months later the server ships version 4.2, whose release notes mention a nice feature: when a ticket links to an external page, the server fetches that page and includes its content in the result, so the agent can answer questions about linked documentation.

That single feature moves the server from "returns text written by 31,400 people" to "returns text written by anyone with a web server", and it changes nothing that a version pin was watching, because the team upgraded the pin deliberately after reading the release notes and thinking it sounded useful. Nobody was dishonest here either.

Two mechanisms catch it without anyone being clever. The first is the egress policy, which already denies everything outside 10.42.7.0/24, so the fetch fails and the feature simply does not work until somebody widens the policy — and widening it is a pull request against a security-owned file, which is a conversation. The second is fingerprinting the server's declared surface at registration, so that a change in what it exposes is an event rather than a silent upgrade:

json
{
  "server": "knowledge-mcp",
  "image": "ghcr.io/atlas/knowledge-mcp@sha256:9f3c…",
  "surface_sha256": "b21d7c…",
  "surface": {
    "tools": ["knowledge_search", "read_page", "list_spaces", "get_comment"],
    "resources": ["atlas://spaces/{key}"],
    "prompts": []
  },
  "on_surface_change": "block"
}

The client computes the hash of tools/list, resources/list and prompts/list — names, descriptions and schemas — at every startup and compares it to the registered value. A changed tool description is enough to trip it, which is deliberate, since a description is server-authored text that sits in the system context on every request, and the tool-poisoning proof of concept from Invariant Labs modifies nothing else. The failure mode on mismatch should be refusing to start rather than warning, because a warning in a startup log is a warning nobody reads. Neither mechanism required predicting the external-fetch feature; they both work by making a category of change visible, which is the only kind of foresight that scales.

The first of those two is worth separating from luck, because the property that gives it foresight is written into its shape. The policy is written as an allowlist: two internal address ranges are permitted and everything else is denied, so the set of destinations it approves is finite and was enumerated by a person. A new destination is therefore denied by construction. Nobody predicted an external-fetch feature; there is simply no rule in the file that could have admitted one. A denylist has the opposite property, approving everything it does not name, so it can only stop what somebody thought of in advance and admits anything that arrives after the list was written. Default-deny is the design decision here, and the two address ranges are the requirement that follows from what the server has to reach. Getting those the wrong way round produces a policy that needs updating every time an attacker has an idea.

Server honesty, and the gap detection leaves

A dishonest server is strictly worse than an honest one, because it can do everything an honest one delivers plus everything topic 6.14 catalogues, and it can do it selectively — returning clean text to your test harness and payloads to production, which no amount of one-off vetting detects. Both reviews are load-bearing and they answer different questions, so skipping the first because of the argument above would be a mistake.

Nor is any of this an argument against using third-party servers. A policy that ends at "do not connect servers you did not write" fails on contact with a platform team that has real work to do, and everything above is compatible with connecting the server on the day it was requested. What changes is the shape of the answer: not approved or rejected, but approved into a named agent with a named tool set, which is a decision that can be revisited when either half of the pair changes.

What remains open is detection. The controls here shrink what a successful payload can reach, and none of them tells you that a payload arrived. The on-call agent will keep reading tickets from 31,400 accounts, and nobody knows how many of those tickets contain a paragraph aimed at a model, because nothing is looking. That is the obvious next thing to build, and it is also the control most often described in a design document in terms it cannot support.

MCP as an injection surface — read the current version at https://daviddominguez.dev/learn/en/mcp-as-an-injection-surface/the-input-surface-of-a-relay-server

Back to top