FastAPI requesting-user identity
Install the SDK in the application's environment. Caller verification dependencies are included in the standard installation:
pip install mainsequence
Install request identity once when creating the application:
from fastapi import FastAPI
from mainsequence.client import User
from mainsequence.server.fastapi import install_request_identity
app = FastAPI()
install_request_identity(app)
@app.get("/me")
def me():
user = User.get_logged_user()
return {"uid": user.uid}
User.get_logged_user() is the single application caller lookup. The integration verifies the caller before the handler, binds one isolated request scope, and resets it on completion, exceptions, or cancellation. Both synchronous and asynchronous handlers and their service functions use the same getter. It performs no extra lookup each time.
The result contains a canonical User uid and optional username. Signed HTTP proofs contain no username, so it is null. Outside an authenticated request, including public routes, the getter raises RequestIdentityError. A copied task context cannot retain the user after its owning request ends.
For a signed HTTP request the result also carries is_organization_admin, whether the platform signed the caller as an admin of the application's Organization. An assertion without it is rejected with 401. Local development and WebSocket requests carry no such facts, so it is false.
Team membership
The application owns its team policies. It asks the platform which of the teams a policy names the caller belongs to with in_teams(), and decides how often to ask. Workload callers are covered through the teams they belong to.
from mainsequence.client import User
def can_publish(team_uids: list[str]) -> bool:
caller = User.get_logged_user()
return caller.is_organization_admin or bool(caller.in_teams(team_uids))
in_teams(team_uids, *, max_age_seconds=None) returns the subset of team_uids the person is an active member of, as a frozenset of canonical UIDs. Teams the platform does not count, such as unknown or inactive ones, are left out. It works on User.get_logged_user() and User.get_requester() inside a signed HTTP request, and asks only about that request's caller or requester. Longer lists are sent in calls of at most 100 teams. In local mode and on WebSockets it raises RequestIdentityError; when the platform cannot answer it raises the SDK's API error, never "not a member". When no answer can be reused it calls the platform, and the call blocks: call it from a def handler, or from an async def handler with await asyncio.to_thread(caller.in_teams, team_uids), which keeps the request's identity.
How long an answer may be reused is the application's choice:
- Within one request, the same question is asked once.
- Across requests, an answer is reused while it is younger than
team_membership_cache_seconds, set withinstall_request_identity(app, team_membership_cache_seconds=60), or thanmax_age_secondswhen a call gives one.0, the default, reuses nothing across requests. max_age_seconds=0is a fresh check: it always asks the platform, skips both kinds of reuse and stores the new answer. Ask for one before acting on a membership that must be current, such as before a write.
A reused answer can outlive a membership change by up to the lifetime chosen. Answers are kept per process, at most 10,000, and are keyed by the release and Environment the SDK verified, so an answer for one never answers another.
RequestUserIdentity has no team_uids: the platform no longer signs a team list, and an assertion that carries one is refused. in_teams() is the only membership check. See ADR-0036.
The caller is a person or a workload identity, the User a deployed Job, FastAPI release or Agent runs as. To tell which, read the caller's User with User.get_by_uid(caller.uid): a workload identity has identity_type "workload" and the UID of its workload in job_uid, resource_release_uid or agent_uid. See Workload identities.
Reading the directory as the caller
The application calls the platform as its own workload identity, which usually has no teams and no grants. To show its caller the people, Teams and workloads that caller can see, for example to give one of them access to one of the application's objects, read the directory as the caller:
from mainsequence.client import User
from mainsequence.server.fastapi import reads_as_caller
@app.get("/access-candidates")
def access_candidates(q: str):
with reads_as_caller():
people = User.filter(search=q)
workloads = User.filter(identity_type="workload", search=q)
return {
"people": [person.uid for person in people],
"workloads": [
{
"uid": workload.uid,
"name": workload.workload_name,
"managed": workload.managed_by_caller,
}
for workload in workloads
],
}
Inside the block, User.filter, User.get_by_uid, Team.filter and Team.get_by_uid present the caller assertion the request arrived with, and the platform answers with what the caller may see. search matches a person's email or name, or a workload's Job, release or Agent name. A workload row carries managed_by_caller, true when the caller manages that workload. No other call presents the assertion, and nothing presents it outside the block.
The block needs a signed HTTP request. Outside an authenticated request, in local mode and on WebSockets it raises RequestIdentityError; it never reads as the application instead. In local mode the SDK session already belongs to the developer, so reads outside the block answer as that person. The assertion lives at most five minutes and is not renewed, so a read after it expires fails. See ADR-0036.
Requester-bound calls
Another application, for example an Agent, can call this application while it works for a person: the requester. The platform then signs the requester into the caller assertion next to the caller. User.get_logged_user() still returns the caller, the acting application. User.get_requester() returns the requester, the checked delegation, or None when the request carries no delegation.
The requester is a RequestUserIdentity with the person's own uid and is_organization_admin, as the platform signed them; in_teams() answers for the person's teams. A requester-bound call carries the person's ordinary permissions, including administrative ones. The SDK verifies the delegation before any handler runs: an invalid one rejects the request with 401 and never falls back to the caller. get_requester() returns None in local mode, on WebSockets and when the assertion names no requester. Outside an authenticated request, including public routes, it raises RequestIdentityError, as get_logged_user() does.
Each handler decides whether it needs a person. One that needs a person fails with its own error when there is none:
from fastapi import HTTPException
from mainsequence.client import User
def team_report(team_uid: str) -> dict:
requester = User.get_requester()
if requester is None:
# This operation answers for a person: without a requester, refuse.
raise HTTPException(status_code=403, detail="This operation needs a requester.")
if not (requester.is_organization_admin or requester.in_teams([team_uid])):
raise HTTPException(status_code=403, detail="The requester cannot read this report.")
return {"team_uid": team_uid, "requested_by": requester.uid}
One where the person is optional authorizes against the person when present, and against the caller otherwise:
def can_publish(team_uid: str) -> bool:
actor = User.get_requester() or User.get_logged_user()
return actor.is_organization_admin or bool(actor.in_teams([team_uid]))
- Authorize against one identity: the person on a requester-bound call, the caller on any other. Never combine the caller's grants with the person's, and never fall back to what the acting application may do when the person may not.
- For a write, check that identity's permission for the exact object and action before mutating anything. A read grant or team membership alone does not establish permission to edit, run, delete or give access. The SDK verifies identity; the application owns these checks.
- Never parse the assertion, headers or MCP
_metayourself, and never trust a person's UID taken from a request body, header or query parameter. Only the signed assertion names the requester, and the SDK has verified that it is addressed to this release. - Inside a requester-bound request,
reads_as_caller()raisesRequestIdentityError: the receiving application cannot forward a requester-bearing assertion to the platform's directory endpoints. Platform-authorized Agent delegation is a separate platform-owned path; this helper cannot create a binding or forward write authority.
The platform checks access on every call and limits requester-bound work to at most 24 hours after the person's request, including across Agent delegation. A model-driven Agent can be steered by prompt injection, so write tools need explicit operation-specific checks. The SDK does not select the person, enable platform endpoints or extend the lifetime of an inbound request scope.
SDK releases up to 9.0.17 reject the requester claim the platform now signs, with its is_organization_admin, so requester-bound calls to an application on such a release fail with 401 while its other calls are unchanged. This release rejects the earlier two-field requester and any assertion without the caller's is_organization_admin; it also rejects a caller or requester that lists team_uids, which the platform no longer signs. Which applications may act for their requester, and what that access covers, is described in Applications that act for their requester. See ADR-0036.
Caller context
A caller can send its own context with a request, in the W3C baggage header:
which of its own users the call is for, and its own information about the
call. The caller assertion says who is calling; caller context says who the
call is for. Caller context describes the keys,
the trust rule and the limits.
install_request_identity makes each request's baggage and traceparent
the OpenTelemetry context of that request. Read it with OpenTelemetry's API,
after checking the caller:
from fastapi import HTTPException
from opentelemetry import baggage
from mainsequence.client import User
# Services trusted to vouch for their users: their workload User UIDs.
TRUSTED_CALLERS = {"<workload User UID of advisor-backend>"}
def advisor_person() -> str | None:
"""The person this call is for, or None."""
if User.get_logged_user().uid not in TRUSTED_CALLERS:
return None # context from any other caller is not believed
return baggage.get_baggage("mainsequence.advisor.person")
@app.get("/portfolio")
def portfolio():
person = advisor_person()
if person is None:
raise HTTPException(status_code=403, detail="This operation needs an Advisor person.")
...
- The request's baggage is exactly its own
baggageheader, never the process's or another request's. It holds every member the header carries, with W3C properties dropped.baggage.get_all()returns them all. - Only a request with a verified caller has baggage. A public-ingress or preflight request has none.
- A request without context is served as usual, and so is one with malformed or oversized baggage: what cannot be read is left out. Missing context means no external user: fail closed, and never fall back to broader access.
- The context lasts for the whole request, in
defandasync defhandlers and in the tools served byinstall_mcp. Tasks the request starts, and threads that receive a copy of its context, such asasyncio.to_thread, have it too. - Calls the SDK makes while handling the request, to Main Sequence and to Agents, pass the context on: a Job run or Agent Task the request starts receives it. A header you set yourself wins.
- Log lines written through the SDK's logger carry its
mainsequence.keys asbaggage. - Caller context is not a requester: it names no Main Sequence User, grants
nothing and does not change
User.get_requester().
The SDK gives no key a meaning and adds no accessor of its own. Locally, send the header yourself with the request, as the platform's gateway would:
curl -H "Authorization: Bearer $MAINSEQUENCE_ACCESS_TOKEN" \
-H "baggage: mainsequence.advisor.person=p-7f3a9c" \
http://localhost:8000/portfolio
Serving MCP
An application can serve its own MCP server at /mcp, next to its HTTP API. Install the mcp extra, pip install "mainsequence[mcp]", and pass the server's ASGI app and lifespan to install_mcp after install_request_identity:
from fastapi import FastAPI
from mcp.server.mcpserver import MCPServer
from mcp.server.transport_security import TransportSecuritySettings
from mainsequence.client import User
from mainsequence.server.fastapi import install_mcp, install_request_identity
mcp = MCPServer("prices")
@mcp.tool()
async def latest_price(symbol: str) -> float:
caller = User.get_logged_user()
... # check what this caller may read, then answer
app = FastAPI()
install_request_identity(app)
install_mcp(
app,
mcp.streamable_http_app(
stateless_http=True,
json_response=True,
transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False),
),
lifespan=mcp.session_manager.run,
)
Tools read the caller with User.get_logged_user() and, on a requester-bound call, the person it works for with User.get_requester(), as REST handlers do. Admission to the release does not authorize every tool: each tool checks what its caller may do.
Each tool decides whether it needs a person, as in
Requester-bound calls. A tool that needs one fails
with its own error when User.get_requester() is None. A tool where the
person is optional authorizes against User.get_requester() when present, and
against User.get_logged_user() otherwise. A write tool checks that identity's
permission for the target object and action before calling the mutating
service; on a requester-bound call it can neither use the calling Agent's
grants instead of the person's nor add them to the person's. Tools never read
the assertion, headers or MCP _meta to find a person. MCP tool annotations, a
successful connection and permission to read the same object do not authorize
writes. Keep any required mutation approval in the application's policy, not in
a model's claim that the user approved it.
The integration:
- serves exactly
/mcpinside the request identity: only authenticated POSTs reach the server, and a request without a valid caller answers 401 as on any route; - keeps each call's identity in the tasks its tool starts and in the worker threads it uses through
asyncio.to_threador anyio'sto_thread.run_sync, and ends it with the call, by completion, failure or cancellation; a thread that receives no copy of the context, such as one fromloop.run_in_executor, has no identity; - serves statelessly: other methods answer 405, and an
Mcp-Session-Idheader is removed, so no request reuses another request's server state; - answers 403 to a request whose
Originis neither the release's public origin nor one of its CORS origins; clients that send noOrigin, such as Codex, are unaffected; - starts the MCP lifespan once, after the application's, and stops it first; a failed MCP start unwinds the application's and fails startup;
- refuses a second installation, an existing route under
/mcpand a public-ingress entry for/mcp.
Configure the HTTP app as above: stateless, JSON responses, and the MCP SDK's DNS-rebinding protection off, because its default admits only localhost hosts while the integration checks Origin and the platform routes the host. Keep its default path, /mcp.
The mcp extra installs the official MCP Python SDK 2.x, which serves protocol 2026-07-28 and the earlier revisions from the same server. An application written for 1.x's FastMCP constructs MCPServer instead and passes the settings above to streamable_http_app().
The release advertises its endpoint once its workflow declares spec.mcp_enabled: true (workflow API 3.0.0) and a deployment with it succeeds. Clients connect to that URL and sign in with the platform; the application receives the caller assertion, never the client's token. ResourceRelease.mcp_connection gives the URL, and ResourceRelease.filter(mcp_available=True) lists releases that advertise one. See ADR 0038.
Skills
The MCP server can also publish Agent Skills: workflows across several tools, checklists and reference files that travel with the service that owns them. The SDK serves them through the MCP Skills extension, io.modelcontextprotocol/skills, exactly as its specification defines it.
Skills are published only from the folder you set with skills_extension, and setting it is required: without it, the server exposes no skills and declares no Skills extension, whatever the repository holds. The folder's name and place are yours to choose; the SDK neither requires nor suggests one, and the examples here use .agents/skills/mcp_only. Only that folder is published; the coding agents' other skill folders are not.
In that folder, a folder with a SKILL.md is a skill, and a folder without one groups skills. A group's path prefixes the URIs of the skills in it, as the specification's organizational prefix:
.agents/skills/mcp_only/ # the folder set below (an example)
billing/ # a group: no SKILL.md
refund-review/
SKILL.md # skill://billing/refund-review/SKILL.md
references/policy.md
chargebacks/
SKILL.md # skill://billing/chargebacks/SKILL.md
daily-close/
SKILL.md # skill://daily-close/SKILL.md
Each SKILL.md begins with YAML frontmatter whose name equals its directory's name:
---
name: refund-review
description: Review a refund request against the refund policy. Use when a customer asks for money back.
---
# Refund review
Read `references/policy.md`, then call the `refund_status` tool.
Set the folder when constructing the server:
from mainsequence.server.mcp_skills import skills_extension
mcp = MCPServer("refunds", extensions=[skills_extension(".agents/skills/mcp_only")])
A relative path is repository-relative: skills_extension looks it up from the directory of the module that calls it upwards, so the same path works from any module of the repository and whatever the working directory. An absolute path is used as given. Point at the folder that holds the skills, never at .agents/skills itself.
The list of skills is the folder: every skill in it is published, and adding or removing a skill folder changes the list at the next deployment. A client that supports the extension, connected to the release's /mcp, finds it in server/discover, lists the skills with skills/list and reads them with skills/get and resources/read. Each entry carries the skill's frontmatter and a manifest of its files with their SHA-256 digests and sizes, which the client checks against what it reads.
skills_extension:
- reads every file once, when it runs, and serves exactly those bytes; a changed skill takes effect with the next deployment;
- skips names beginning with
.inside the folder, and ignores files beside skills, such as a README in the folder or in a group; - fails startup with a
ValueErrornaming the file and the rule when the folder is missing or holds no skill, a group holds no skill, a skill or group folder's name is not 1 to 64 characters ofa-z,0-9and single hyphens, or a skill breaks the Agent Skills format: frontmatter that is missing or unclosed, anamethat differs from its folder, adescriptionoutside 1 to 1024 characters, acompatibilityover 500, ormetadatathat does not map strings to strings; - fails startup as well for a skill over 512 files or 16 MiB, a symbolic link, a skill inside another skill, or a file path with characters other than
A-Z a-z 0-9 . _ ~ -; - refuses frontmatter values that YAML 1.1 and YAML 1.2 parsers read differently, such as an unquoted
yes, a date or0755, because a client compares the frontmatter it parses with the entry: quote them.
Skills are served only behind the same authenticated /mcp as tools. Every caller admitted to the release sees every skill: a skill is instructions, and the tools it mentions still check their caller. The server's skill:// resources belong to the extension, so do not register your own under skill://. Clients on protocol 2025-11-25 can call the methods too, but that revision has no field in which the server can declare the extension. See ADR 0039.
Platform and SDK responsibilities
Django authenticates and signs. The gateway forwards the proof. The application's SDK integration verifies it using deployment-owned trust configuration. PodDeploymentOrchestrator validates that the integration is installed and serves the app without importing or depending on the SDK.
The existing request.state.user and request.state.user_uid fields are compatibility projections of the same identity. New code uses the getter. There is one resolver and no UID-header fallback for hosted HTTP.
User.get_authenticated_user_details() identifies the account associated with the process's SDK authentication. Its workload credential is unchanged by incoming callers and is never a fallback caller.
Local development
Without hosted deployment markers, the integration uses a direct incoming Bearer token and validates it against MAINSEQUENCE_ENDPOINT/api/v1/users/me/. The handler still calls the same getter. Local mode cannot override a deployed target or replace a failed assertion. Missing local identity returns 401; an unavailable authenticator returns 503.
By default every request asks users/me, so each one waits for a round trip to Main Sequence before any route runs. An application can reuse the answer for a token across requests with install_request_identity(app, local_caller_cache_seconds=60):
- Only who holds the token is reused: the UID and username
users/meanswered. TheX-User-UIDcheck still runs on every request, against that UID. - An answer expires after the period, and never outlives the token's
expwhen the token is a JWT. A refusal (401) and an unavailable check (503, including a timeout or a network error) are not kept, so the next request asks again. - Requests that arrive with the same token while its check is running wait for that check and share its result, success or failure.
- Answers are keyed by
MAINSEQUENCE_ENDPOINTand a keyed hash of the token whose key is generated in the process: the token is never stored, and no token, key or hash appears in a log line, an error or arepr(). At most 10,000 callers are kept, oldest dropped first. - A revoked token stays accepted for up to the period.
60matches how long the platform's gateway may reuse an admission in hosted mode.0, the default, asks on every request.
Hosted mode and WebSockets are unchanged. See ADR-0036.
MCP behaves the same way locally. /mcp takes the same Bearer token as your routes, and there is no sign-in challenge locally, so configure the client, for example Codex, to send your platform token as a fixed Authorization: Bearer header.
The Origin check reads FASTAPI_PUBLIC_BASE_URL and FASTAPI_CORS_ALLOW_ORIGINS once, when install_mcp runs. In a deployment the platform sets both: the release's URL and its allowed origins. Locally they are usually unset, so the list is empty. A browser, which always sends Origin, then gets 403, while Codex, curl and scripts, which send none, are unaffected. To try a browser-based MCP client locally, allow its origin in the environment the app starts with:
export FASTAPI_CORS_ALLOW_ORIGINS=http://localhost:3000
Start time and deadlines
To a verified caller, every HTTP response carries
Mainsequence-Process-Started-At, when this process started, and the first
one a process serves also carries Mainsequence-Cold-Start: true. A calling
release reads them through
ResourceRelease.request.
A verified caller's Mainsequence-Request-Timeout-Ms header, the milliseconds
it will still wait, bounds the release calls the handler makes with
ResourceRelease.request. An absent or invalid value bounds nothing. Public
ingress, preflight and WebSocket requests neither get the response headers nor
set a deadline.
Public ingress, preflight and WebSockets
Exact public method/path pairs require the backend-owned FASTAPI_PUBLIC_INGRESS declaration and the gateway's matching admission marker. Anonymous requests have no logged user, and credential/identity headers are stripped. Unlisted routes and methods require authentication. Applications still validate provider callback state or webhook signatures.
OPTIONS receives an anonymous context. Platform health is handled by the launcher's external health wrapper. WebSockets preserve the existing gateway ticket/header contract, use the same getter while the connection is active, and reset identity on disconnect; HTTP caller assertions do not replace their transport contract.
Migration
Existing application factories must add the installer before adopting the corresponding launcher. Replace direct verifier calls, private ContextVar manipulation, or caller lookups through process authentication with User.get_logged_user(). The launcher rejects missing/mismatched integration at startup. Keep Django signing, key discovery and gateway forwarding available before deploying proof-required applications.
The identity answers who called. Application resource policy stays in the application. See server verification and ADR-0036.