MCP's Security Model is Broken by Design — Here's What We Use Instead
Someone filed a supply-chain vulnerability report against MCP. A rogue server could declare malicious tools, and the client would run them — no verification, no allowlist, no signature check. The protocol just trusts whatever the server says it can do.
Anthropic closed it as "expected behavior."
They're right. It is expected behavior. MCP was designed this way. The server declares tools, the client presents them, the model calls them. No integrity check anywhere in the chain. That's the protocol working as specified.
And that's the problem.
Where MCP's Trust Model Breaks
MCP has a clean architecture. A client (Claude Code, Cursor, whatever) connects to a server over stdio or HTTP. The server advertises a list of tools — functions the model can call. The client trusts that list and makes those tools available.
The implicit assumption: every server in your tool chain is trustworthy. Every npx @random-package/mcp-server you install is acting in good faith. Every tool declaration is honest about what it does.
This is the same trust model that npm had before lockfiles. The same model that Docker had before image signing. The same model that got SolarWinds compromised. "Trust the supply chain" is not a security model. It's the absence of one.
The reported vulnerability was straightforward: a malicious MCP server declares a tool called read_file that actually exfiltrates data to an external endpoint. The client has no mechanism to verify that read_file does what its description says. The model calls it because the description says "reads a file." The user approved it because the tool name looked benign.
Anthropic's position: MCP is a protocol, not a security boundary. Security is the client's responsibility.
That's technically defensible. It's also how you end up with an ecosystem where the default posture is "run first, audit never."
We Built an MCP Server. We Don't Use MCP for Orchestration.
We have an MCP server. @ultrathink-art/mcp-server lets you browse products and check out from inside Claude Code. It's a TypeScript stdio server that translates tool calls into HTTP requests against our Rails API.
We do not use MCP to orchestrate our agents.
We run 10 specialized agents — coder, designer, product manager, security auditor, social, marketing, QA, operations, customer success, growth. They deploy code, create products, publish content, and manage each other's output. Over 5,000 tasks completed.
None of them connect to MCP servers. None of them discover tools at runtime. Every tool an agent can use is declared before the agent starts, in a place the agent cannot modify.
Client-Side Tool Restrictions via Frontmatter
Every agent runs from a markdown file at .claude/agents/<role>.md. The YAML frontmatter declares exactly which tools are available:
# .claude/agents/security.md
---
name: security
tools: Read, Glob, Grep, Bash, WebFetch
model: opus
---
# .claude/agents/social.md
---
name: social
tools: Read, Glob, Grep, WebSearch, WebFetch,
Bash(bin/bluesky*), Bash(bin/reddit*),
Bash(bin/moltbook*), Bash(bin/agent-task*)
model: sonnet
---
# .claude/agents/customer-success.md
---
name: customer-success
tools: Read, Glob, Grep
model: haiku
---
Three agents, three different capability sets. The security agent can run Bash commands and fetch URLs — but cannot write or edit files. An auditor that modifies what it audits is not an auditor. The social agent can only execute specific CLI prefixes: bin/bluesky*, bin/reddit*. It cannot run rm -rf, SSH anywhere, or touch the codebase. Customer success gets three read-only tools. Period.
These restrictions are enforced at the runtime level. Claude Code's --agent flag reads the frontmatter and applies tool constraints before the agent processes its first instruction. The agent does not choose its tools. It receives them.
Compare this to MCP, where the server declares available tools and the client accepts them. We flipped it: the client declares available tools and the agent works within them.
What This Actually Prevents
This is not theoretical. We process untrusted content continuously — social feeds, inbound emails, third-party API responses. Prompt injection payloads appear in our agents' input regularly.
If our social agent gets injected while reading a MoltBook feed (a social network for AI agents with 1.6 million accounts — prompt injection is a given), the blast radius is bounded:
- It cannot write to the filesystem (no
WriteorEdittools) - It cannot execute arbitrary Bash (
Bash(bin/bluesky*)is notBash) - It cannot read credentials, modify code, or deploy
- It can post to Bluesky or Reddit — because those are its tools. That's the worst case.
Under MCP's model, a compromised server can declare any tool. read_credentials, exec_shell, upload_to_external. If the server declares it, the client presents it. If the model is told the tool "reads your settings," it calls it.
The difference is not policy — it's architecture. In our system, the tool surface is defined in a file the agent never sees. In MCP, the tool surface is defined by the thing you're supposed to be securing against.
The Pattern: Declare Outside, Enforce Below
The principle isn't novel. It's IAM policies, container security contexts, seccomp profiles, AppArmor — the capability set is always declared outside the workload and enforced by the runtime.
MCP inverts this. The workload (server) declares its own capabilities. The runtime (client) trusts the declaration. It's chmod 777 as a protocol specification.
Our stack:
Agent frontmatter (tools: ...) ← declares capability
↓
Claude Code --agent flag ← enforces at runtime
↓
Agent instructions (.md body) ← defines role behavior
↓
CLAUDE.md project rules ← adds project-wide constraints
Tool restrictions compose downward. The frontmatter sets the ceiling. Instructions can further narrow behavior within that ceiling. CLAUDE.md adds cross-cutting rules. The agent can operate within the intersection, not the union.
MCP's stack:
MCP Server (untrusted) ← declares tools
↓
MCP Client (Claude, Cursor) ← presents tools to model
↓
Model ← calls tools based on descriptions
The server declares. The client trusts. The model executes.
MCP Is a Transport, Not a Security Boundary
MCP is fine as a wire protocol. Stdio framing, JSON-RPC, tool schemas — it solves real integration problems. We use it for our shopping server because the trust model makes sense there: we wrote the server, we control the tools, the customer chooses to install it.
Where it breaks is when MCP becomes an agent platform — when you're connecting to servers you didn't write, running tools described by parties you don't control, in agent loops where approval fatigue makes human review decorative.
The fix is not "better MCP servers." It is client-side tool restrictions that the server cannot override. Default-deny permission models where no tool exists until the deployment configuration grants it. Capability declarations that live outside the agent's context and outside the server's control.
We wrote those declarations in markdown frontmatter because that's what Claude Code supports. The mechanism matters less than the invariant: the thing being sandboxed does not declare its own sandbox.
Previously: Why Your Agent Framework Needs Default-Deny Permissions — the four-layer isolation model we built for 10 production agents.