Repository navigation
Give an ended Cloud trial a reactivation link on blocked tool calls - #1390
Merged
Merged
Conversation
When an expired trial blocks an MCP tool call, the licence guard now throws TrialEndedException (a ForbiddenException carrying trialEndedAt), also once the cron has flipped the trial to status 'expired'. The tool result says when the trial ended, that connectors and settings are kept, and gives the pricing link (source=mcp_blocked, no email token) for the AI to show. Each blocked call is recorded as the server-only product event trial_expired_call_blocked (tool, adapter slug, OAuth client name), at most once per workspace and tool an hour, fire and forget. Self-hosted is unchanged: the guard returns before any check outside Cloud.
7 of 14 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When a Cloud workspace's free trial has ended, every MCP tool call is refused with "This workspace's trial has ended. Ask a workspace administrator to activate a license." The AI gets no link, and nothing records that the workspace still wants to use the product. Those blocked calls are the clearest sign that someone wants to come back.
This PR makes the refusal useful and measurable:
Changes
license/license-guard.service.ts: newTrialEndedException, aForbiddenExceptionsubclass that carriestrialEndedAt, with the same message as before. It is thrown for an active trial past itsexpiresAt. It is also thrown when the workspace's newest inactive licence is a trial with statusexpired. This second case matters because the hourly cron flips ended trials toexpired(markExpiredTrials), and from then on the guard used to answer "no active license". So most blocked trial calls would never have reached the trial branch. All other messages are unchanged: no licence at all, a paid licence revoked after the trial, a revoked trial, and an expired paid licence.license/license-checkout.ts: newmcp_blockedpricing-link source. NewmarketingOrigin()helper andtrialReactivationUrl(), which returns<MARKETING_URL>/pricing?return_url=<cloud>/settings/license/activate&source=mcp_blocked. The link has no email token, because the text ends up in the AI provider's logs. Duplicate-subscription protection still runs at checkout.email.service.tsnow reusesmarketingOrigin().mcp-server/trial-blocked-call.ts(new):trial_expired_call_blocked. It never throws and is not awaited.mcp-server/dynamic-mcp-tools.ts:checkLicenseActiveanswers aTrialEndedExceptionwith the new text. The JSON haserrorandreactivateUrl.adapterSlug(catalog connectors only) and the OAuth client's name. It is recorded only when the tool resolves in the caller's scope, so made-up tool names cannot fill the throttle map.resolveTool(), with the same logic. The new code uses it too.ProductEventServiceis injected as an optional last argument.mcp-server/mcp-server.service.ts,mcp-server/mcp-endpoint.controller.ts: the invocation context now carriesoauthClientId(azp/client_idof the access token). The event uses it to name the client.audit/product-event.service.ts:trial_expired_call_blockedis added to the allow-list as a server-only event. It storestoolName,adapterSlugandclient, which are already allowed metadata keys.Cloud and self-hosted
checkLicenseActivereturns before any check when the deployment is not Cloud, so the new code path is never reached.Type
Testing
New and updated specs:
license/license-guard.service.spec.tscovers:TrialEndedExceptionwithtrialEndedAt, for an active trial past its end and for one the cron already flipped;TrialEndedException;mcp-server/trial-blocked-call.spec.tscovers:executeTool: the link and the event for an ended trial, the answer not waiting on the event, nothing recorded for an unknown tool, and the plain message with no link and no event for a workspace without a licence and for an expired paid licence.Commands run in
packages/backendafternpm ciat the root andnpx prisma generate:npm run lint--fixrewrote nothing.npx tsc --noEmit -p tsconfig.jsonnpx jest src/license src/mcp-server src/audit src/settingsnpm testnpx jest src/connectors/engines/email.engine.integration.spec.ts src/connectors/engines/response-size-limit.engines.spec.tsThe two failures in the full run were 5 s timeouts in
email.engine.integration.spec.tsandresponse-size-limit.engines.spec.ts, under full-suite load. These specs do not touch this change. Run alone, both pass, as the last row shows.Checklist
npm test). The two load timeouts above pass when run alone.Notes
sourceinto the checkout, and turns any value it does not know intopricing_page. Untilmcp_blockedis added toPRICING_CHECKOUT_SOURCESon the site, those checkouts will be stored aspricing_page.Related Issues
First step of the trial win-back work: the blocked-call link and event. A follow-up PR uses the event for an email.