Symptom
A user has a stable Claude → MCP Worker session. The moment they sign into the Umbraco backoffice with the same user, the MCP connection dies — next tool call returns 401 and the Worker's refresh attempt fails.
Root cause
Umbraco ships RevokeUserAuthenticationTokensNotificationHandler (internal sealed). It listens for UserLoginSuccessNotification and, when SecuritySettings.GetUserAllowConcurrentLogins() is false (the default — StaticAllowConcurrentLogins = false), calls:
await _tokenManager.RevokeUmbracoUserTokens(user.Key);
That extension (OpenIdDictTokenManagerExtensions.cs:16-24) does:
var tokens = await openIddictTokenManager.FindBySubjectAsync(userKey.ToString()).ToArrayAsync();
foreach (var token in tokens) await openIddictTokenManager.DeleteAsync(token);
i.e. every OpenIddict token for that user, across every client_id, gets deleted. That includes the refresh token the hosted MCP Worker (umbraco-cms-editor-mcp-hosted / umbraco-cms-dev-mcp-hosted) is using. Worker copy in KV is fine — it's Umbraco's authoritative OpenIddict store that's been emptied. There is no client-side fix; the IdP has deleted the row.
Current stopgap
Branch fix/concurrent-logins-mcp-token-revocation flips SecuritySettings.UserAllowConcurrentLogins = true from the MCP composers via:
builder.Services.Configure<SecuritySettings>(o => o.UserAllowConcurrentLogins = true);
This short-circuits the handler before it revokes. Lands in:
template/umbraco/McpOAuthComposer.cs (self-hosted single-tenant)
template/umbraco/McpHostedClientsComposer.Cloud.cs (multi-tenant Cloud, commented-out boilerplate so it ships once uncommented)
tests/umbraco-instance/McpOAuthComposer.cs (integration-test fixture)
Why this is a stopgap
The fix relaxes a tenant-wide security policy — concurrent backoffice logins for all users — to keep one specific client's refresh tokens alive. That's a bigger blast radius than the problem requires:
- A customer with a "one backoffice session per user" policy is now silently overridden by installing an MCP composer.
- We're not actually granting concurrent backoffice sessions intentionally — we just want MCP tokens to survive.
- The fix correctly scopes to
UserAllowConcurrentLogins (not the broader AllowConcurrentLogins or the MemberAllowConcurrentLogins), so members are untouched, but the principle still applies.
Better options (in increasing complexity)
Option 1 — scope by OpenIddict client (recommended)
Replace Umbraco's handler with a copy that does the same sweep but skips tokens whose Application is one of the registered MCP clients. The global single-session policy stays intact for browser sessions — open a second tab, the first one still gets kicked. MCP refresh tokens survive because they belong to a different client_id.
Plumbing — in the composer's Compose(...):
// Remove Umbraco's internal handler from DI (all three notification interfaces).
var apiMgmtAssembly = typeof(Umbraco.Cms.Api.Management.Security.BackOfficeApplicationManager).Assembly;
var originalType = apiMgmtAssembly.GetType(
"Umbraco.Cms.Api.Management.Handlers.RevokeUserAuthenticationTokensNotificationHandler",
throwOnError: true)!;
for (var i = builder.Services.Count - 1; i >= 0; i--)
{
if (builder.Services[i].ImplementationType == originalType)
{
builder.Services.RemoveAt(i);
}
}
// Register MCP-aware replacement against all three notifications
// the original handles (UserLoginSuccess, UserSaved, UserDeleted)
builder.AddNotificationAsyncHandler<UserLoginSuccessNotification, McpAwareRevokeTokensHandler>();
builder.AddNotificationAsyncHandler<UserSavedNotification, McpAwareRevokeTokensHandler>();
builder.AddNotificationAsyncHandler<UserDeletedNotification, McpAwareRevokeTokensHandler>();
The replacement resolves MCP application ids once per call (via IOpenIddictApplicationManager.FindByClientIdAsync → GetIdAsync) and skips tokens whose GetApplicationIdAsync matches:
var preservedAppIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var clientId in McpClientIds) // e.g. ["umbraco-cms-editor-mcp-hosted", ...]
{
var app = await _applicationManager.FindByClientIdAsync(clientId, ct);
if (app is null) continue;
var id = await _applicationManager.GetIdAsync(app, ct);
if (id is not null) preservedAppIds.Add(id);
}
await foreach (var token in _tokenManager.FindBySubjectAsync(user.Key.ToString()).WithCancellation(ct))
{
var appId = await _tokenManager.GetApplicationIdAsync(token, ct);
if (appId is not null && preservedAppIds.Contains(appId)) continue;
await _tokenManager.DeleteAsync(token, ct);
}
Caveats:
- Uses reflection against an internal type. Brittle if the handler is renamed upstream — but
throwOnError: true makes the failure loud at startup.
- Must cover all three
INotificationAsyncHandler<> interfaces the original implements (UserLoginSuccessNotification, UserSavedNotification, UserDeletedNotification), or we silently regress the lockout/delete revocation paths.
Option 2 — scope by user
Allowlist a dedicated MCP service user (by email / key / group) and skip the revocation entirely for that user. Lighter touch but assumes operators run a separate service account for MCP — usually they don't. Also restores full concurrent backoffice rights for the allowlisted user (a leftover browser session won't get kicked), which is broader than we need.
Option 3 — combine: user AND client
Maximally defensive. Run the original sweep; skip only when token's client is MCP AND user is in the MCP allowlist. Probably overkill.
Recommendation
Ship Option 1 in @umbraco-cms/create-umbraco-mcp-server template (replace the current Configure<SecuritySettings> line), and revert the global config relax. ~60 lines of replacement handler + DI removal, against an internal type. Worth the reflection cost to avoid changing tenant-wide login policy as a side-effect of installing an MCP composer.
Upstream fix
The real bug is in Umbraco CMS: RevokeUmbracoUserTokens(user.Key) should not be a blanket subject-wide sweep. The handler should at minimum filter to tokens whose client is the backoffice cookie/SPA client (Constants.OAuthClientIds.BackOffice), leaving every other registered OpenIddict client's tokens alone. Concurrent-login enforcement was clearly designed for "kick the other browser tab," not "kill every machine-to-machine session." Worth a separate issue on umbraco/Umbraco-CMS. Once that lands, this template-side workaround goes away entirely.
Related
Symptom
A user has a stable Claude → MCP Worker session. The moment they sign into the Umbraco backoffice with the same user, the MCP connection dies — next tool call returns 401 and the Worker's refresh attempt fails.
Root cause
Umbraco ships
RevokeUserAuthenticationTokensNotificationHandler(internal sealed). It listens forUserLoginSuccessNotificationand, whenSecuritySettings.GetUserAllowConcurrentLogins() is false(the default —StaticAllowConcurrentLogins = false), calls:That extension (
OpenIdDictTokenManagerExtensions.cs:16-24) does:i.e. every OpenIddict token for that user, across every client_id, gets deleted. That includes the refresh token the hosted MCP Worker (
umbraco-cms-editor-mcp-hosted/umbraco-cms-dev-mcp-hosted) is using. Worker copy in KV is fine — it's Umbraco's authoritative OpenIddict store that's been emptied. There is no client-side fix; the IdP has deleted the row.Current stopgap
Branch
fix/concurrent-logins-mcp-token-revocationflipsSecuritySettings.UserAllowConcurrentLogins = truefrom the MCP composers via:This short-circuits the handler before it revokes. Lands in:
template/umbraco/McpOAuthComposer.cs(self-hosted single-tenant)template/umbraco/McpHostedClientsComposer.Cloud.cs(multi-tenant Cloud, commented-out boilerplate so it ships once uncommented)tests/umbraco-instance/McpOAuthComposer.cs(integration-test fixture)Why this is a stopgap
The fix relaxes a tenant-wide security policy — concurrent backoffice logins for all users — to keep one specific client's refresh tokens alive. That's a bigger blast radius than the problem requires:
UserAllowConcurrentLogins(not the broaderAllowConcurrentLoginsor theMemberAllowConcurrentLogins), so members are untouched, but the principle still applies.Better options (in increasing complexity)
Option 1 — scope by OpenIddict client (recommended)
Replace Umbraco's handler with a copy that does the same sweep but skips tokens whose
Applicationis one of the registered MCP clients. The global single-session policy stays intact for browser sessions — open a second tab, the first one still gets kicked. MCP refresh tokens survive because they belong to a differentclient_id.Plumbing — in the composer's
Compose(...):The replacement resolves MCP application ids once per call (via
IOpenIddictApplicationManager.FindByClientIdAsync→GetIdAsync) and skips tokens whoseGetApplicationIdAsyncmatches:Caveats:
throwOnError: truemakes the failure loud at startup.INotificationAsyncHandler<>interfaces the original implements (UserLoginSuccessNotification,UserSavedNotification,UserDeletedNotification), or we silently regress the lockout/delete revocation paths.Option 2 — scope by user
Allowlist a dedicated MCP service user (by email / key / group) and skip the revocation entirely for that user. Lighter touch but assumes operators run a separate service account for MCP — usually they don't. Also restores full concurrent backoffice rights for the allowlisted user (a leftover browser session won't get kicked), which is broader than we need.
Option 3 — combine: user AND client
Maximally defensive. Run the original sweep; skip only when token's client is MCP AND user is in the MCP allowlist. Probably overkill.
Recommendation
Ship Option 1 in
@umbraco-cms/create-umbraco-mcp-servertemplate (replace the currentConfigure<SecuritySettings>line), and revert the global config relax. ~60 lines of replacement handler + DI removal, against an internal type. Worth the reflection cost to avoid changing tenant-wide login policy as a side-effect of installing an MCP composer.Upstream fix
The real bug is in Umbraco CMS:
RevokeUmbracoUserTokens(user.Key)should not be a blanket subject-wide sweep. The handler should at minimum filter to tokens whose client is the backoffice cookie/SPA client (Constants.OAuthClientIds.BackOffice), leaving every other registered OpenIddict client's tokens alone. Concurrent-login enforcement was clearly designed for "kick the other browser tab," not "kill every machine-to-machine session." Worth a separate issue onumbraco/Umbraco-CMS. Once that lands, this template-side workaround goes away entirely.Related