Skip to main content
Intelliger
Bind authority to the exact request

How to Authorize an MCP Tool Call

Authorize an MCP tool call by verifying identity, mandate, policy and exact request arguments before execution, with replay-safe evidence afterward.

Developer reviewing an MCP tool-call permission panel beside a code editor
Intelliger
11 min read

To authorize an MCP tool call, verify the caller and credential, canonicalize the exact tool name and arguments, bind them to a mandate and policy decision, prevent replay, then execute only the approved request. Listing a tool proves availability. Possessing a transport token may prove access to an MCP server. Neither proves authority for this particular side effect.

This implementation guide is for MCP server developers exposing consequential tools such as issue_refund, change_supplier_bank_account or release_purchase_order. The outcome is a middleware boundary that can deny an altered request before business logic runs and produce evidence that names what was approved and what actually happened.

Availability, access and authority are different

MCP tool discovery tells a client which tools a server exposes and their input schemas. The MCP specification states that servers expose tools clients can discover and invoke; it also calls for input validation, access controls, rate limiting and output sanitization on the server side (MCP tools specification).

MCP authorization for HTTP transports uses OAuth-oriented mechanisms to control access to protected servers. That answers whether a client can present an acceptable credential to the resource server. It does not automatically answer whether the represented principal may refund order 9182 for €450 right now.

Use three separate decisions:

DecisionExample questionTypical evidence
AvailabilityDoes this server expose issue_refund?tool list and schema
AccessMay this client call the protected MCP server?validated access token, audience and scopes
Action authorityMay this principal perform this exact refund?mandate, policy, request digest and current business state

OAuth scopes can narrow access, but a scope such as refunds:write is usually too broad to encode amount limits, merchant boundary, order ownership, approval state and expiry. Keep it as one input to the action decision, not the decision itself.

For the broader model, read AI agent authorization. For why MCP needs this added authority layer, see MCP gives agents tools, but who gives authority?.

Model the exact request

Authorization should bind to a canonical transaction envelope. Do not sign raw JSON text: property order, whitespace and number representation can change without changing meaning. Validate against a strict schema, normalize allowed values and produce a deterministic encoding before hashing.

type ToolEnvelope<TArgs> = {
  protocol: 'mcp';
  serverAudience: string;
  toolName: string;
  arguments: TArgs;
  principalId: string;
  agentId: string;
  mandateId: string;
  transactionId: string;
  nonce: string;
  issuedAt: string;
  expiresAt: string;
};

type AuthorizationDecision = {
  decisionId: string;
  effect: 'allow' | 'deny';
  requestDigest: string;
  policyVersion: string;
  mandateVersion: string;
  obligations: Array<
    | { type: 'human_approval'; approvalId: string }
    | { type: 'revalidate_order_state' }
    | { type: 'record_receipt' }
  >;
  decidedAt: string;
  expiresAt: string;
};

The digest must cover at least the server audience, tool name, canonical arguments, represented principal, agent, mandate, transaction ID, nonce and expiry. If a client obtains approval for amountMinor: 4500, changing it to 45000 must produce a different digest and a denial.

Use integer minor units for currency and an explicit ISO currency code. Reject unknown fields unless the tool schema deliberately permits extensions. Ambiguous input is not flexibility at a consequential boundary.

Put authorization before tool execution

The middleware sequence is:

  1. Authenticate the connection and validate the token issuer, signature, audience, expiry and required access scope.
  2. Parse the MCP request and validate the tool-specific argument schema.
  3. Resolve the principal, agent and mandate without trusting client-supplied display fields.
  4. Canonicalize the envelope and compute its digest.
  5. Claim the nonce or transaction ID atomically to stop duplicate execution.
  6. Evaluate policy against the mandate, exact request and current business state.
  7. Satisfy obligations such as fresh approval or state revalidation.
  8. Recompute or compare the digest immediately before invoking business logic.
  9. Execute through an idempotent domain API.
  10. Record an action receipt containing decision and result evidence.
async function authorizeAndCall<TArgs, TResult>(
  ctx: AuthenticatedContext,
  input: ToolEnvelope<TArgs>,
  handler: (args: TArgs, key: string) => Promise<TResult>,
): Promise<TResult> {
  assertAudience(ctx.token, input.serverAudience);
  assertPrincipalBinding(ctx, input.principalId, input.agentId);
  assertFreshWindow(input.issuedAt, input.expiresAt, clock.now());

  const args = schemas.forTool(input.toolName).parse(input.arguments);
  const canonical = canonicalize({ ...input, arguments: args });
  const digest = sha256(canonical);

  const replay = await nonceStore.claim({
    namespace: input.serverAudience,
    nonce: input.nonce,
    transactionId: input.transactionId,
    expiresAt: input.expiresAt,
  });
  if (!replay.claimed) return recoverExistingResult(replay);

  const mandate = await mandateStore.getCurrent(input.mandateId);
  const decision = await policy.evaluate({ ctx, input, args, digest, mandate });
  if (decision.effect !== 'allow') throw new Forbidden(decision.decisionId);

  await satisfyObligations(decision.obligations, { input, digest, mandate });
  if (
    sha256(canonicalize({ ...input, arguments: args })) !==
    decision.requestDigest
  ) {
    throw new RequestChanged();
  }

  try {
    const result = await handler(args, input.transactionId);
    await receipts.recordSuccess({ input, digest, decision, result });
    return result;
  } catch (error) {
    await receipts.recordFailure({ input, digest, decision, error });
    throw error;
  }
}

The sample assumes policy.evaluate returns the same digest it evaluated. In production, fail closed if the decision is missing a digest, has expired or references a different mandate or policy version.

Write policy over domain facts

A useful rule names concrete domain boundaries. Consider a refund tool:

policy_id: refund-agent-v3
tool: issue_refund
allow_when:
  principal_role: customer_support_agent
  merchant_id_from_token: equals(arguments.merchantId)
  order_customer: equals(principal.customerId)
  currency: EUR
  amount_minor: { max: 5000 }
  order_state: [paid, partially_refunded]
  mandate_purpose: customer_remediation
  mandate_expires_after_request: true
obligations:
  - revalidate_order_state
  - require_human_approval_if: amount_minor > 2500
  - record_receipt

Resolve order_state from the order system during evaluation or as a pre-execution obligation. Do not accept it from the model's arguments. Similarly, derive merchant tenancy from trusted identity context, not a mutable field alone.

Policy versions must be immutable and addressable. A later auditor needs to reproduce the rule that made the decision, not whatever rule happens to be deployed today.

Handle replay and uncertain outcomes

Nonce checks alone do not provide exactly-once execution. A server can execute the refund, lose the response and receive a retry. Use a domain idempotency key tied to the transaction ID and make retries recover the recorded result.

Use these states:

received -> authorized -> executing -> succeeded | failed | outcome_unknown

An outcome_unknown state is necessary when the downstream service times out after accepting a request. Do not retry blindly. Query the domain system by idempotency key, reconcile the result and then finalize the receipt.

The safe behavior for common failures is explicit:

Same nonce, same digest. Return the prior result or current transaction status. Do not execute again.

Same nonce, different digest. Deny and flag a replay or substitution attempt.

Approval references a different digest. Deny even if the amount is lower. A different request needs a new decision.

Mandate revoked after decision but before execution. Recheck current mandate status at the execution boundary. Deny or require a new decision.

Policy service unavailable. Fail closed for consequential writes. Read-only low-risk tools may have a separately reviewed degradation policy.

Business state changed. If the order became fully refunded, deny or return the existing result. Never rely on the earlier eligible state.

Receipt write fails after successful action. Do not repeat the action. Mark evidence delivery pending and repair asynchronously from the idempotent domain record.

The detailed transaction path is covered in from MCP tool call to auditable enterprise transaction. Receipts are evidence, not a substitute for authorization; see AI audit trails and action receipts.

Verify the boundary with attack fixtures

Unit tests for the happy path are insufficient. Run a conformance fixture against middleware and a fake idempotent domain service.

const cases = [
  { name: 'exact approved request', mutate: none, expect: 'allow', calls: 1 },
  {
    name: 'amount substitution',
    mutate: set('amountMinor', 45000),
    expect: 'deny',
    calls: 0,
  },
  {
    name: 'tool substitution',
    mutate: set('toolName', 'change_bank_account'),
    expect: 'deny',
    calls: 0,
  },
  {
    name: 'audience swap',
    mutate: set('serverAudience', 'mcp://other'),
    expect: 'deny',
    calls: 0,
  },
  { name: 'expired mandate', mutate: expireMandate, expect: 'deny', calls: 0 },
  {
    name: 'same retry',
    mutate: replayIdentically,
    expect: 'recover',
    calls: 1,
  },
  {
    name: 'nonce with new args',
    mutate: replayWithNewArgs,
    expect: 'deny',
    calls: 0,
  },
  {
    name: 'downstream timeout after commit',
    mutate: timeoutAfterCommit,
    expect: 'reconcile',
    calls: 1,
  },
];

For every case, assert the domain call count, decision effect, request digest, policy and mandate versions, transaction state and receipt outcome. Set the clock and key material in the fixture so results are reproducible. Fuzz unknown fields, numeric boundaries, Unicode normalization and reordered JSON properties. A canonicalization implementation should either normalize them predictably or reject them.

Also test confused-deputy conditions: a valid token for one MCP server presented to another, a principal from one tenant paired with an order from another and a client requesting a redirect or resource that changes the intended audience. OAuth protected-resource metadata exists partly to help clients identify the correct resource and authorization servers (RFC 9728). Validate the resource audience at the server anyway.

Implementation checklist

  • Validate token signature, issuer, audience, expiry and required access scope.
  • Resolve principal and agent identity from trusted context.
  • Validate arguments with a strict, tool-specific schema.
  • Canonicalize and hash the tool name, arguments and transaction context.
  • Bind each decision and approval to that exact digest.
  • Evaluate mandate limits, policy version and current domain state.
  • Claim nonce and transaction ID atomically.
  • Use a domain idempotency key and reconcile uncertain outcomes.
  • Recheck revocation and mutable business state before execution.
  • Record success, denial, failure and unknown outcomes without secrets.
  • Test substitution, replay, cross-tenant and timeout-after-commit cases.
  • Obtain expert security review and threat modeling before production.

Current Intelliger and OATI boundary

OATI is an open standard in developer preview, not a production authorization service operated across customer fleets. Current implemented assets include schemas, a TypeScript SDK, portable Python and Go core, a CLI, 73 conformance tests, local Commerce and RWA sandboxes, an Envoy reference and a deployed public trust/lookup vertical slice.

The full policy compiler, independent security review, evidence and dispute workflows, customer gateway fleet, Hub experience and two-enterprise production acceptance are not complete. The transaction envelope, mandate and action-receipt approach informs the pattern above, but teams must verify the exact repository status before adopting it. See the OATI overview, developer documentation and OATI repository.

Security review note: the code and policy here are illustrative, not a deployable security control. Cryptographic canonicalization, OAuth configuration, tenancy controls, payment operations and replay recovery require review by qualified security and domain experts for your deployment.

To test this boundary rather than merely discuss it, run the OATI developer-preview conformance material from the public repository.