Skip to main content

Build an AI access request app with the Access Request MCP server

Install the open-source Permit.io Access Request MCP server, run it in Claude Desktop, and then extend it into your own MCP server for a family food-ordering app with a FastAPI backend, Gemini, and a command-line client. This tutorial is for AI agent builders who want users to create, review, approve, and deny access requests in natural language.

Access request MCP server or Permit MCP Gateway

This page covers the Access Request MCP server from the permitio/permit-mcp repository: a Model Context Protocol (MCP) server that you run and extend yourself, with tools that call Permit's access request and approval APIs.

It is not Permit MCP Gateway, the product that sits in front of your existing MCP servers and authorizes every tool call. For how the Access Request MCP server fits human-in-the-loop agent workflows, see the Access Request MCP overview.

What the Access Request MCP server does

The Access Request MCP server gives an AI assistant tools to:

ToolWhat it does
create_access_requestRequests a role on a resource instance for a user.
list_access_requestsLists access requests.
approve_access_request, deny_access_requestApproves or denies an access request.
create_operation_approvalRequests one-time approval for an operation on a resource instance.
list_operation_approvalsLists operation approval requests.
approve_operation_approval, deny_operation_approvalApproves or denies an operation approval request.
list_resource_instancesLists instances of the resource the server manages.

The tools call the Permit API behind Permit Elements, Permit's embeddable UI components for access requests and approvals.

Ways to run the server

SetupWho uses itHow it works
LocalReviewers who hold the Permit credentialsRun the server on your machine and connect an AI assistant, such as Claude Desktop, to view, approve, or deny the access requests your users submit.
Hosted in your applicationYour application's end usersRun the server and the large language model (LLM) in your backend. Users send requests through your application, and the backend calls the tools. The food-ordering tutorial on this page builds this setup. The finished code is in the food-ordering-system example.

Prerequisites

Install the Access Request MCP server

Clone the repository, create a virtual environment, and install the server:

git clone https://github.com/permitio/permit-mcp
cd permit-mcp

# Create a virtual environment, activate it, and install dependencies
uv venv
source .venv/bin/activate # For Windows: .venv\Scripts\activate
uv pip install -e .

Set the environment variables

Create a .env file in the root of the cloned repository with the variables from .env.example:

TENANT= # default
RESOURCE_KEY= # The key of the resource you want to manage access for.
PERMIT_PDP_URL= # defaults to the cloud PDP https://cloudpdp.api.permit.io
PERMIT_API_KEY=
PROJECT_ID=
ENV_ID=
ACCESS_ELEMENTS_CONFIG_ID=
OPERATION_ELEMENTS_CONFIG_ID=
VariableValue
TENANTThe tenant key of your resource instances, such as default.
RESOURCE_KEYThe key of the resource you manage access for.
PERMIT_PDP_URLThe URL of your policy decision point (PDP). Without a value, the server uses the Cloud PDP at https://cloudpdp.api.permit.io. See Run the PDP container.
PERMIT_API_KEYYour environment API key. See Get your environment API key.
PROJECT_IDYour project ID. See Get the project ID or key.
ENV_IDYour environment ID. See Get the environment ID or key.
ACCESS_ELEMENTS_CONFIG_IDThe config ID of your User Management element.
OPERATION_ELEMENTS_CONFIG_IDThe config ID of your Approval Management element.

The food-ordering tutorial creates the resource and both elements, and shows where to find the config IDs.

Run the Access Request MCP server in Claude Desktop

  1. Install Claude Desktop.

  2. Add the server to the Claude Desktop MCP configuration. Replace /ABSOLUTE/PATH/TO/PARENT/FOLDER with the absolute path of your cloned permit-mcp repository:

    {
    "mcpServers": {
    "permit": {
    "command": "uv",
    "args": [
    "--directory",
    "/ABSOLUTE/PATH/TO/PARENT/FOLDER/src/permit_mcp",
    "run",
    "server.py"
    ]
    }
    }
    }
  3. Restart Claude Desktop. The Permit tools appear in Claude's tool list, and you can ask Claude to list or approve access requests.

For every configuration option, see the Access Request MCP implementation guide.

Tutorial: build a family food-ordering CLI with the Access Request MCP server

This tutorial extends the Access Request MCP server into a command-line app. Authenticated family members browse restaurants, order dishes, and manage access in natural language.

The app has three parts:

  1. A custom MCP server that includes the Access Request MCP server tools and adds list_dishes and order_dish.
  2. A FastAPI backend that runs the MCP server, passes its tools to Gemini, and serves a WebSocket chat endpoint.
  3. A command-line client that signs users in and chats with the backend.

Parents and children use the app with these rules:

  • A child who wants to see a restricted restaurant submits an access request, and a parent approves or denies it.
  • A child who wants a dish that costs more than $10 requests one-time approval, and a parent approves or denies it.

The backend in this tutorial calls Gemini directly and lets the MCP tools run the permission checks. For the same app built as a LangGraph agent that pauses mid-run for a reviewer with interrupt(), see the food ordering demo.

1. Set up the policy in Permit

Create the resource, roles, and Permit Elements that the MCP server tools use. The app uses four of the Permit Elements, two for access requests and two for operation approvals:

ElementRole in the app
Access RequestLets users request access to a restricted resource.
User ManagementSets which users can review access requests, based on their permission level, and lets them approve or deny requests.
Operation ApprovalLets users request approval for one operation on a resource.
Approval ManagementLets reviewers approve or deny operation approval requests.

The MCP server tools call the API behind these elements, so you don't embed the element UIs in this app.

Access control design

The food-ordering app uses relationship-based access control (ReBAC) with:

  • A resource with the key restaurants.
  • Two ReBAC resource roles on restaurants:
    • parent: can create, read, update, and delete.
    • child-can-view: can only read.
  • A User Management element named "Restaurant requests", which lets parents manage their children's requests to access restaurants.
  • An Operation Approval element named "Dish approval". The element adds two roles to the restaurants resource: _Reviewer_, for users who review approval requests, and _Approved_, for users whose request is approved.
  • An Approval Management element named "Dish requests".

Create the restaurants resource

  1. In the Permit dashboard, go to Policy > Resources, and click Create a Resource.

    Resources tab of the Policy screen in the Permit dashboard, with the Create a Resource button

  2. Name the resource restaurants, and add two ReBAC roles: parent and child-can-view.

    Create resource form with the key restaurants and the ReBAC roles parent and child-can-view

  3. On the Policy Editor tab, give restaurants#parent the create, read, update, and delete actions, and give restaurants#child-can-view only read.

    Policy Editor with restaurants#child-can-view allowed only read, and restaurants#parent allowed create, delete, read, and update

Create the User Management element

  1. Go to Elements, and create a User Management element with these values:

    • Name: Restaurant Requests
    • Configure elements based on: ReBAC Resource Roles
    • Resource Type: restaurants
    • Role permission levels: Level 1 - Workspace Owner: parent
    • Assignable Roles: child-can-view

    User Management element form for Restaurant Requests, based on ReBAC resource roles of restaurants, with parent at level 1 and child-can-view assignable

  2. Click Create.

  3. On the element's tab, click Get Code. Copy the element config ID, restaurant-requests. You use it as ACCESS_ELEMENTS_CONFIG_ID.

    Get Code panel of the Restaurant Requests element, showing the config ID restaurant-requests

Create the Operation Approval element

Create an Operation Approval element with these values:

  • Name: Dish approval
  • Resource Type: restaurants

Dish approval element form with resource type restaurants and the default roles Approved and Reviewer

Create the Approval Management element

  1. Create an Approval Management element named "Dish requests".

    Approval Management element form named Dish requests

  2. Copy the element config ID, dish-requests. You use it as OPERATION_ELEMENTS_CONFIG_ID.

Set RESOURCE_KEY=restaurants and both config IDs in your .env file.

2. Build a custom MCP server

Install the app dependencies and set its variables

  1. Install the extra dependencies:

    uv add 'fastapi[standard]' google-genai bcrypt 'python-jose[cryptography]' rich websockets
  2. Add these variables to your .env file. Get a Gemini API key from Google AI Studio.

    PERMIT_PDP_URL= # The local Permit PDP URL http://localhost:7766
    GEMINI_API_KEY=
    DB_NAME=food_ordering.db

This app uses a PDP that you run next to the app, at http://localhost:7766, so that authorization runs inside your own infrastructure. To run one, see Run the PDP container. To compare the Cloud PDP with a PDP you run, see Cloud PDP capabilities.

Add food-ordering tools to the Access Request MCP server

The Access Request MCP server lets users list restaurants, as resource instances, but it has no tool to list or order dishes. The custom MCP server adds two tools to the Access Request MCP server tools:

  • list_dishes: lists the dishes of a restaurant and their prices.
  • order_dish: places an order for a dish.
  1. Create permit_client.py in the root of the cloned repository. The file creates one Permit client that the other files import:

    import os
    from permit import Permit
    from dotenv import load_dotenv

    # Load environment variables
    load_dotenv()

    PERMIT_PDP_URL = os.getenv("PERMIT_PDP_URL")
    PERMIT_API_KEY = os.getenv("PERMIT_API_KEY")

    permit = Permit(
    pdp=PERMIT_PDP_URL,
    token=PERMIT_API_KEY,
    )
  2. Create food_ordering_mcp.py in the root of the repository. Import the libraries, read the database name from the command-line arguments, and connect to the SQLite database:

    from typing import List, Tuple
    import aiosqlite
    import sqlite3
    from mcp.server.fastmcp import FastMCP
    from dotenv import load_dotenv
    import os
    import sys
    from src.permit_mcp.server import PermitServer
    from mcp.server.fastmcp.exceptions import ToolError
    from permit_client import permit

    load_dotenv()

    TENANT = os.getenv("TENANT")

    if len(sys.argv) > 1:
    DB_NAME = sys.argv[1]
    else:
    DB_NAME = "test.db"


    conn = sqlite3.connect(DB_NAME)
    cursor = conn.cursor()
  3. Create a FastMCP instance, and register the Access Request MCP server tools on it:

    # Initialize the FastMCP instance and the Permit MCP
    # server, which registers its tools on that instance.
    mcp = FastMCP("family_food_ordering_system")
    permit_server = PermitServer(mcp)
  4. Add the list_dishes tool. The tool checks whether the user can read the restaurant, and returns the dish names and prices. If the check fails, the tool raises an access denied error.

    @mcp.tool()
    async def list_dishes(user_id: str, restaurant_id: str) -> List[Tuple[str, float]]:
    """
    Lists the dishes available at a given restaurant along with their prices in dollars.
    Dishes are only listed when the user has access; otherwise, an access request must be sent.

    Args:
    user_id: The ID of the user.
    restaurant_id: The key of the restaurant.
    """

    # Check if a user is permitted in the restaurant
    permitted = await permit.check(user_id, 'read', f"restaurants:{restaurant_id}")
    if not permitted:
    raise ToolError(
    "Access denied. You are not permitted to view dishes from this restaurant."
    )

    async with aiosqlite.connect(DB_NAME) as db:
    # Fetch dishes
    dishes_query = """
    SELECT name, price FROM dishes
    WHERE restaurant_id = ?
    """
    cursor = await db.execute(dishes_query, (restaurant_id,))
    dishes = await cursor.fetchall()
    await cursor.close()
    return dishes
  5. Add the order_dish tool. The tool:

    • Checks that the dish and the user exist.
    • Checks that the user can read the restaurant.
    • Checks whether the user can operate on the restaurant, which the _Approved_ role grants. A child can order a dish over $10 only with that permission.
    • After an approved order, unassigns the _Approved_ role, so the approval covers one order.
    @mcp.tool()
    async def order_dish(user_id: str, restaurant_id: str, dish_name: str) -> str:
    """
    Processes an order for a dish.

    Args:
    user_id: The ID of the person ordering.
    restaurant_id: The key of the restaurant.
    dish_name: The name of the dish to order.
    """
    MAX_ALLOWED_DISH_PRICE = 10 # 10 dollars

    async with aiosqlite.connect(DB_NAME) as db:
    # Get dish price
    dish_cursor = await db.execute(
    "SELECT price FROM dishes WHERE name = ? AND restaurant_id = ?",
    (dish_name, restaurant_id),
    )
    dish = await dish_cursor.fetchone()
    await dish_cursor.close()

    if dish is None:
    raise ToolError(
    f"Dish '{dish_name}' not found."
    )

    # Get user role
    user_cursor = await db.execute(
    "SELECT role FROM users WHERE id = ?",
    (user_id,),
    )
    user = await user_cursor.fetchone()
    await user_cursor.close()

    if user is None:
    raise ToolError(
    f"User with ID '{user_id}' not found. Please check the user ID."
    )

    # Check if a user is permitted in the restaurant that serves this dish.
    permitted = await permit.check(user_id, "read", f"restaurants:{restaurant_id}")

    if not permitted:
    raise ToolError(
    "Access denied. You are not permitted to order from this restaurant."
    )

    # Check if a user is permitted to order costly dishes.
    permitted = await permit.check(user_id, "operate", f"restaurants:{restaurant_id}")

    # Apply price restriction for children
    if user[0] == "child" and dish[0] > MAX_ALLOWED_DISH_PRICE and not permitted:
    raise ToolError(
    f"This dish costs ${dish[0]:.2f}, and you can only order dishes less than "
    f"${MAX_ALLOWED_DISH_PRICE:.2f}. To order this dish, you need to request approval."
    )

    if permitted:
    await permit.api.users.unassign_role({
    "user": user_id,
    "role": "_Approved_",
    "resource_instance": f"restaurants:{restaurant_id}",
    "tenant": TENANT
    })

    return f"Order successfully placed for {dish_name}!"
  6. Run the MCP server over standard input/output (stdio) when the script runs:

    if __name__ == "__main__":
    mcp.run(transport="stdio")

3. Build the FastAPI backend

The FastAPI backend keeps the Permit API key and the Gemini API key on the server, where the command-line client can't read them. The backend:

  • Signs users in and issues JSON Web Tokens (JWTs).
  • Starts food_ordering_mcp.py as a subprocess and passes only the tools that match the user's role to Gemini.
  • Serves a WebSocket endpoint that takes user messages and returns responses as they arrive.

Copy the backend files

The backend is two files. Copy both from the food-ordering-system example to the root of your cloned repository, next to food_ordering_mcp.py:

FileWhat it holds
utils.pyinit_db(), which creates the SQLite database, syncs the four users to Permit, creates one resource instance per restaurant, and assigns the roles. filter_tools_by_role(), which limits a child to the tools that list restaurants, list dishes, order a dish, and create requests. convert_mcp_tools_to_gemini(), which turns MCP tool definitions into Gemini function declarations. retry_tool_call(), which retries a failed tool call. Password hashing and JWT helpers.
server.pyThe FastAPI app, the /token sign-in endpoint, and the /ws/chat WebSocket endpoint.

server.py starts the MCP server over standard input/output (stdio) and creates the app. The FastAPI lifespan hook calls init_db(), so the database and the Permit facts exist before the first request:

server_params = StdioServerParameters(
command="python",
args=["food_ordering_mcp.py", DB_NAME],
env=None,
)

genai_client = genai.Client(api_key=GEMINI_API_KEY)


@asynccontextmanager
async def lifespan(app: FastAPI):
await init_db()
yield

app = FastAPI(lifespan=lifespan)

The /token endpoint verifies the password against the hash in the database and returns the JWT that the client sends on every WebSocket connection:

@app.post("/token")
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = get_user(form_data.username)
if not user or not verify_password(form_data.password, user["hashed_password"]):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)

access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user["username"]}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}

How the chat endpoint enforces the user's role

The websocket_chat function in server.py handles one chat session. For each connection it:

  1. Reads the JWT from the Authorization header and closes the connection with code 1008 when the token is missing, invalid, or belongs to a user without a role.
  2. Starts food_ordering_mcp.py over stdio and opens an MCP ClientSession. AsyncExitStack closes both when the session ends.
  3. Lists the MCP tools, passes them through filter_tools_by_role() with the signed-in user's role, and converts the result to Gemini function declarations. A child never receives the approval tools, so Gemini can't call them for a child.
  4. Calls Gemini with the conversation history, the filtered tools, and a system instruction that carries the user ID, the user's role, the assignable role child-can-view, and the rule that every tool call includes a resource_instance.
  5. Runs the function calls Gemini requests against the MCP server, appends the results to the history, and calls Gemini again until the model returns a response with no function calls.
  6. Sends each message to the client as JSON with a type of text, status, error, or history_update.

The permission checks run inside the MCP tools, not in the backend. list_dishes and order_dish call permit.check(), and the Access Request MCP server tools call the Permit API, so the backend can pass a request straight to Gemini and let the PDP decide.

Set a current Gemini model

server.py in the example calls genai_client.models.generate_content with a pinned Gemini model name. Google retires Gemini model versions, so before you run the backend, set the model argument to a current model from the Gemini models list:

model="gemini-2.5-flash",

A retired model name makes every chat message fail with a model-not-found error from the Gemini API.

4. Set up the command-line chat client

The client signs the user in, connects to the WebSocket endpoint, sends messages, and prints responses as they arrive. Copy client.py from the example to the root of your repository.

FunctionWhat it does
login()Posts the username and password to /token and returns the access token.
chat()Opens the WebSocket connection with the token, starts a background task that prints each message the backend sends, and reads your input until you type exit. While the backend processes a message, the loop waits instead of sending a second message.
main()Prompts for a username and password, signs in, and starts the chat session.

The client connects to localhost:8000. If your backend runs on another host or port, change both constants at the top of client.py:

API_URL = "http://localhost:8000"
WS_URL = "ws://localhost:8000"

5. Run and test the app

Start the backend and the client

  1. Check that your PDP is running at the PERMIT_PDP_URL you set. A local PDP container answers curl http://localhost:7766/health with HTTP 200 and "status": "ok". See Verify the PDP is healthy.

  2. Start the FastAPI backend:

    fastapi dev server.py

    On the first run, init_db() creates the database and the Permit facts, so the log includes these lines:

    Initializing database
    Setting up Permit...
    Database initialization complete.
    INFO: Application startup complete.
    INFO: Uvicorn running on http://127.0.0.1:8000 (Press CTRL+C to quit)

    If the log shows a Permit API error instead, check PERMIT_API_KEY, PROJECT_ID, and ENV_ID in .env, and check that the restaurants resource exists with the parent and child-can-view roles.

  3. In another terminal, start the client:

    uv run client.py
  4. Sign in with one of the users that init_db() creates:

    UsernamePasswordRole
    joejoe_passwordparent
    janejane_passwordparent
    henryhenry_passwordchild
    roserose_passwordchild

    After a successful sign-in, the client opens the chat session:

    --- Chat session started ---
    Type 'exit' to quit.

    You:

    The database stores a bcrypt hash of each password, and init_db() seeds these four users for the demo. Replace them with your own sign-in before you run the app anywhere but your machine.

Verify the access request flow

init_db() creates four restaurants. Children get the child-can-view role on Pizza Palace and Burger Bonanza only, so Fancy French and Sushi World are the restricted restaurants.

  1. As henry, ask for the dishes of Fancy French. The permit.check() call in list_dishes returns false, and the assistant reports the tool error:

    Access denied. You are not permitted to view dishes from this restaurant.
  2. Ask the assistant to request access to Fancy French, and give a reason when it asks for one. The create_access_request tool answers Your request has been successfully sent.

  3. Type exit, sign in as joe, and ask the assistant to list the pending access requests and approve Henry's. The approve_access_request tool answers Access request approved successfully.

  4. Sign in as henry again and ask for the dishes of Fancy French. The assistant now lists Escargot, Foie Gras, and Truffle Pasta with their prices.

Verify the operation approval flow

  1. As henry, ask to order the Deluxe Burger from Burger Bonanza, which costs $12.99. The price check in order_dish blocks the order:

    This dish costs $12.99, and you can only order dishes less than $10.00. To order this dish, you need to request approval.
  2. Ask the assistant to request approval for that order. The create_operation_approval tool answers Operation approval request created successfully.

  3. Sign in as joe and approve the request. Permit assigns the _Approved_ role to henry on that restaurant.

  4. Sign in as henry and order the Deluxe Burger again. The order succeeds with Order successfully placed for Deluxe Burger!, and order_dish unassigns the _Approved_ role, so the next order over $10 needs a new approval.

Every check and decision in these flows appears in the Permit audit logs.

The video shows the app running:

Best practices

Sync users with their names

Set each user's name when you sync or create users in Permit. Reviewers then see who submitted each access or approval request.

await permit.api.users.sync({
"key": user_id,
"first_name": firstname
})

Store resource instance names as attributes

With a ReBAC model, if a resource instance key isn't the instance's name, add the name, and any other identifying details, as attributes when you create the instance. You and the LLM can then identify the instance by name.

await permit.api.resource_instances.create({
"resource": "restaurants",
"key": restaurant_id,
"tenant": TENANT,
"attributes": {
"name": restaurant_name,
"allowed_for_children": bool(allowed_for_children)
}
})

Next steps