Skip to content

Give an ended Cloud trial a reactivation link on blocked tool calls - #1390

Merged
helpcodeai merged 1 commit into
mainfrom
feat/trial-blocked-call-link
Nov 1, 2026
Merged

helpcodeai merged 1 commit into
mainfrom
feat/trial-blocked-call-link

Conversation

@helpcodeai

Copy link
Copy Markdown
Contributor

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:

  • Message: the tool result says when the trial ended and that connectors and settings are kept. It also gives a reactivation link and asks the AI to show it to the user.
  • Event: each blocked call is recorded as a server-side product event, so we can count how many workspaces come back after their trial, and with which app.

Changes

  • license/license-guard.service.ts: new TrialEndedException, a ForbiddenException subclass that carries trialEndedAt, with the same message as before. It is thrown for an active trial past its expiresAt. It is also thrown when the workspace's newest inactive licence is a trial with status expired. This second case matters because the hourly cron flips ended trials to expired (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: new mcp_blocked pricing-link source. New marketingOrigin() helper and trialReactivationUrl(), 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.ts now reuses marketingOrigin().
  • mcp-server/trial-blocked-call.ts (new):
    • The tool-result text.
    • An in-memory throttle: one slot per workspace and tool per hour, with a bounded map.
    • The recorder. It checks the in-memory slot first, then looks for a row in the last hour, then writes trial_expired_call_blocked. It never throws and is not awaited.
  • mcp-server/dynamic-mcp-tools.ts:
    • Trial ended: the catch around checkLicenseActive answers a TrialEndedException with the new text. The JSON has error and reactivateUrl.
    • Event: the call is recorded in the background with the tool name, the connector's 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.
    • Refactor: tool scope resolution moved into resolveTool(), with the same logic. The new code uses it too.
    • Constructor: ProductEventService is injected as an optional last argument.
  • mcp-server/mcp-server.service.ts, mcp-server/mcp-endpoint.controller.ts: the invocation context now carries oauthClientId (azp / client_id of the access token). The event uses it to name the client.
  • audit/product-event.service.ts: trial_expired_call_blocked is added to the allow-list as a server-only event. It stores toolName, adapterSlug and client, which are already allowed metadata keys.

Cloud and self-hosted

  • Cloud: an ended trial gets the new message with the link, and one event per workspace and tool per hour.
  • Self-hosted: no change. checkLicenseActive returns before any check when the deployment is not Cloud, so the new code path is never reached.

Type

  • Bug fix
  • New feature
  • Documentation
  • Refactoring
  • Performance improvement
  • Other (describe below)

Testing

  • Added/updated unit tests
  • Tested manually (describe steps below)
  • No tests needed (explain why)

New and updated specs:

  • license/license-guard.service.spec.ts covers:
    • TrialEndedException with trialEndedAt, for an active trial past its end and for one the cron already flipped;
    • the other messages, which never become TrialEndedException;
    • no check on self-hosted.
  • mcp-server/trial-blocked-call.spec.ts covers:
    • the URL with its source and return URL, and no token;
    • the tool text;
    • the throttle window and its memory bound;
    • the recorder: metadata, in-memory and DB dedup, errors swallowed;
    • 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/backend after npm ci at the root and npx prisma generate:

Command Result
npm run lint exit 0, 0 errors. 11 warnings, all in files this PR does not touch. --fix rewrote nothing.
npx tsc --noEmit -p tsconfig.json exit 0
npx jest src/license src/mcp-server src/audit src/settings 58 suites passed (1 skipped), 1679 tests passed
npm test 930 suites passed, 2 failed, 5 skipped. 29357 tests passed, 2 failed.
npx jest src/connectors/engines/email.engine.integration.spec.ts src/connectors/engines/response-size-limit.engines.spec.ts 2 suites passed, 22 tests passed

The two failures in the full run were 5 s timeouts in email.engine.integration.spec.ts and response-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

  • My code follows the existing code style
  • I have tested my changes locally
  • I have updated documentation (if applicable). There is no user-facing setting or doc page for this.
  • All existing tests pass (npm test). The two load timeouts above pass when run alone.
  • This PR has a descriptive title

Notes

  • Website: the pricing page passes source into the checkout, and turns any value it does not know into pricing_page. Until mcp_blocked is added to PRICING_CHECKOUT_SOURCES on the site, those checkouts will be stored as pricing_page.
  • Paid licences past their grace period: they keep the current "renew" text. This PR adds no billing-portal link.

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.

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.
@helpcodeai
helpcodeai merged commit c812ef2 into main Nov 1, 2026
15 checks passed
@helpcodeai
helpcodeai deleted the feat/trial-blocked-call-link branch November 1, 2026 09:50
@github-actions github-actions Bot locked and limited conversation to collaborators Nov 1, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant