Authorization for MCP Servers
Model Context Protocol (MCP) servers expose tools that AI agents can call. Without authorization, any authenticated user can invoke any tool. OpenFGA lets you control which tools each user can access, based on their role, group membership, or time-limited grants.
This guide shows how to model tool-level authorization for an MCP server using OpenFGA, covering public tools, role-based access, group membership, temporal access, and resource-level permissions within tools. It explains how to control an agent's interactions with an MCP server based on the user's permissions.
Authorization model
The model defines four types: users, groups, roles, and tools. Users belong to groups, groups are assigned to roles, and roles grant access to tools. Tools can also be made public or granted directly to a user.
model
schema 1.1
type user
type group
relations
define member: [user]
type role
relations
define assignee: [user, group#member]
type tool
relations
define can_call: [user:*, user, role#assignee, user with temporal_grant, role#assignee with temporal_grant]
condition temporal_grant(grant_time: timestamp, grant_duration: duration, current_time: timestamp) {
current_time < grant_time + grant_duration
}
The can_call relation on tool accepts several types of assignments:
user:*— any authenticated user can call the tool (public tools).user— a specific user can call the tool.role#assignee— anyone assigned to the role can call the tool, either directly or through group membership.user with temporal_grantorrole#assignee with temporal_grant— access is granted for a limited duration.
Setting up groups and roles
Link users to groups, and groups to roles:
tuples:
# anne is a member of the managers group
- user: user:anne
relation: member
object: group:managers
# beth is a member of the marketing group
- user: user:beth
relation: member
object: group:marketing
# the managers group is assigned the admin role
- user: group:managers#member
relation: assignee
object: role:admin
# the marketing group is assigned the content_editor role
- user: group:marketing#member
relation: assignee
object: role:content_editor
Granting tool access
With groups and roles in place, grant tool access through role assignments and public access:
tuples:
# get_datetime is public — any authenticated user can call it
- user: user:*
relation: can_call
object: tool:get_datetime
# the admin role can call all tools
- user: role:admin#assignee
relation: can_call
object: tool:greet
- user: role:admin#assignee
relation: can_call
object: tool:whoami
- user: role:admin#assignee
relation: can_call
object: tool:get_documents
# the content_editor role can call greet, whoami, and get_documents
- user: role:content_editor#assignee
relation: can_call
object: tool:greet
- user: role:content_editor#assignee
relation: can_call
object: tool:whoami
- user: role:content_editor#assignee
relation: can_call
object: tool:get_documents
With this setup:
- anne (managers → admin) can call
greet,whoami,get_documents, andget_datetime. - beth (marketing → content_editor) can call
greet,whoami,get_documents, andget_datetime. - carl (no group) can only call
get_datetime.
Filtering the tool list
When a user connects to the MCP server, use ListObjects to retrieve all tools they are authorized to call. The MCP server then exposes only those tools.
For example, listing all tools user:carl can call:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
const response = await fgaClient.listObjects({
user: "user:carl",
relation: "can_call",
type: "tool",
}, {
authorizationModelId: "01HVMMBCMGZNT3SED4Z17ECXCA",
});
// response.objects = ["tool:get_datetime"]
options := ClientListObjectsOptions{
AuthorizationModelId: PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientListObjectsRequest{
User: "user:carl",
Relation: "can_call",
Type: "tool",
}
data, err := fgaClient.ListObjects(context.Background()).
Body(body).
Options(options).
Execute()
// data = { "objects": ["tool:get_datetime"] }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA",
};
var body = new ClientListObjectsRequest {
User = "user:carl",
Relation = "can_call",
Type = "tool",
};
var response = await fgaClient.ListObjects(body, options);
// response.Objects = ["tool:get_datetime"]
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA"
}
body = ClientListObjectsRequest(
user="user:carl",
relation="can_call",
type="tool",
)
response = await fga_client.list_objects(body, options)
# response.objects = ["tool:get_datetime"]
var options = new ClientListObjectsOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientListObjectsRequest()
.user("user:carl")
.relation("can_call")
.type("tool");
var response = fgaClient.listObjects(body, options).get();
// response.getObjects() = ["tool:get_datetime"]
fga query list-objects --store-id=${FGA_STORE_ID} --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:carl can_call tool
# Response: {"objects": ["tool:get_datetime"]}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/list-objects \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
"type": "tool",
"relation": "can_call",
"user":"user:carl"
}'
# Response: {"objects": ["tool:get_datetime"]}
listObjects(
"user:carl", // list the objects that the user `user:carl`
"can_call", // has an `can_call` relation
"tool", // and that are of type `tool`
authorization_model_id = "01HVMMBCMGZNT3SED4Z17ECXCA", // for this particular authorization model id
);
Reply: ["tool:get_datetime"]
The MCP server only exposes get_datetime to carl. The other tools are hidden from the tool list entirely.
For user:anne (managers → admin), the result includes all tools:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
const response = await fgaClient.listObjects({
user: "user:anne",
relation: "can_call",
type: "tool",
}, {
authorizationModelId: "01HVMMBCMGZNT3SED4Z17ECXCA",
});
// response.objects = ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]
options := ClientListObjectsOptions{
AuthorizationModelId: PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientListObjectsRequest{
User: "user:anne",
Relation: "can_call",
Type: "tool",
}
data, err := fgaClient.ListObjects(context.Background()).
Body(body).
Options(options).
Execute()
// data = { "objects": ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"] }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA",
};
var body = new ClientListObjectsRequest {
User = "user:anne",
Relation = "can_call",
Type = "tool",
};
var response = await fgaClient.ListObjects(body, options);
// response.Objects = ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA"
}
body = ClientListObjectsRequest(
user="user:anne",
relation="can_call",
type="tool",
)
response = await fga_client.list_objects(body, options)
# response.objects = ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]
var options = new ClientListObjectsOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientListObjectsRequest()
.user("user:anne")
.relation("can_call")
.type("tool");
var response = fgaClient.listObjects(body, options).get();
// response.getObjects() = ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]
fga query list-objects --store-id=${FGA_STORE_ID} --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:anne can_call tool
# Response: {"objects": ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/list-objects \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
"type": "tool",
"relation": "can_call",
"user":"user:anne"
}'
# Response: {"objects": ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]}
listObjects(
"user:anne", // list the objects that the user `user:anne`
"can_call", // has an `can_call` relation
"tool", // and that are of type `tool`
authorization_model_id = "01HVMMBCMGZNT3SED4Z17ECXCA", // for this particular authorization model id
);
Reply: ["tool:get_datetime", "tool:greet", "tool:whoami", "tool:get_documents"]
Temporal access
You can grant time-limited access to a tool using the temporal_grant condition. This is useful when a user needs temporary access to a tool for a specific task.
tuples:
# carl can call greet for 1 hour
- user: user:carl
relation: can_call
object: tool:greet
condition:
name: temporal_grant
context:
grant_time: "2026-04-03T10:00:00Z"
grant_duration: 1h
When checking access, pass the current time in the request context. The check returns true only if the current time is within the grant window:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'user:carl',
relation: 'can_call',
object: 'tool:greet',
context: {"current_time":"2026-04-03T10:30:00Z"}
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = true
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "user:carl",
Relation: "can_call",
Object: "tool:greet",
Context: &map[string]interface{}{"current_time":"2026-04-03T10:30:00Z"},
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: true }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "user:carl",
Relation = "can_call",
Object = "tool:greet",
Context = new { current_time="2026-04-03T10:30:00Z" }
};
var response = await fgaClient.Check(body, options);
// response.Allowed = true
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="user:carl",
relation="can_call",
object="tool:greet",
context=dict(
current_time="2026-04-03T10:30:00Z"
)
)
response = await fga_client.check(body, options)
# response.allowed = true
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("user:carl")
.relation("can_call")
._object("tool:greet")
.context(Map.of("current_time", "2026-04-03T10:30:00Z"));
var response = fgaClient.check(body, options).get();
// response.getAllowed() = true
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:carl can_call tool:greet --context='{"current_time":"2026-04-03T10:30:00Z"}'
# Response: {"allowed":true}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
"tuple_key": {
"user": "user:carl",
"relation": "can_call",
"object": "tool:greet"
},
"context": {"current_time":"2026-04-03T10:30:00Z"}
}'
# Response: {"allowed": true}
check(
user = "user:carl", // check if the user `user:carl`
relation = "can_call", // has an `can_call` relation
object = "tool:greet", // with the object `tool:greet`
context = { current_time = "2026-04-03T10:30:00Z" }, authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: true
is user:carl related to tool:greet as can_call?
# Note: Check context is not supported on the playground
# Response: A green path from the user to the object indicating that the response from the API is `{"allowed":true}`
After the grant expires, the same check returns false:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'user:carl',
relation: 'can_call',
object: 'tool:greet',
context: {"current_time":"2026-04-03T11:30:00Z"}
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = false
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "user:carl",
Relation: "can_call",
Object: "tool:greet",
Context: &map[string]interface{}{"current_time":"2026-04-03T11:30:00Z"},
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: false }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "user:carl",
Relation = "can_call",
Object = "tool:greet",
Context = new { current_time="2026-04-03T11:30:00Z" }
};
var response = await fgaClient.Check(body, options);
// response.Allowed = false
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="user:carl",
relation="can_call",
object="tool:greet",
context=dict(
current_time="2026-04-03T11:30:00Z"
)
)
response = await fga_client.check(body, options)
# response.allowed = false
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("user:carl")
.relation("can_call")
._object("tool:greet")
.context(Map.of("current_time", "2026-04-03T11:30:00Z"));
var response = fgaClient.check(body, options).get();
// response.getAllowed() = false
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:carl can_call tool:greet --context='{"current_time":"2026-04-03T11:30:00Z"}'
# Response: {"allowed":false}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
"tuple_key": {
"user": "user:carl",
"relation": "can_call",
"object": "tool:greet"
},
"context": {"current_time":"2026-04-03T11:30:00Z"}
}'
# Response: {"allowed": false}
check(
user = "user:carl", // check if the user `user:carl`
relation = "can_call", // has an `can_call` relation
object = "tool:greet", // with the object `tool:greet`
context = { current_time = "2026-04-03T11:30:00Z" }, authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: false
is user:carl related to tool:greet as can_call?
# Note: Check context is not supported on the playground
# Response: A red object indicating that the response from the API is `{"allowed":false}`
Resource-level permissions within tools
Some tools return different results depending on user permissions. For example, a get_documents tool might return public documents to all users but restrict private documents to specific roles. You can model this by adding a separate relation to the tool:
model
schema 1.1
type user
type group
relations
define member: [user]
type role
relations
define assignee: [user, group#member]
type tool
relations
define can_call: [user:*, user, role#assignee, user with temporal_grant, role#assignee with temporal_grant]
define can_view_private_documents: [role#assignee]
condition temporal_grant(grant_time: timestamp, grant_duration: duration, current_time: timestamp) {
current_time < grant_time + grant_duration
}
Grant the can_view_private_documents relation to the admin role:
tuples:
- user: role:admin#assignee
relation: can_view_private_documents
object: tool:get_documents
When the tool executes, check whether the user has the can_view_private_documents relation and adjust the response accordingly:
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'user:anne',
relation: 'can_view_private_documents',
object: 'tool:get_documents',
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = true
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "user:anne",
Relation: "can_view_private_documents",
Object: "tool:get_documents",
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: true }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "user:anne",
Relation = "can_view_private_documents",
Object = "tool:get_documents",
};
var response = await fgaClient.Check(body, options);
// response.Allowed = true
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="user:anne",
relation="can_view_private_documents",
object="tool:get_documents",
)
response = await fga_client.check(body, options)
# response.allowed = true
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("user:anne")
.relation("can_view_private_documents")
._object("tool:get_documents");
var response = fgaClient.check(body, options).get();
// response.getAllowed() = true
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:anne can_view_private_documents tool:get_documents
# Response: {"allowed":true}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
"tuple_key": {
"user": "user:anne",
"relation": "can_view_private_documents",
"object": "tool:get_documents"
}
}'
# Response: {"allowed": true}
check(
user = "user:anne", // check if the user `user:anne`
relation = "can_view_private_documents", // has an `can_view_private_documents` relation
object = "tool:get_documents", // with the object `tool:get_documents`
authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: true
is user:anne related to tool:get_documents as can_view_private_documents?
# Response: A green path from the user to the object indicating that the response from the API is `{"allowed":true}`
- Node.js
- Go
- .NET
- Python
- Java
- CLI
- curl
- Pseudocode
- Playground
// Run a check
const { allowed } = await fgaClient.check({
user: 'user:beth',
relation: 'can_view_private_documents',
object: 'tool:get_documents',
}, {
authorizationModelId: '01HVMMBCMGZNT3SED4Z17ECXCA',
});
// allowed = false
options := ClientCheckOptions{
AuthorizationModelId: openfga.PtrString("01HVMMBCMGZNT3SED4Z17ECXCA"),
}
body := ClientCheckRequest{
User: "user:beth",
Relation: "can_view_private_documents",
Object: "tool:get_documents",
}
data, err := fgaClient.Check(context.Background()).
Body(body).
Options(options).
Execute()
// data = { allowed: false }
var options = new ClientCheckOptions {
AuthorizationModelId = "01HVMMBCMGZNT3SED4Z17ECXCA"
};
var body = new ClientCheckRequest {
User = "user:beth",
Relation = "can_view_private_documents",
Object = "tool:get_documents",
};
var response = await fgaClient.Check(body, options);
// response.Allowed = false
options = {
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
}
body = ClientCheckRequest(
user="user:beth",
relation="can_view_private_documents",
object="tool:get_documents",
)
response = await fga_client.check(body, options)
# response.allowed = false
var options = new ClientCheckOptions()
.authorizationModelId("01HVMMBCMGZNT3SED4Z17ECXCA");
var body = new ClientCheckRequest()
.user("user:beth")
.relation("can_view_private_documents")
._object("tool:get_documents");
var response = fgaClient.check(body, options).get();
// response.getAllowed() = false
fga query check --store-id=$FGA_STORE_ID --model-id=01HVMMBCMGZNT3SED4Z17ECXCA user:beth can_view_private_documents tool:get_documents
# Response: {"allowed":false}
curl -X POST $FGA_API_URL/stores/$FGA_STORE_ID/check \
-H "Authorization: Bearer $FGA_API_TOKEN" \ # Not needed if service does not require authorization
-H "content-type: application/json" \
-d '{
"authorization_model_id": "01HVMMBCMGZNT3SED4Z17ECXCA",
"tuple_key": {
"user": "user:beth",
"relation": "can_view_private_documents",
"object": "tool:get_documents"
}
}'
# Response: {"allowed": false}
check(
user = "user:beth", // check if the user `user:beth`
relation = "can_view_private_documents", // has an `can_view_private_documents` relation
object = "tool:get_documents", // with the object `tool:get_documents`
authorization_id = "01HVMMBCMGZNT3SED4Z17ECXCA"
);
Reply: false
is user:beth related to tool:get_documents as can_view_private_documents?
# Response: A red object indicating that the response from the API is `{"allowed":false}`
Since anne is an admin, she sees all documents. Beth, as a content editor, sees only public documents.
If you have many tools with resource-level permissions, consider creating separate types for each tool's resources instead of adding relations to the tool type. See Domain-specific models for an example.
Integration pattern
The authorization flow for an MCP server follows this pattern:
- Authentication: The user authenticates with an identity provider (e.g., via OAuth 2.0). The MCP server verifies the token and extracts the user ID.
- Tool filtering: On each request, call
ListObjectsto retrieve all tools the user is authorized to call. Only expose those tools. - Tool execution: When a tool is invoked, verify the user still has access. For tools with resource-level permissions, perform additional checks to determine what data to return.
- Dynamic grants: Use temporal access or direct user grants to provide just-in-time access to tools as needed.
Sample implementation
For a complete working example of an MCP server with OpenFGA authorization, see the FastMCP + OpenFGA sample. The sample includes:
- A fully configured authorization model and tuples
- OAuth 2.0 authentication with token verification
ListObjectsfor tool filtering at connection timeCheckfor resource-level permissions at execution time- Shell scripts for managing group membership and temporal access