Audit an MCP server for tool authority, not for model behaviour.
An MCP security audit asks who the tool call runs as, what object it may touch, and whether untrusted content can reach it. SecHelix ships a Gold Check Pack for that boundary and refuses to treat nondeterministic model output as proof.
Part of the AppSec agent guide.
An MCP security audit is an authorization audit with an unusual caller. The questions are which identity a tool call executes as, which objects that identity may touch, whether the effect is reversible, and whether content the agent merely read can decide which tool runs. It is not an audit of how the model phrases things. A model that sometimes says something alarming is a candidate; a path where retrieved text reliably reaches a privileged tool is a finding.
SecHelix ships a Gold Check Pack for exactly this boundary. It names the trust boundary as TOOL_AUTHORITY, the action as INVOKE, and the invariant as tool authority bounded by operator intent rather than by model output. The rest of the evidence contract applies unchanged: an agent finding earns the same verification pass as any other.
What to enumerate before testing anything
- Every reachable server and tool
- Not the tools the agent is supposed to use — the tools it can name. The dispatcher decides what runs, and it usually accepts any key present in the table.
- The identity behind each call
- Whether the tool runs as the end user, as a service principal, or as whatever ambient process credential the host happened to have.
- Reversibility
- A read, a send, a mutation, and a deletion should not sit in the same authority tier. Most agent loops put them in the same dictionary.
- The namespace
- When tools from several servers share one namespace, a renamed or shadowed tool can capture a call intended for another server.
- Instruction provenance
- Which text entered the context, and who could write it. Server-supplied tool descriptions count: they are frequently placed in the system context and treated as trusted.
- Second-order paths
- Tool results and stored content that re-enter later runs without provenance. An instruction can survive the session that introduced it.
The shape that fails
This is the loop the evaluation fixtures use, and it is close to what most first implementations look like. Two defects share one function: retrieved text is concatenated into the instruction channel, and the dispatcher honours whichever tool the model names.
TOOLS = {
"search_docs": lambda args, ctx: ctx["index"].search(args["q"]),
"send_email": lambda args, ctx: ctx["mailer"].send(args["to"], args["body"]),
"delete_records": lambda args, ctx: ctx["db"].delete(args["table"], args["where"]),
}
def run(operator_prompt, ticket, model, ctx):
fetched = ctx["index"].fetch(ticket["url"])
# Give the model everything it needs in one context.
prompt = f"{operator_prompt}\n\nTicket: {ticket['body']}\n\nReference:\n{fetched}"
for _ in range(5):
step = model.next_step(prompt)
if step["type"] == "final":
return step["text"]
tool = TOOLS[step["tool"]]
result = tool(step["args"], ctx)
prompt += f"\n\nTool {step['tool']} returned: {result}"Injected text in a fetched page can invoke delete_records. The model did nothing wrong; the dispatcher let generated text choose authority.
READ_ONLY_TOOLS = {"search_docs": ...}
PRIVILEGED_TOOLS = {"send_email": ..., "delete_records": ...}
def run(operator_prompt, ticket, model, ctx,
allowed_tools=frozenset({"search_docs"})):
fetched = ctx["index"].fetch(ticket["url"])
# Untrusted text travels in its own labelled channel.
prompt = model.build_prompt(
system=operator_prompt,
untrusted={"ticket": ticket["body"], "reference": fetched},
)
for _ in range(5):
step = model.next_step(prompt)
if step["type"] == "final":
return step["text"]
name = step["tool"]
if name not in allowed_tools:
prompt = model.append_tool_error(
prompt, name, "tool not permitted in this run")
continue
result = READ_ONLY_TOOLS[name](step["args"], ctx)The allowlist is a property of the run, not of the prompt. A tool outside it produces an error the loop can continue from, so the failure is visible instead of silent.
The repair is not better prompt wording. It is that allowed_tools is evaluated by code that untrusted content cannot reach. Both variants above are evaluation fixtures, and both are readable in the open repository alongside the pack that describes them. To run the lane yourself, install the skill in the host you already use.
The same bug with everything else removed
Stripped of the loop, the defect is two lines. It is worth keeping this version in mind while reading a larger agent, because the larger agent is usually this with more context around it.
# vulnerable
def handle_document(document, tools):
instruction = document['suggested_tool']
return tools[instruction['name']](**instruction['arguments'])
# clean
ALLOWED_READ_ONLY = {'lookup_public_record'}
def handle_document(document, tools, operator):
instruction = document['suggested_tool']
if instruction['name'] not in ALLOWED_READ_ONLY or not operator.confirm(instruction):
raise PermissionError('tool use not authorized')
return tools[instruction['name']](**instruction['arguments'])Note what the clean variant does not do: it does not try to detect malicious documents. It restricts the callable set and requires operator confirmation. Detection of hostile phrasing is a reporting aid; the control is that the document never had authority.
The failure modes that recur
- Data and instructions share one channelThe single most common root cause. Retrieved documents, ticket bodies, tool results, and the operator prompt arrive as one string, and nothing downstream can tell them apart.
- Ambient process credentialsTools execute with whatever the host process holds rather than with credentials scoped to the run principal. The agent can then do more than the user it is acting for.
- One authority tier for everythingIrreversible tools sit beside read-only ones in the same table. Any dispatcher bug becomes a destructive dispatcher bug.
- Trusted tool descriptionsServer-supplied descriptions are placed in the system context. A server you connected to can then write into the instruction channel of every session.
- Unpinned shared namespaceA tool definition can be shadowed or altered between runs. The call goes to a different implementation than the one you reviewed.
- Second-order persistenceA result written to memory or a vector store re-enters a later run with no provenance, so the instruction outlives the session and can cross users.
How to prove it without trusting the transcript
Model output is the wrong evidence. It varies between runs, and a refusal on one sample is not a control. Observe the dispatcher instead — the same instinct that got a plausible high-severity candidate refuted in the published run.
1. Stub every tool so it records the call and returns a fixed value.
2. Run the task with benign content. Record call log A.
3. Run the identical task with only the untrusted content changed. Record call log B.
4. Diff A against B.
5. A privileged call that appears only in B is the observation.The pack requires four pieces of evidence before this class can be called verified.
- The assembled context, with each segment labelled by who can write it, showing untrusted text in an instruction position.
- The dispatcher path from model output to execution, showing where a per-run authority check is absent.
- A recorded tool-call log from an isolated run in which only the untrusted content differed.
- The authority the invoked tool carries: which credential, which data, and whether the effect is reversible.
The pack contract
- Pack
SEC-AI-MCP-AUTHORITY-001- Title
- Tool authority for agents that consume untrusted content
- Boundary
TOOL_AUTHORITY· actionINVOKE- Sinks
TOOL_DISPATCH·OUTBOUND_MESSAGE·DATA_MUTATION·CODE_EXECUTION·CONTEXT_APPEND- Capability tags
llm_agent,tool_invocation,mcp_server,retrieval_augmentation,autonomous_action- Detection layers
- MODEL, STATIC, CONTRACT, DATA, LOCAL_RUNTIME
- Default validation mode
- LOCAL. Destructive actions and production mutation are both forbidden by the schema.
- Independent verification
- Required. The schema pins it to true, so a pack cannot waive it.
- Regression fixtures
EVAL-AI-001,EVAL-AI-002- Calibration
- NOT_MEASURED, sample size 0
Audit the dispatcher, not the transcript.
npx skills@latest add omarmohelal/SecHelix --skill sechelix