Authentication
The Capture MCP SDK is currently only available to Capture's internal developers. We are working hard on the cybersecurity part, so that we can also offer it to everyone who builds on top of Capture.
This page covers who is calling your tool, which token you get, and how your app gets an identity of its own.
The token your tool receives
Your tool never receives the token of the caller. When an agent calls your tool, the MCP server trades the token of the caller at the Capture API for a delegated token: your app, acting for this person. It sends only that token to your service. The call context of your tool holds it: ctx.auth_token in Python and callContext.AuthToken in .NET.
The MCP authorization specification forbids passing the token of a client on to another service. This is how Capture complies. It is also why a tool called "get logbook entries" cannot quietly do everything the person can do.
- Python
- .NET
@app.tool(requires_permission="events:view")
async def get_event(event_id: str, company_id: int, ctx: CaptureMcpCallContext) -> dict:
"""Reads an event for a company."""
headers = {"Authorization": f"Bearer {ctx.auth_token}"} # for this person, through your app
...
[McpServerTool, RequiresPermission("events:view"), Description("Reads an event for a company.")]
public static async Task<Event> GetEventAsync(string eventId, int companyId, ICaptureMcpCallContext callContext)
{
var token = callContext.AuthToken; // use it as the bearer: for this person, through your app
...
}
The call goes like this:
- A person asks a question in an MCP client, such as Claude or Captain.
- The client calls your tool at the MCP server, with the token of the person.
- The MCP server checks who the caller is with the Capture API.
- The MCP server exchanges the token of the person for a delegated token (a standard token exchange). The subject is the person, and the actor is your app.
- The MCP server calls your service over gRPC with the delegated token.
- The SDK checks the delegated token again with the Capture API, using a cache. What comes back is the list of companies and permissions of the caller, already narrowed to what the person and your app may both do.
- Your function runs, with the delegated token in the call context.
- If your tool calls the Capture API, it uses the delegated token. The call is only allowed if the person and your app may do it.
What is in the token
The delegated token is a short-lived JWT. Treat it as opaque and do not parse it to make decisions. For orientation only:
| Claim | Value |
|---|---|
sub | The person, as their Capture user id. |
act.sub | The client id of your app: the app the call is made through. |
aud | The Capture API only. |
scope | api |
exp | At most 5 minutes, and never later than the token of the caller. No refresh token is issued. |
It carries no permissions. The Capture API evaluates them on every request, so a revoked role or a withdrawn consent takes effect immediately, even for a token that was already issued.
What it may do
The Capture API answers a delegated call with the intersection of two sets, for each company: what the person may do, which is their roles at the company, and what your app may do, which is every permission an administrator consented to plus every permission in your own namespace.
- Your own permissions just work. Everything under your own
{app_key}:namespace, the permissions you declare in your manifest, is in the set of your app once the app is installed at the company. The person still needs a role that grants it: a delegated token adds no authority. - Platform permissions need consent. To call anything outside your own namespace, list it under
requestedClientPermissionsin your manifest. A Capture administrator must consent at that company. Requested but not consented means refused. - Owners get no wildcard. A company owner normally bypasses every permission check. Under delegation that bypass is dropped:
ctx.is_owner(...)andIsOwnerarefalse, and an owner holds only the permissions their roles grant that your app's set also allows.
What it cannot do
These are refused with a 403, even if the person could do them and your app was consented:
- Administering anything. Roles, role assignments, consent, app definitions, app availability, app installation, identity providers, and publishing legal documents.
- Calling another Capture service. The delegated token is valid on the Capture API only. Other Capture services treat a token that carries an
actclaim as invalid.
What identity reads show your tool
| You call | A delegated token sees |
|---|---|
GET /api/me | The person's id and companies, with permissions narrowed as above, isOwner: false and roles: []. |
companies, company by id | Only the companies where your app is installed. |
me.legalDocuments | Only the root companies where your app is installed. |
me.profile (username and e-mail address) | Only if an administrator consented your app to iam:profile:view. No role is needed for it. |
Good practice
- Fails closed. If the MCP server cannot get a delegated token for your app, your tool is not called and the agent is told so. The token of the caller is never the fallback. Your service sees nothing, so there is nothing to retry on your side.
- Use the delegated token as the bearer when your tool calls the Capture API for this person, and nowhere else. Do not log it, store it or pass it to a third party. It expires within minutes.
- Use the credentials of your own app for work with no person behind it, such as background jobs, caches and catalog reads. Do not try to emulate that with the token of a user.
- Prefer the permission check of the call context over asking the Capture API. It is already narrowed to the person and your app, so it answers may my app, acting for this person, do this?
- Declare what you need up front under
requestedClientPermissions, instead of finding a403in production.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| The agent reports that it could not obtain a delegated token and the call was not made. | The token exchange is not enabled in that environment, or the client of your app is not linked to your app. Ask a Capture administrator. |
A 403 when you call the Capture API for something the person can do. | It is outside the set of your app (not in your namespace, no consent), or it is an administration call. |
A permission check is false for an owner. | This is expected: owners get no bypass under delegation. Give them a role that grants the permission. |
me.profile has no e-mail address. | Your app is not consented to iam:profile:view at any company the person can reach. |
A call to another Capture service fails with 401. | Those services do not accept delegated tokens. Go through the Capture API. |
App identity without the tools
An app that wants nothing but its own Capture credentials, for example to call the embeddings endpoint, asks for an identity. This binds no port, dials no MCP server and starts no background task:
- Python
- .NET
import os
from capture_mcp_sdk import CaptureAppSettings, capture_app_identity
settings = CaptureAppSettings(
api_base_url="https://api.example.com/",
capture_ai_base_url="https://captain.example.com/captain/",
app_client_id="shiftapp-client",
app_client_secret=os.environ["SHIFTAPP_CLIENT_SECRET"],
)
identity = capture_app_identity(settings)
token = await identity.access_token() # client_credentials, scope=api, cached against its own expiry
app_key = await identity.app_key() # "app-901", or None when this client is linked to no app
CaptureMcpSettings extends CaptureAppSettings with the fields of the tool path (gateway_address, callback_address and service_name) and ttl_seconds. A tools host still configures one object.
- One token cache per process.
capture_app_identityreturns the sameCaptureAppIdentityfor the same credentials, andCaptureMcpApp.start()takes its token provider from it. A host that does both mints and refreshes one token, not two. Hand thetoken_providerof the identity to anything you build on top of it, for the same reason. - Call
access_token()for every request. It is cheap. Do not hold on to the result: a held token goes stale, and a new read costs a dictionary lookup until the token needs a refresh. app_key()can returnNone. That means the client you configured is not linked to a custom app in Capture.
builder.Services.AddCaptureAppIdentity(o =>
{
o.ApiBaseUrl = new Uri("https://api.example.com/");
o.AppClientId = "shiftapp-client";
o.AppClientSecret = builder.Configuration["ShiftApp:ClientSecret"]!;
});
This registers the options (with validation), a memory cache and the two HTTP clients behind ICaptureAppIdentity, and nothing else: no gRPC server, no registry client and no hosted service.
public sealed class ShiftReport(ICaptureAppIdentity identity)
{
public async Task SendAsync(CancellationToken ct)
{
var token = await identity.GetAccessTokenAsync(ct); // cached against its own expiry
var appKey = await identity.GetAppKeyAsync(ct); // GET /api/apps/me; null if unlinked
}
}
AddCaptureMcpTools calls AddCaptureAppIdentity itself, so a tools host gets ICaptureAppIdentity too. Both are safe to call in one host: the options are configured and validated once.
An app key that is None (Python) or null (.NET) means the configured client is not linked to a custom app in Capture. Only an administrator can fix that, so treat it as not available instead of retrying harder.
Caller identity caching
Every call to your tool checks the delegated token with the api/me endpoint of the Capture API. A successful check is cached, so a caller who calls your tool in a loop does not hit that endpoint every time:
- The key is a hash of the whole token, scoped to the issuer. It is never a claim of the token, such as
sub. The SDK does not verify the signature of the token itself: theapi/mecheck is the verification, and a cache hit skips it. Keying on an unverified claim would let someone forge an unsigned token with thesubof a victim, and receive the companies of that victim without ever passing the check. - The lifetime is the smaller of the cache setting and the time the token has left, so a cached entry never outlives the token. A token without a readable expiry still authenticates, but is capped by the cache setting. This bounds how long a revocation takes to show, but it is not a hard expiry.
- Only a success is cached. A rejected token is checked again on the next call.
- A token refresh is a cache miss. The same caller with a new token string is checked again. This is on purpose: correctness over hit rate.
- The setting is
me_cache_ttl_secondsin Python andMeCacheTtlSecondsin .NET, and it defaults to 60. In Python it must be zero or greater, and with0the SDK checks every call again. In .NET, use a positive value.
The result of this check is what the call context shows you:
- Python
- .NET
ctx.has_permission(permission, company_id) and ctx.is_owner(company_id) answer from the cached result, narrowed to the person and your app.
ICaptureMcpCallContext exposes the result: Companies (every company of the caller, each with CompanyId, IsOwner and Permissions), HasPermission(permission, companyId) and IsOwner(companyId). Your tool never has to call /api/me itself.
Use the auth building blocks on their own
- Python
- .NET
The identity code is not specific to MCP. An app that only wants to know who is this caller or which app am I, for example in a FastAPI dependency, imports it directly. No gRPC port and no registration loop are involved.
from capture_mcp_sdk.auth import (
ApiTokenProvider, # client_credentials token for this app, cached and single-flighted
AppKeyResolver, # this app's app key, from GET api/apps/me
CachedTokenResolver, # the token-keyed cache behind resolve_caller
extract_bearer, # "Bearer <token>" header -> token
resolve_caller, # a caller's token -> their companies + per-company permissions
)
token = extract_bearer(request.headers.get("authorization"))
companies = await resolve_caller(
token,
http_client=http_client,
api_base_url=api_base_url,
cache=cache, # hold one per process: that is what makes it a cache
max_ttl=60.0,
)
if companies is None:
raise HTTPException(status_code=401)
capture_mcp_sdk.auth re-exports the same objects that the executor of the SDK uses. There is one implementation, not a copy. Importing any of this does not import grpc or the generated stubs.
resolve_caller tells two cases apart, and you must not mix them up:
- It returns
Nonefor a token that the Capture API rejects (401or403). - It returns an empty tuple for a valid token that has no companies.
A 5xx from the Capture API raises instead of being reported as an invalid token, so a hiccup at the backend does not look like a mass credential failure.
In .NET the building blocks are ICaptureAppIdentity (see above) for the identity of your app, and ICaptureMcpCallContext for the caller of a tool. The SDK validates the delegated token of the caller before your tool runs, so a tool does not resolve callers itself. A call without a valid token never reaches your code: the SDK refuses it first, with an Unauthenticated error.
If you need the identity of the app in a service that has no tools, register AddCaptureAppIdentity alone.