Skip to content

Principals and roles

Section 8 · From docs/MCP_PRIVACY.md on GitHub

8.1 Where principals come from

A Principal has an id, a set of roles and free attributes. Policies key off the roles and, optionally, the id.

Transport Principal
stdio, in-memory Principal.local(): id local, roles {default, local}.
HTTP with --auth-token The principal configured for the presented bearer token, which always has the default role in addition to the ones listed. A request without a valid token gets HTTP 401.
HTTP without --auth-token anonymous, roles {default}. Keep the bind address on loopback in that case.
An mcp SDK server with OAuth id from the access token's client_id, roles {default} plus the token's scopes.
CLI cloudg mcp tools, resources, prompts, call and read with --role R --principal-id ID id ID (or cli), roles as given (or {default}); without either flag, the local user.

The token syntax is TOKEN[:ROLES[:ID]], repeatable, where ROLES is a comma-separated list. The fields are split from the right, so a token that itself contains a colon must use the full three-field form (abc:def:analyst: is token abc:def with role analyst; empty fields are allowed). TOKEN may be env:VAR to read the secret from the environment, which keeps it out of ps output and shell history. Without an id the principal is called token- plus the first 8 hex digits of the token's SHA-256. CLOUDG_MCP_AUTH_TOKENS holds the same specs separated by spaces. Tokens are compared as SHA-256 digests with hmac.compare_digest, every entry is compared on each request, and only digests stay in memory.

export ANALYST_TOKEN=... LEAD_TOKEN=...
cloudg mcp serve --transport http --policy soc-analyst \
  --auth-token env:ANALYST_TOKEN:analyst:ann \
  --auth-token env:LEAD_TOKEN:lead:lee

A custom principal_resolver (fn(RequestInfo) -> Principal) can map anything else, such as mTLS subjects or headers set by a gateway; see MCP_INTERNALS.md. Never derive roles from clientInfo, which the client chooses freely.

8.2 What a role can do

A role entry is a RuleConfig whose match.roles gets the role name prepended. Anything a rule can do, a role can do: grant and deny tools, categories and capabilities, set a ceiling, add transforms, override transform options, add rate limits and switch hint handling. A principal with several roles collects every matching role rule; an explicit deny from any of them wins, and grants combine.

8.3 Worked example: soc-analyst

The profile models a SOC team with three kinds of caller. These results come from the HTTP server started with the command above, against the sample estate.

Without a token:

HTTP 401
{"jsonrpc": "2.0", "id": null, "error": {"code": -32600, "message": "Unauthorized"}}

The analyst (ann) asks for the bastion host. Ids, names, the owner tag, the URI and the dataset are pseudonymised, the prod account shows as its alias, and the analyst sees 60 tools: reveal_token and the other restricted tools (get_asset_metadata, privacy_audit_log, sparql_query, subgraph_export), everything in the live and export categories and export_ontology are absent.

{
  "id": "res-d635cdb27d",
  "name": "res-d635cdb27d",
  "account_id": "prod-payments",
  "arn": "arn:aws:ec2:us-east-1:prod-payments:instance/res-f02c8397f9",
  "uri": "cloudg://assets/res-d635cdb27d",
  "tags": {"env": "prod", "owner": "person-f0df8adf2d"},
  "dataset": "ds-a6c8d812e0"
}

Everything the analyst sees can go back into a tool. find_assets {"account_id": "prod-payments"} returns the account's 31 assets (the alias is reversed to 111111111111), get_asset with the aliased ARN finds the asset, and so does a read of cloudg://assets/res-d635cdb27d. Completions are pseudonymised too: completing cloudg://assets/{+ref} from bas offered res-d635cdb27d and res-5b48a97700, and reading the first gave the bastion host.

The lead (lee) gets the same asset with real values ("name": "bastion", "owner": "platform", the real instance id inside the ARN, but the same account alias), sees 70 tools including reveal_token, privacy_audit_log, get_asset_metadata and the export tools, and can reveal what the analyst saw. For web-1:

reveal_token argument (as the analyst saw it) value returned
res-3c1f1e6b50 web-1 (with "entity_type": "resource_name")
arn:aws:ec2:us-east-1:prod-payments:instance/res-f7ce1c6611 arn:aws:ec2:us-east-1:111111111111:instance/i-0web1 ("replaced": 2)
prod-payments 111111111111 ("replaced": 1)
check res-3c1f1e6b50 owned by person-6bbd789f86 check web-1 owned by alice@example.com ("replaced": 2)

Aliases are reversed together with pseudonyms, and the result is not aliased again on the way out. The full response for the first row:

{"pseudonym": "res-3c1f1e6b50", "found": true, "value": "web-1", "entity_type": "resource_name"}

The analyst calling reveal_token gets Unknown tool: reveal_token, and the attempt is audited as not_found. A principal with the collector role sees 69 tools: the live tools are in category live, which its allow_categories grants, and its allow_capabilities lifts the capability denials. The policy's confidential ceiling still hides the five restricted tools from it. Nobody else can collect.

Each lead reveal produces two warnings on cloudg.mcp.audit, and with audit: true an INFO line as well:

WARNING REVEAL principal=lee roles=['lead'] tool=reveal_token args={'token': '08c645bf422c'} policy=soc-analyst
INFO    allowed tool reveal_token principal=lee reveal
WARNING reveal_token principal=lee entity=resource_name token_hash=8d26addee9fc

When the argument contains aliases, the second warning reads reveal_token principal=lee reversed=2 token_hash=... instead.