MCP Server

Cookbook: Authorize MCP tool calls with OpenFGA

Applies to
Gatewayruntime for API, AI, and MCP gatewaysEnterprise add-on
Hosting
Managed EdgeManaged DedicatedSelf-Hosted

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: 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.

MCP client
MCP route: authentication
Tool route: authentication, checks, OpenFGA
Your API

On the tool's route, a policy sees:

WhatWhere to read it
The callerrequest.user, which the authentication policy on the tool's route sets
Path argumentsrequest.params
Query argumentsrequest.query
Body argumentsawait request.clone().json()
The tool namenew ZuploMcpSdk(context).getRawCallToolRequest()?.params.name
The raw tool inputnew 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. 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:

Code
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:

UserRelationObjectCondition
user:alicecan_callmcp_tool:transferFundsNone
user:alicecan_transferaccount:acc_123within_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.

TypeScriptmodules/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:

JSONconfig/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 setup:

JSONconfig/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, as described in Keep tool routes private.

Use AuthZEN instead

If your policy decision point speaks AuthZEN, use the AuthZEN Authorization policy 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:

TypeScriptCode
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.
  • Read every ZuploMcpSdk method available to policies and handlers.
  • Test the denied and allowed paths with the tools in Test your MCP server.
Last modified on