# Cookbook: Authorize MCP tool calls with OpenFGA

> **Scope of this page**
>
> This page documents the Zuplo **gateway** runtime (API, AI, and MCP gateways), not the Developer Portal.
>
> It applies to every Zuplo hosting option: Managed Edge, Managed Dedicated, and Self-Hosted. See [Hosting options](https://zuplo.com/docs/articles/hosting-options).
>
> It requires the Enterprise add-on.

Authenticating the MCP route tells you who the caller is. It doesn't tell you
whether that caller may run a particular tool with particular arguments, such as
transferring $5,000 out of account `acc_123`. This cookbook adds that decision
with the [OpenFGA Authorization policy](../policies/openfga-authz-inbound.mdx):
a small custom policy reads the caller and the tool arguments, and OpenFGA
decides whether the call goes ahead.

## Where the decision runs

When a client calls a tool, the MCP Server handler invokes the tool's API route
inside the gateway. That invocation runs the route's full inbound policy
pipeline, then its handler and outbound policies, the same as a direct HTTP
request. Put the authorization policies on the tool's route, not only on the MCP
route.

<Diagram height="h-56">
  <DiagramNode id="client">MCP client</DiagramNode>
  <DiagramNode id="mcp">MCP route: authentication</DiagramNode>
  <DiagramNode id="tool">
    Tool route: authentication, checks, OpenFGA
  </DiagramNode>
  <DiagramNode id="api">Your API</DiagramNode>
  <DiagramEdge from="client" to="mcp" label="tools/call" />
  <DiagramEdge from="mcp" to="tool" label="POST /accounts/acc_123/transfers" />
  <DiagramEdge from="tool" to="api" />
</Diagram>

On the tool's route, a policy sees:

| What               | Where to read it                                                                            |
| ------------------ | ------------------------------------------------------------------------------------------- |
| The caller         | `request.user`, which the authentication policy on the tool's route sets                    |
| Path arguments     | `request.params`                                                                            |
| Query arguments    | `request.query`                                                                             |
| Body arguments     | `await request.clone().json()`                                                              |
| The tool name      | `new ZuploMcpSdk(context).getRawCallToolRequest()?.params.name`                             |
| The raw tool input | `new ZuploMcpSdk(context).getRawCallToolRequest()?.params.arguments`, as the client sent it |

The MCP Server handler maps tool arguments onto the route's path, query, header,
and body parameters before it invokes the route, for both flat and nested
[tool input styles](./tools.mdx). Read the arguments from the request, and your
policy works the same for every input style.

The handler passes the MCP request's headers, including `Authorization`, to the
tool's route. Add the same authentication policy you use on the MCP route to the
tool's route, and `request.user` holds the same caller.

## Example: limit transfers by account and amount

The example API has a `transferFunds` operation,
`POST /accounts/{accountId}/transfers`, with a body of
`{ "amount": 250, "toAccountId": "acc_456" }`. The operation declares the
`accountId` path parameter and the request body schema, so the tool takes them
as arguments. The tool's name is the `operationId`, `transferFunds`, unless you
set `mcp.name` on the route. The rules are:

- Only callers you grant the `transferFunds` tool can call it.
- A caller can transfer from an account only up to the limit you grant them on
  that account.

### 1. Model the rules in OpenFGA

Write the authorization model in your OpenFGA store. The `within_transfer_limit`
condition compares the requested amount, which the gateway sends with each
check, to the maximum stored on the relationship:

```txt
model
  schema 1.1

type user

type mcp_tool
  relations
    define can_call: [user]

type account
  relations
    define can_transfer: [user with within_transfer_limit]

condition within_transfer_limit(amount: double, max_amount: double) {
  amount <= max_amount
}
```

Then write tuples for each user. For example, to let `alice` call the tool and
transfer up to 500 from `acc_123`:

| User         | Relation       | Object                   | Condition                                        |
| ------------ | -------------- | ------------------------ | ------------------------------------------------ |
| `user:alice` | `can_call`     | `mcp_tool:transferFunds` | None                                             |
| `user:alice` | `can_transfer` | `account:acc_123`        | `within_transfer_limit`, `{ "max_amount": 500 }` |

### 2. Write the checks policy

Create `modules/transfer-authz.ts`. The policy builds two OpenFGA checks from
the caller and the arguments, and stores them on the context with
`OpenFGAAuthZInboundPolicy.setContextChecks`. The OpenFGA policy that runs next
sends them to your store.

```ts title="modules/transfer-authz.ts"
import {
  HttpProblems,
  OpenFGAAuthZInboundPolicy,
  ZuploContext,
  ZuploMcpSdk,
  ZuploRequest,
} from "@zuplo/runtime";

interface TransferBody {
  amount: number;
  toAccountId: string;
}

interface Check {
  user: string;
  relation: string;
  object: string;
  context?: object;
}

export async function setTransferChecks(
  request: ZuploRequest,
  context: ZuploContext,
) {
  const userId = request.user?.sub;
  if (!userId) {
    return HttpProblems.unauthorized(request, context);
  }

  const { amount } = (await request.clone().json()) as TransferBody;
  if (typeof amount !== "number") {
    return HttpProblems.badRequest(request, context, {
      detail: "amount must be a number",
    });
  }

  const checks: Check[] = [
    {
      user: `user:${userId}`,
      relation: "can_transfer",
      object: `account:${request.params.accountId}`,
      // Values for the within_transfer_limit condition
      context: { amount },
    },
  ];

  // Present only when the route runs for an MCP tool call
  const toolCall = new ZuploMcpSdk(context).getRawCallToolRequest();
  if (toolCall) {
    checks.push({
      user: `user:${userId}`,
      relation: "can_call",
      object: `mcp_tool:${toolCall.params.name}`,
    });
  }

  OpenFGAAuthZInboundPolicy.setContextChecks(context, checks);
  return request;
}
```

The request goes ahead only when OpenFGA allows every check. If any check fails,
the OpenFGA policy returns `403 Forbidden`, and the MCP client receives a tool
result with `isError` set.

### 3. Configure the policies

Add the checks policy and the OpenFGA policy to `config/policies.json`. Store
the OpenFGA token in an
[environment variable](../articles/environment-variables.mdx):

```json title="config/policies.json"
{
  "policies": [
    {
      "name": "set-transfer-checks",
      "policyType": "custom-code-inbound",
      "handler": {
        "export": "setTransferChecks",
        "module": "$import(./modules/transfer-authz)"
      }
    },
    {
      "name": "openfga-authz",
      "policyType": "openfga-authz-inbound",
      "handler": {
        "export": "OpenFGAAuthZInboundPolicy",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "apiUrl": "https://api.us1.fga.dev",
          "storeId": "$env(OPENFGA_STORE_ID)",
          "authorizationModelId": "$env(OPENFGA_MODEL_ID)",
          "credentials": {
            "method": "api-token",
            "token": "$env(OPENFGA_API_TOKEN)"
          }
        }
      }
    }
  ]
}
```

### 4. Add the policies to the tool's route

On the `transferFunds` operation, list the authentication policy first, then the
checks policy, then the OpenFGA policy. This example uses `mcp-oauth-inbound`,
the OAuth policy on the MCP route from the
[MCP Server handler](../handlers/mcp-server.mdx#oauth-authentication) setup:

```json title="config/routes.oas.json"
"/accounts/{accountId}/transfers": {
  "post": {
    "operationId": "transferFunds",
    "x-zuplo-route": {
      "handler": {
        "export": "urlForwardHandler",
        "module": "$import(@zuplo/runtime)",
        "options": {
          "baseUrl": "https://api.example.com"
        }
      },
      "policies": {
        "inbound": [
          "mcp-oauth-inbound",
          "set-transfer-checks",
          "openfga-authz"
        ]
      },
      "mcp": {
        "type": "tool"
      }
    }
  }
}
```

Because the checks run on the route, they also apply when a client calls
`POST /accounts/{accountId}/transfers` directly over HTTP. In that case there's
no tool call, so only the account check runs. To accept the operation only
through the MCP server, also add the
[Internal Only policy](../policies/internal-only-inbound.mdx), as described in
[Keep tool routes private](./tools.mdx#keep-tool-routes-private).

## Use AuthZEN instead

If your policy decision point speaks AuthZEN, use the
[AuthZEN Authorization policy](../policies/authzen-inbound.mdx) in place of
OpenFGA. Build the subject, resource, and action from `request.user` and the
arguments in the same kind of custom policy, and store them with
`AuthZenInboundPolicy.setAuthorizationPayload`. This example uses the `userId`
and `toolCall` variables from the checks policy above:

```ts
AuthZenInboundPolicy.setAuthorizationPayload(context, {
  subject: { type: "user", id: userId },
  resource: { type: "account", id: request.params.accountId },
  action: { name: toolCall?.params.name ?? request.method },
});
```

An AuthZEN request carries one subject, resource, and action. To decide on an
argument value such as the amount, use OpenFGA conditions as this cookbook does.

## Next steps

- Choose which operations become tools in [Tools](./tools.mdx).
- Read every [ZuploMcpSdk](../programmable-api/mcp-sdk.mdx) method available to
  policies and handlers.
- Test the denied and allowed paths with the tools in
  [Test your MCP server](./testing.mdx).
