Limit an AI agent to some repositories on a shared MCP server
A GitHub or Postgres MCP server reaches everything its one credential reaches. In Straza 2.0 a role covers some of the server's repositories or databases, and the gateway refuses a call on any other before the server sees it.
On this page7 sections
A GitHub App is installed on your organization, and an MCP server holds its key, so the server reaches every repository the App reaches. Until Straza 2.0, a Straza role granted tools, and a tool reached everything the server reached: an agent that held a role with pr_create could open a pull request in any of those repositories. A Postgres MCP server in front of several databases had the same shape, and a role with update_rows could update rows in all of them. For every agent allowed to call its write tools, such a server runs in god mode.
Straza 2.0 lets a role say which of the server’s objects it covers. A role on a GitHub server covers some repositories, or every one. A role on a Postgres server does the same with databases. The gateway checks the object each call names against the caller’s roles before any policy runs, and a call on an object that no role covers never reaches the server.
The pictures below come from Straza’s demo stack running 2.0.1, with ghapp, the GitHub App server in the Straza catalog, installed on a test organization.
What a role covers
ghapp-write-payments holds seven write tools of ghapp, git_push among them, on the two repositories of a payments team:
The role file, from Export YAML
apiVersion: straza.dev/v1beta1
kind: Role
metadata:
name: ghapp-write-payments
spec:
kind: application
description: Write tools of ghapp on the payments repos
server: ghapp
bindings:
- app: ghapp
tools:
- git_fetch
- git_push
- issue_comment
- issue_create
- pr_comment
- pr_create
- pr_merge
covers:
objects:
- ghapp-straza-test/pay-api
- ghapp-straza-test/pay-web
A second role, ghapp-read, holds the read tools with covers: every. An agent that holds both reads in every repository and writes in those two. every includes the repositories the App reaches later. A role that names repositories leaves a new one out until a person adds it. Straza stores each named repository with its id, so the role keeps covering a repository that is renamed, and does not cover one that was deleted and created again under the old name.
In the console, a new role on ghapp asks the same question on its Access step. Here a role for the web team gets one repository:
What the wizard stores, from How it is stored
bindings:
- app: ghapp
tools:
- issue_create
- pr_comment
- pr_create
covers:
objects:
- ghapp-straza-test/site
A tool that reaches every repository at once, such as ghapp’s repo_list, fits only in a role on every repository, because nothing would keep it to some. With These repos picked, the wizard greys it out and says so, and the check of a role file refuses it.
The refusal
alice holds ghapp-write-payments. Claude Code, working in alice’s session, fetched ghapp-straza-test/site with git, which goes through Straza in 2.0. Straza decided the fetch with the same roles and covers as an MCP call, and refused it:
The record as stored
{
"app": "ghapp",
"arguments": "{\"repo\":\"ghapp-straza-test/site\"}",
"effect": "deny",
"event": "grant",
"granted": false,
"harness": "claude-code",
"object": {
"id": "1425833478",
"reach": "argument",
"type": "repo",
"value": "ghapp-straza-test/site"
},
"reason": "Straza: git_fetch on ghapp-straza-test/site was refused: none of your roles on ghapp covers that repo. Your roles cover git_fetch on ghapp-straza-test/pay-api and ghapp-straza-test/pay-web. Check the repo's name, or ask an admin for a role that covers it.",
"rolesChecked": ["ghapp-write-payments"],
"tool": "mcp.call",
"toolName": "git_fetch"
}
The answer names only repositories the agent’s own roles cover, at most ten. A repository that no held role covers and one that is not in Straza’s list get the same answer, so an agent learns nothing about repositories it cannot use. Because the check runs before any policy, no policy rule can allow a call it refuses. The record carries the object’s type, name and id, and the roles Straza checked.
An MCP call on Postgres reads the same way. When the agent sam-sre-agent asked for rows of a database its roles leave out, the answer was Straza: read_rows on inventory was refused: none of your roles on postgres covers that database. Your roles cover read_rows on helpdesk, sales_eu and sales_us.
Where the objects come from
Straza does not guess which argument names the repository. The server says so, in two marks in its own tool list. A tool that works on one object carries ai.straza/object with the object type and the argument that names it. The listing tool carries ai.straza/lists-objects:
{ "name": "pr_create",
"_meta": { "ai.straza/object": { "type": "repo", "argument": "repo" } } }
{ "name": "repo_list",
"_meta": { "ai.straza/object": { "type": "repo", "reach": "all" },
"ai.straza/lists-objects": { "type": "repo" } } }
The marks change nothing by themselves. An admin pins them into the server’s manifest through a draft that a person publishes, and from then on Straza decides with that pinned copy, so a mark the server changes later waits for the next publish. Straza then lists the objects with a credential of its own, which the server accepts on the listing tool alone, and lists them again every 10 minutes. A role can name only objects that the listing returned, so a typo or a name in the wrong case is refused when the draft is checked, before anyone publishes it.
The pinned block in ghapp’s manifest
straza:
objects:
type: repo
list:
tool: repo_list
attributes:
- name: team
setBy: org
tools:
branch_list: { argument: repo }
file_get: { argument: repo }
git_fetch: { argument: repo }
git_push: { argument: repo }
issue_comment: { argument: repo }
issue_create: { argument: repo }
issue_get: { argument: repo }
issue_list: { argument: repo }
pr_comment: { argument: repo }
pr_create: { argument: repo }
pr_get: { argument: repo }
pr_list: { argument: repo }
pr_merge: { argument: repo }
repo_get: { argument: repo }
repo_list: all
search_code: { argument: repo }
search_issues: { argument: repo }
The scope is one object type per server, and each tool names its object in one argument. A role says which objects of that type it covers. It does not constrain the tool’s other arguments.
What your IGA sees
Each Straza role is a SCIM group that your IGA imports as an entitlement. In 2.0 the group also says what the role covers, one value per access row and named object:
{"displayName":"ghapp-read","covers":["ghapp:repo every"]}
{"displayName":"ghapp-write-payments","covers":[
"ghapp:repo \"ghapp-straza-test/pay-api\"",
"ghapp:repo \"ghapp-straza-test/pay-web\""]}
midPoint maps the values to the role’s strazaCovers item and shows them in a Covers column, so a certifier who reviews ghapp-write-payments sees the two repositories as well as the tools:
strazaCovers, read from midPoint’s REST interface
AR:ghapp-read ["ghapp:repo every"]
AR:ghapp-write-payments ["ghapp:repo \"ghapp-straza-test/pay-api\"",
"ghapp:repo \"ghapp-straza-test/pay-web\""]
The values are read-only over SCIM. What a role covers changes in Straza, through a draft, and the IGA decides who holds the role.
Who may widen a role
Every change to a role goes through a draft that a person publishes. A line that makes a role reach more objects is one the publisher confirms when publishing. An agent that holds straza-draft-config may write the role files and submit them through straza__draft_submit, but it cannot publish, and the check refuses an agent’s draft that widens what the agent’s own roles cover. A server’s own admin may write covers: every while the setting admin.serverAdminsCoverEvery is on, which is the default. Turn it off and only a global admin may.
What the server still promises
Straza checks the object a call names, and the server has to keep the call to that object. Every marked server makes three promises that Straza cannot check: a call touches only the object its argument names, that value names exactly one object at the time of the call, and the server accepts calls only from the gateway.
ghapp compares the name with GitHub’s own spelling byte for byte. When a role on some repositories admitted the call, Straza sends the repository’s id from its list in ai.straza/object-id, and ghapp refuses the call if the name now leads to another id. It then makes a GitHub token for that one repository with only the tool’s permissions. adaptive-pg-mcp compares the database id the same way, and each call still runs under the grants of that database’s login.
The check lives in the gateway. Straza’s harness hooks do not run it, so an agent that reached the server some other way would meet no object check. The third promise covers that case: the server takes calls from the gateway only.
Try it
Two servers in the catalog carry the marks: GitHub covers repositories and PostgreSQL covers databases. Each page has the manifest with the pinned block, the suggested roles on every object and an example role on some. Limit a role to some objects walks the whole setup on ghapp, from the pin to a refused call, and Object marks for server authors covers adding the marks to a server of your own.