MCP server and access tokens

The Starter serves a Model Context Protocol server at the gateway's /mcp. An agent in an editor or a chat, or a script, connects to it and calls tools that read and change what its user can — as that user, and bounded further by the token's scope, the organization's setting and a rate limit. The same tool catalog also runs Blouppy's own assistant (see AI gateway).

What it does

Two ways in

ClientCredentialHow it gets one
Claude Code, Cursor, a scriptA personal access token, Authorization: Bearer blp_pat_…The user creates it in My account → Security and pastes it into the client
Claude.ai, Visual Studio Code, ChatGPTAn OAuth access token for the MCP resourceAuthorization code with PKCE against the identity host, as a pre-registered client
Any connector that publishes a Client ID Metadata DocumentThe same OAuth tokenIts client_id is an HTTPS URL; no registration needed

Personal access tokens are minted by the identity host (Blouppy.Identity/Services/PersonalAccessTokens/) and listed, created and revoked under api/identity/personal-access-tokens/me. The rules are in PersonalAccessTokenRules: a name of at most 100 characters, Read or Read and write, a mandatory expiry of at most 366 days, at most 20 active tokens per user. The value is shown once; only metadata is stored (personal_access_tokens). A token also works on the REST API, which tells it apart by its blp_pat_ prefix.

Connectors go through OAuth: a missing or invalid token gets a 401 with WWW-Authenticate: Bearer resource_metadata="…", which points the client to the protected resource metadata at /.well-known/oauth-protected-resource/mcp, and from there to the identity host. The user consents, and sees and revokes the connector in My account → Security, beside their tokens (api/identity/mcp-connectors/me).

What an agent may do

Two scopes, blouppy.read and blouppy.write, where write implies read. Whatever the scope, no token administers an organization: it never satisfies Policies.Manage. On top of that, each organization sets its API & MCP access — Off, Read or ReadWrite (the default) — in its AI settings. Off hides the organization from every token; Read keeps a write token read-only there.

The tools

Reads:

ToolReturns
list_organizationsThe organizations the user belongs to and the token may read, with the role
list_workspacesThe workspaces the user can open, in one organization or in every readable one
get_workspaceA workspace and its team
list_membersAn organization's members, optionally matched by name — ids and display names, never an e-mail
list_my_notificationsThe user's notifications, newest first, with the unread count

Writes:

ToolThroughWith a token
create_workspaceIWorkspacesServiceAnswers read-only
rename_workspaceIWorkspacesServiceAnswers read-only
add_team_memberITeamsServiceAnswers read-only
remove_team_memberITeamsServiceAnswers read-only
invite_memberIOrganizationInvitationsServiceAnswers read-only
mark_notifications_readINotificationsServiceAllowed: the user's own data

The administering writes are there for the assistant, which runs in the user's browser session; through a token they are refused. There is no delete tool. Lists take limit (20 by default, at most 50) and an opaque cursor, and answer next_cursor (ToolPaging).

How it works

Project / folderHolds
Blouppy.AgentToolsThe catalog: ITool, IToolCatalog, ToolDefinition, ToolResult, ToolErrors, the tools, and the ports IToolAccess and IToolCaller. No SDK.
Blouppy.McpThe MCP binding: AddMcpEndpoint(), MapMcpEndpoint(), McpToolRegistration
Blouppy.Web/Security/ToolAccess, ToolCaller, the mcp / mcp-token / mcp-challenge schemes, ApiAccessRules
Blouppy.Gateway/Mcp/McpRoute, the metadata route, the unauthenticated limit
Blouppy.IdentityToken minting, connectors (Services/McpConnectors/), metadata documents (Services/ClientIdMetadataDocuments/), BlouppyApplicationManager
Tool catalogIdentity hostAPI /mcpGateway /mcpMCP clientTool catalogIdentity hostAPI /mcpGateway /mcpMCP clientPOST, Authorization: Bearer …same header, cookie strippedintrospect as blouppy_api (cached 60 s)active, scopes, audiencestools/callIToolAccess, write budgetCallToolResult
Tool catalogIdentity hostAPI /mcpGateway /mcpMCP clientTool catalogIdentity hostAPI /mcpGateway /mcpMCP clientPOST, Authorization: Bearer …same header, cookie strippedintrospect as blouppy_api (cached 60 s)active, scopes, audiencestools/callIToolAccess, write budgetCallToolResult

The gateway forwards /mcp as a plain YARP route, not a session-carrying one: the client's Authorization header is the credential and passes through, the Cookie header is removed, and a request whose Origin is not the gateway's is refused (UseSameOriginCheck). An address whose requests the API answered 401 more than 30 times in a minute is held back (McpRoute.UnauthenticatedRequestsPerIpPerMinute), because each garbage token costs an introspection. See Gateway and session.

The API selects the mcp scheme for any request under /mcp. It forwards authentication to mcp-token, an IntrospectedTokenHandler that asks the identity host as blouppy_api, caches the answer in HybridCache for ApiAccessRules.IntrospectionCacheDuration (60 s — how long a revoked token can still work) and requires the token's audience to be the MCP resource, the gateway's URL followed by /mcp. A personal access token carries both the API's and the MCP resource's audiences; a connector's token only the MCP resource's. The endpoint requires Policies.McpAccess. See Identity.

The server is ModelContextProtocol.AspNetCore over Streamable HTTP, stateless: no session id, so any replica answers any request. It offers tools only. McpToolRegistration maps a ToolDefinition to an MCP tool — readOnlyHint for a read, idempotentHint from the definition, destructiveHint: false on writes — and a ToolResult to a CallToolResult with structured content, or isError for a refusal the model can read.

The catalog is shared with the assistant. A tool calls the same ports as a controller — IXxxService to write, IXxxApiQueryService to read — and commits through IUnitOfWork (ToolCommits), so a write fires the same domain events, webhooks, notifications and audit entries. It never decides rights itself; it asks IToolAccess:

public interface IToolAccess
{
    Task<bool> CanReadAsync(OrganizationId organizationId, CancellationToken cancellationToken = default);
    Task<bool> CanReadAsync(WorkspaceId workspaceId, CancellationToken cancellationToken = default);
    Task<bool> CanManageAsync(OrganizationId organizationId, CancellationToken cancellationToken = default);
    Task<bool> CanManageAsync(WorkspaceId workspaceId, CancellationToken cancellationToken = default);
    Task<bool> CanWriteAsync(CancellationToken cancellationToken = default);
    Task<IReadOnlyCollection<OrganizationId>> GetReadableOrganizationIdsAsync(CancellationToken cancellationToken = default);
}

ToolAccess, in the Web, answers through IAuthorizationService and the same Policies.Read and Policies.Manage the controllers use, plus the organization's API & MCP access, so a tool is refused exactly where the same REST request would be. IToolCatalog.GetDefinitionsAsync drops the write tools when CanWriteAsync is false, so a read token, or a user whose organizations are all at Read, sees reads only — and a write called anyway is still refused. A read token calling a write tool gets a 403 insufficient_scope naming both scopes (McpWriteScopeChallengeMiddleware), so a connector can ask the user for write and retry.

Rate limits (ApiAccessRules, applied by ApiAccessRateLimiter to token requests only, never the browser's):

PartitionLimit
Per token120 requests a minute, burst of 30
Per user, across tokens300 requests a minute
Per token, writes30 a minute

Every MCP request is a POST, so on /mcp the write budget is spent per write tool call instead (IToolCaller.TryTakeWrite), and a refusal is a tool result — too many changes, try again in N seconds — rather than a 429.

Audit. A request with a token or a connector has the channel ActorChannel.Api, which the audit log records on every entry. The catalog logs each call with the tool, outcome, duration, user, token and organization, never the arguments nor the result.

Configuration

SettingHostMeaning
ApiIntrospection:SecretAPI and identityThe blouppy_api client's secret for introspection. Empty: every token is refused and a warning is logged at startup. The AppHost generates it as the parameter api-introspection-secret.
BlouppyConfiguration:GatewayUrlAPIThe public address; the MCP resource and the links tools return are built from it
BlouppyConfiguration:IdentityUrlAPIThe authorization server named in the protected resource metadata
McpConnectors:PreRegistered:<Name>IdentityClientId, ClientName, ClientUri, LogoUri, RedirectUris, ClientSecret of a pre-registered connector
ClientIdMetadataDocuments:AllowInsecureTransportIdentityAccept an http://client_id. Forced off outside Development.
ClientIdMetadataDocuments:AllowPrivateNetworkTargetsIdentityFetch a document from a private address. Forced off outside Development.

In production the secret is ApiIntrospection__Secret (see Deployment). The identity host's appsettings.json declares Claude (blouppy-claude), VisualStudioCode (blouppy-vscode) and ChatGpt (blouppy-chatgpt).

Metadata documents are fetched through the same PublicOnlyHttpHandler as webhooks (see Webhooks), with a 5-second timeout and a 5 KiB size limit (ClientIdMetadataDocumentRules).

Extending it

Adding a tool:

  1. Write an internal sealed class implementing ITool in Blouppy.AgentTools/<Area>/. Its ToolDefinition gives a stable snake_case name, a short title, a description written for a model, ToolKind.Read or ToolKind.Write, the JSON Schema of its arguments and, for a write, IsIdempotent.
  2. Ask IToolAccess before touching anything, and answer with ToolErrors: NotFoundOrNotAccessible (never which of the two), ReadOnly, or the validator's refusal. Throw ToolArgumentException for a bad argument; the catalog turns it into a refusal.
  3. Call the domain's port: IXxxApiQueryService to read, IXxxService to write, then ToolCommits.CommitAsync, which maps a failed ValidationResult back to the model's argument names or commits. Restate no rule. A person is an id and a display name, never an e-mail; a link comes from ToolLinks.
  4. Register it in AddToolCatalog (Blouppy.AgentTools/ServiceCollectionExtensions.cs) as AddScoped<ITool, YourTool>(). The MCP server and the assistant both pick it up.
  5. Update the tests that list the catalog: ServiceCollectionExtensionsTests in Blouppy.AgentTools.UnitTests, and ReadTools / WriteTools in Blouppy.Web.IntegrationTests/Mcp/McpServerTests.cs, and add the tool's own unit tests beside the others.
  6. For a write tool, give the assistant's confirmation a sentence in front/application/src/app/features/assistant/assistant-proposal.ts; without one it falls back on the tool's title.

Limits

  • No organization administration through a token, and no delete tool at all.
  • A token acts in every organization its user belongs to that lets tokens in; it cannot be scoped to one organization or workspace.
  • Revocation takes up to a minute to reach the API, the introspection cache's window.
  • The rate limiter is per process: exact on one replica, looser with several. The numbers are a first guess, logged on every refusal.