takeaway notes

MCP Server Security: Scoping Tools So an Agent Can't Wreck Things

11 min · ai engineering, mcp, security, agents

Watch on YouTube

An MCP server hands its tools to a model that decides what to call, so its security is about limiting what any call can do. Expose only the tools the job needs, read and pin third-party tool descriptions, confirm writes with their real inputs, run the agent under a dedicated least-privilege role, use the user's own token (issued for your server, never passed through), check every call on the server, and log every call.

Concepts

Excessive agency

Theory

An MCP server exposes tools (name, description, input schema) and the model picks which one to call. Whatever the server can do, the agent can do. OWASP lists three root causes of excessive agency: too much functionality, too many permissions, too much autonomy.

Practical example: Remove what the job doesn't need

A support bot server with search_tickets, get_customer, issue_refund and delete_account. The bot never needs delete_account, so the server doesn't expose it.

Tool poisoning

Theory

The model reads the full tool description, while the user usually sees just the tool name. Instructions hidden in a description are followed by the model. A server can also change a description after you approved it (a rug pull), and one server's description can change how the agent uses another server's tools.

Practical example: Pin descriptions by hash

Store a hash of each approved tool description and refuse to call a tool whose description hash changed.

import hashlib, json

def tool_hash(tool):
    return hashlib.sha256(json.dumps(tool, sort_keys=True).encode()).hexdigest()

def allowed(tool, pinned):
    return pinned.get(tool['name']) == tool_hash(tool)

Read vs write tools

Theory

MCP tool annotations include readOnlyHint and destructiveHint. The spec says a human should always be able to deny a tool call, and clients should show the tool's inputs before calling. Annotations are hints, so trust them only from servers you control.

Practical example: Confirm with the real inputs

Instead of 'Approve issue_refund?', the client asks 'Refund $480 for order 1182?'.

A dedicated role for the agent

Theory

Run the server's calls under a role made for the agent with the least privilege its job needs, not the admin key from the .env file. If the agent is tricked, the damage stops at the edge of the role, and every action is traceable to it.

Practical example: A support-agent role

The role can read tickets, read customers and issue refunds. It can't delete accounts or use admin endpoints.

User tokens, issued for your server

Theory

A remote MCP server is an OAuth resource server, and each request carries the user's access token, so actions run in that user's context. The server must check the token was issued for it (its audience) and must never forward it to the API behind it (token passthrough). Start with a small scope and ask for more when a privileged tool is first used.

Practical example: Check the audience

The token for your support server should name it as its audience (support-mcp.example.com). A token whose audience is billing-api gets rejected, and your server never forwards the user's token to the API behind it.

Check every call on the server

Theory

Servers must validate tool inputs, enforce access control and rate limit tool calls. Check the caller's authorization against every id on every call. Tool outputs can carry indirect prompt injection, so a tricked model must still hit the same server rules.

Practical example: Checks inside issue_refund

Reject the call unless the order belongs to the user on the token and the amount is at most what they paid; allow at most 5 refunds an hour for the agent's role; return a tool error that says why.

ORDERS = {"1182": {"user": "u42", "paid": 480}, "2207": {"user": "u77", "paid": 90}}
REFUNDS_THIS_HOUR = {"support-agent": 0}

def issue_refund(user, role, order_id, amount):
    order = ORDERS.get(order_id)
    if order is None or order["user"] != user:
        return {"isError": True, "text": "order not found for this user"}
    if amount > order["paid"]:
        return {"isError": True, "text": "amount is more than the order total"}
    if REFUNDS_THIS_HOUR[role] >= 5:
        return {"isError": True, "text": "refund limit reached, try again later"}
    REFUNDS_THIS_HOUR[role] += 1
    return {"isError": False, "text": f"refunded {amount} for order {order_id}"}

print(issue_refund("u42", "support-agent", "2207", 90))  # order not found for this user

Audit log

Theory

The spec says clients should log tool usage for audit; log on the server too. Logs don't prevent an attack, but they show where it happened and how far it got.

Practical example: One line per call

user 42, role support-agent, issue_refund, order 1182, $480, approved.

Checklist

Gotchas

References