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.
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:
| Tool | What it does |
|---|---|
create_access_request | Requests a role on a resource instance for a user. |
list_access_requests | Lists access requests. |
approve_access_request, deny_access_request | Approves or denies an access request. |
create_operation_approval | Requests one-time approval for an operation on a resource instance. |
list_operation_approvals | Lists operation approval requests. |
approve_operation_approval, deny_operation_approval | Approves or denies an operation approval request. |
list_resource_instances | Lists 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
| Setup | Who uses it | How it works |
|---|---|---|
| Local | Reviewers who hold the Permit credentials | Run 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 application | Your application's end users | Run 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
- Python 3.10 or later.
uv0.6.1 or later.- A Permit.io account. See Create a Permit.io account.
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=
| Variable | Value |
|---|---|
TENANT | The tenant key of your resource instances, such as default. |
RESOURCE_KEY | The key of the resource you manage access for. |
PERMIT_PDP_URL | The 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_KEY | Your environment API key. See Get your environment API key. |
PROJECT_ID | Your project ID. See Get the project ID or key. |
ENV_ID | Your environment ID. See Get the environment ID or key. |
ACCESS_ELEMENTS_CONFIG_ID | The config ID of your User Management element. |
OPERATION_ELEMENTS_CONFIG_ID | The 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
-
Install Claude Desktop.
-
Add the server to the Claude Desktop MCP configuration. Replace
/ABSOLUTE/PATH/TO/PARENT/FOLDERwith the absolute path of your clonedpermit-mcprepository:{"mcpServers": {"permit": {"command": "uv","args": ["--directory","/ABSOLUTE/PATH/TO/PARENT/FOLDER/src/permit_mcp","run","server.py"]}}} -
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:
- A custom MCP server that includes the Access Request MCP server tools and adds
list_dishesandorder_dish. - A FastAPI backend that runs the MCP server, passes its tools to Gemini, and serves a WebSocket chat endpoint.
- 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:
| Element | Role in the app |
|---|---|
| Access Request | Lets users request access to a restricted resource. |
| User Management | Sets which users can review access requests, based on their permission level, and lets them approve or deny requests. |
| Operation Approval | Lets users request approval for one operation on a resource. |
| Approval Management | Lets 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: cancreate,read,update, anddelete.child-can-view: can onlyread.
- 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
restaurantsresource:_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
-
In the Permit dashboard, go to Policy > Resources, and click Create a Resource.

-
Name the resource
restaurants, and add two ReBAC roles:parentandchild-can-view.
-
On the Policy Editor tab, give
restaurants#parentthecreate,read,update, anddeleteactions, and giverestaurants#child-can-viewonlyread.
Create the User Management element
-
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

-
Click Create.
-
On the element's tab, click Get Code. Copy the element config ID,
restaurant-requests. You use it asACCESS_ELEMENTS_CONFIG_ID.
Create the Operation Approval element
Create an Operation Approval element with these values:
- Name: Dish approval
- Resource Type: restaurants

Create the Approval Management element
-
Create an Approval Management element named "Dish requests".

-
Copy the element config ID,
dish-requests. You use it asOPERATION_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
-
Install the extra dependencies:
uv add 'fastapi[standard]' google-genai bcrypt 'python-jose[cryptography]' rich websockets -
Add these variables to your
.envfile. Get a Gemini API key from Google AI Studio.PERMIT_PDP_URL= # The local Permit PDP URL http://localhost:7766GEMINI_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.
-
Create
permit_client.pyin the root of the cloned repository. The file creates one Permit client that the other files import:import osfrom permit import Permitfrom dotenv import load_dotenv# Load environment variablesload_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,) -
Create
food_ordering_mcp.pyin 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, Tupleimport aiosqliteimport sqlite3from mcp.server.fastmcp import FastMCPfrom dotenv import load_dotenvimport osimport sysfrom src.permit_mcp.server import PermitServerfrom mcp.server.fastmcp.exceptions import ToolErrorfrom permit_client import permitload_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() -
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) -
Add the
list_dishestool. The tool checks whether the user canreadthe 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 restaurantpermitted = 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 dishesdishes_query = """SELECT name, price FROM dishesWHERE restaurant_id = ?"""cursor = await db.execute(dishes_query, (restaurant_id,))dishes = await cursor.fetchall()await cursor.close()return dishes -
Add the
order_dishtool. The tool:- Checks that the dish and the user exist.
- Checks that the user can
readthe restaurant. - Checks whether the user can
operateon 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 dollarsasync with aiosqlite.connect(DB_NAME) as db:# Get dish pricedish_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 roleuser_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 childrenif 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}!" -
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.pyas 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:
| File | What it holds |
|---|---|
utils.py | init_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.py | The 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:
- Reads the JWT from the
Authorizationheader and closes the connection with code1008when the token is missing, invalid, or belongs to a user without a role. - Starts
food_ordering_mcp.pyover stdio and opens an MCPClientSession.AsyncExitStackcloses both when the session ends. - 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. - 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 aresource_instance. - 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.
- Sends each message to the client as JSON with a
typeoftext,status,error, orhistory_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.
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.
| Function | What 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
-
Check that your PDP is running at the
PERMIT_PDP_URLyou set. A local PDP container answerscurl http://localhost:7766/healthwith HTTP200and"status": "ok". See Verify the PDP is healthy. -
Start the FastAPI backend:
fastapi dev server.pyOn the first run,
init_db()creates the database and the Permit facts, so the log includes these lines:Initializing databaseSetting 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, andENV_IDin.env, and check that therestaurantsresource exists with theparentandchild-can-viewroles. -
In another terminal, start the client:
uv run client.py -
Sign in with one of the users that
init_db()creates:Username Password Role 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.
-
As
henry, ask for the dishes of Fancy French. Thepermit.check()call inlist_dishesreturnsfalse, and the assistant reports the tool error:Access denied. You are not permitted to view dishes from this restaurant. -
Ask the assistant to request access to Fancy French, and give a reason when it asks for one. The
create_access_requesttool answersYour request has been successfully sent. -
Type
exit, sign in asjoe, and ask the assistant to list the pending access requests and approve Henry's. Theapprove_access_requesttool answersAccess request approved successfully. -
Sign in as
henryagain 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
-
As
henry, ask to order the Deluxe Burger from Burger Bonanza, which costs $12.99. The price check inorder_dishblocks 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. -
Ask the assistant to request approval for that order. The
create_operation_approvaltool answersOperation approval request created successfully. -
Sign in as
joeand approve the request. Permit assigns the_Approved_role tohenryon that restaurant. -
Sign in as
henryand order the Deluxe Burger again. The order succeeds withOrder successfully placed for Deluxe Burger!, andorder_dishunassigns 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)
}
})