MCP tools need production permissions, not just better prompts

MCP is the software conversation this week. The useful question is not whether an agent can call a tool. It is what that tool is allowed to do when the prompt is wrong.

MCP is having its software-industry week, and most of the conversation is about what agents can connect to next. I keep landing on a less exciting question: what is the tool allowed to do when the agent is confidently wrong? A prompt can say “only refund the customer who asked.” A permission check can enforce customerId, tenantId, amount, and approval. Only one of those survives a malicious document.

The Model Context Protocol gives an agent a standard door into tools and resources. That is useful. It also makes an old API lesson impossible to dodge: the description is not the boundary. An MCP server still needs authentication, authorization, validation, rate limits, timeouts, and an audit trail. I would add one read-only tool to Ask Ayush before I gave any agent a write path into OnMission, Lahebo, or the Stripe inbox.

Authorization is about determining whether a client has permission to perform a given action on a given resource.

Model Context Protocol specification, Authorization

The agent does not need to see a Stripe key, a Postgres URL, or a service account token. It needs a narrow tool that returns a result. Keep the credential on the server that owns the action.

Step 1: Turn every tool into an API contract

Do not begin with a long tool description. Begin with the action contract. Name the resource, the tenant, the actor, the side effect, and the maximum cost. “Create invoice” is not a contract. “Create one invoice for this authenticated tenant, for this approved customer, below this amount, with this idempotency key” is one.

type ToolContext = {
  userId: string;
  tenantId: string;
  approvalId?: string;
};

type RefundInput = {
  paymentId: string;
  amountInCents: number;
  idempotencyKey: string;
};

export async function refundPayment(
  context: ToolContext,
  input: RefundInput,
) {
  if (!context.approvalId) throw new Error("approval_required");
  if (input.amountInCents > 10_000) throw new Error("amount_not_allowed");

  return payments.refund({
    tenantId: context.tenantId,
    requestedBy: context.userId,
    approvalId: context.approvalId,
    ...input,
  });
}

The model can choose a tool. It does not get to choose the tenant or silently manufacture approval. Those values come from the authenticated request and your approval flow. This is the same discipline as the webhook inbox that rejects a duplicate event: make the invariant executable.

Step 2: Separate reads from irreversible writes

A read of a project name and a production deploy should not look identical to the policy layer. I would make the risk visible in the tool name and metadata, then enforce it server-side. Read tools can run automatically with a narrow credential. Send, delete, charge, publish, and deploy tools should return approval_required until a human or a higher-trust workflow supplies it.

Human approval is not a checkbox beside the tool Approval must bind to the exact action. “Ayush approved refunds” is too broad. “Ayush approved refund payment pay_123 for 2,500 cents before 14:32 UTC” can be checked. Expire it. Consume it once. Record the decision.

Step 3: Assume tool output can carry an attack

Prompt injection is not only text pasted into the user message. A support ticket, README, issue, email, or database row can tell the agent to ignore its policy and call another tool. Treat retrieved content as data. Keep instructions and untrusted fields visibly separate, and never let a document promote its own permissions.

export function normalizeTicket(ticket: Ticket) {
  return {
    id: ticket.id,
    subject: ticket.subject,
    body: ticket.body,
    // The model may read this text; it is never executable policy.
    trust: "untrusted" as const,
  };
}

That marker does not magically make a model safe. The real boundary is that the server never accepts a tool name, tenant, or credential from ticket.body. Use the marker to make the contract legible, then enforce the important parts in code.

Step 4: Log the decision, not the secret

An agent trace that says tool_called is not an audit trail. Record the request id, user, tenant, tool, validated arguments, policy result, approval id, latency, and outcome. Hash or redact sensitive values. When a refund is disputed, you should be able to answer who asked, what the agent saw, which policy allowed it, and which API actually changed money.

await audit.write({
  requestId,
  userId: context.userId,
  tenantId: context.tenantId,
  tool: "payments.refund",
  argumentsHash: hash({ paymentId, amountInCents }),
  approvalId: context.approvalId ?? null,
  policy: "refund-under-10000-cents",
  outcome: "allowed",
});

// Never put API keys or full payment details in this event.

Step 5: Ship one narrow tool and test its denials

The first MCP integration should be boring. Pick one read-only operation, give it a tenant-scoped credential, and test the failures before you add a write. The interesting test is not that the happy path returns a project. It is that a different tenant, a missing approval, an expired approval, an oversized amount, a duplicate idempotency key, and a slow upstream all fail predictably.

# The contract is the product surface.
npm test -- agent/tools.read.test.ts
npm test -- agent/tools.policy.test.ts

# Then inspect the audit shape before connecting a real account.
rg -n "requestId|tenantId|approvalId|argumentsHash|outcome" src server

The feed will move on to the next agent protocol. The useful part will still be here. MCP can standardize the door. Your application still decides who may open it, what they may touch, and whether the action can be undone.

MCP tool security questions

What is the main security risk with MCP tools?

The risk is not MCP by itself. It is giving an agent a tool whose description sounds narrow while its credential can do much more. A read tool should have a read credential, a payment tool should require explicit approval, and every call should be attributable to a user and request.

Is a system prompt enough to secure an MCP server?

No. A prompt is guidance, not an authorization boundary. Enforce the action, resource, tenant, and approval rules in the MCP server or the API behind it. Assume tool output and retrieved documents can contain hostile instructions.

Should every MCP tool require human approval?

No. Require approval for irreversible or externally visible actions such as sending email, changing production data, issuing a refund, or deploying code. Keep low-risk reads automatic, but still scope and log them.

How should I protect secrets used by an MCP tool?

Keep secrets in the server, not in the model context. Give each tool the smallest credential it needs, bind it to the tenant and user, rotate it, and redact it from logs. The model should receive the result of an action, never the bearer token that made it possible.

What should I build first for an MCP integration?

Start with one read-only tool and an audit record. Define its input schema, tenant boundary, timeout, rate limit, and denial behavior. Add writes only after you can inspect who called the tool, what resource it touched, and whether approval was present.