From 18014ac0fc0a261fedcfb940e9470a730d5d6afc Mon Sep 17 00:00:00 2001 From: milstan Date: Wed, 2 Sep 2026 15:08:09 -0700 Subject: [PATCH] feat(mcp): a ChatGPT connector URL that neither sells nor promotes selling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OpenAI app directory rejects an app that sells digital goods — "plugins may conduct commerce only for physical goods. Selling digital products or services — including subscriptions, digital content, tokens, or credits — is not allowed" — and separately forbids promoting the purchase: a plugin "must not display subscription plans, initiate new subscriptions, or promote upgrades" or "link directly to a checkout". Signing in to an existing paid account and using what it entitles you to IS allowed. Anthropic's Software Directory Policy has no equivalent clause, so Claude keeps the top-up flow untouched. POST /chatgpt/mcp is the URL submitted to OpenAI. Same Hono app, same auth, same handler, built with includeCommerce: false. Four things go away together — the tools, and every text that would promote buying them, since removing only the tools left the agent still pushing a top-up it could no longer produce: 1. leadbay_create_topup_link / leadbay_open_billing_portal unregistered. 2. Tool descriptions lose their {{commerce}} blocks (NO_COMMERCE_TOOL_DESCRIPTIONS, emitted by promptforge). 3. The QUOTA_TOPUP instruction paragraph is not pushed. quota-topup.md was split verbatim; the neutral half became quota-refresh.md, pushed on both. 4. LeadbayClient.commerce drops the two selling sentences from the QUOTA_EXCEEDED hint. One client per session, so it cannot leak. Nothing is reworded for ChatGPT. {{commerce}} only ever DELETES, so there is no softened second wording to drift. Enforced two ways: commerce-gate.test.ts asserts the gated strings are character-level subsequences of the Claude ones (any reworded character fails), and the generated descriptions were diffed against main to confirm the default rendering did not move by a byte. A path, not a clientInfo sniff and not a query parameter: the OAuth protected-resource identifier IS the path, so ?commerce=off would leave both URLs advertising the same resource and a client reconnecting to its registered audience would get the selling tools back. Verified against production — real token, real dist bundle on a socket: /mcp lists 60 tools, a 24,128-char instruction string carrying every selling phrase, and returns a live cs_live_ checkout URL. /chatgpt/mcp lists 58, errors on the tool, carries 22,625 chars with none of those phrases, and both its instructions and its descriptions are pure deletions of Claude's. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 + CLAUDE.md | 24 ++ README.md | 4 +- packages/core/src/client.ts | 22 +- packages/core/src/index.ts | 5 + .../core/src/tool-descriptions.generated.ts | 343 ++++++++++++++++++ packages/mcp/CHANGELOG.md | 50 +++ packages/mcp/installer/install-shared.ts | 11 +- packages/mcp/installer/installer-gui.ts | 3 +- packages/mcp/package.json | 2 +- packages/mcp/server.json | 6 +- packages/mcp/src/bin.ts | 3 +- packages/mcp/src/http-server.ts | 32 +- .../mcp/src/server-instructions.generated.ts | 8 +- packages/mcp/src/server.ts | 69 +++- .../mcp/test/unit/commerce-free-path.test.ts | 125 +++++++ packages/mcp/test/unit/commerce-gate.test.ts | 220 +++++++++++ .../next-steps/scan-portfolio-signals.md | 2 + .../rendering/scan-portfolio-signals.md | 2 + .../server-instructions/quota-refresh.md | 1 + .../server-instructions/quota-topup.md | 2 - packages/promptforge/src/assembler.ts | 20 +- packages/promptforge/src/commerce.ts | 90 +++++ packages/promptforge/src/emit.ts | 17 +- packages/promptforge/src/index.ts | 6 + .../promptforge/test/commerce-markers.test.ts | 139 +++++++ .../composite/account-status.md.tmpl | 6 +- .../composite/scan-portfolio-signals.md.tmpl | 2 + 28 files changed, 1196 insertions(+), 33 deletions(-) create mode 100644 packages/mcp/test/unit/commerce-free-path.test.ts create mode 100644 packages/mcp/test/unit/commerce-gate.test.ts create mode 100644 packages/promptforge/snippets/server-instructions/quota-refresh.md create mode 100644 packages/promptforge/src/commerce.ts create mode 100644 packages/promptforge/test/commerce-markers.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8012940c..4c85868d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 0.34.0 — 2026-09-02 — Leadbay on ChatGPT gets its own address + +- **On ChatGPT, add Leadbay as `https://mcp.leadbay.app/chatgpt/mcp`.** Same + Leadbay, same sign-in, same leads, same everything — except the assistant + cannot generate a top-up link or open your billing page there, and will not + suggest buying credits at all. ChatGPT's rules for apps do not allow an app + to sell credits or plans, or to push you toward an upgrade. When you run out + of quota it tells you which window is empty and when it refills. Buy credits + in your Leadbay account as usual; tell the assistant you did, and it picks + straight back up where it stopped. +- **Nothing changes on Claude.** `https://mcp.leadbay.app/mcp` keeps the + 30-second top-up and the billing link exactly as before. +- **If ChatGPT was already set up for you**, our installer now points it at the + new address on its own. + ## 0.33.4 — 2026-09-02 — Enrich the one person you named - **You can now ask for one specific person's email or phone.** "Get me the diff --git a/CLAUDE.md b/CLAUDE.md index 4f751f3f..c1d3fc2e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -250,6 +250,30 @@ Common shared blocks live in `packages/promptforge/snippets/`: | `headers/*.md` | The tiny shared header fragments promptforge stitches in | | `server-instructions/*.md` | Server-instruction blocks — emitted as consts by `emitServerInstructions`, NOT resolved via `{{include:}}` | +### `{{commerce}}` — prose that only exists where selling is allowed + +The OpenAI app directory forbids promoting a purchase; Anthropic's does not. A +handful of paragraphs therefore ship to Claude and not to ChatGPT. Wrap them: + +```markdown +{{commerce}} +**Top-ups always beat waiting.** … +{{/commerce}} +``` + +Promptforge emits the tool's normal const plus an entry in +`NO_COMMERCE_TOOL_DESCRIPTIONS` with the block deleted; +`buildServer({ includeCommerce: false })` serves the second one. Rules: + +- **The marker DELETES; it never substitutes.** Never write a softened + "ChatGPT version" of a paragraph. If a sentence cannot be removed without + rewording the prose around it, gate the whole paragraph instead. +- Markers go on their own lines for a block. For an inline span, put the + leading space *inside* the markers — `resets{{commerce}} (or top + up){{/commerce}}.` — so deleting it leaves `resets.`, not a double space. +- `packages/mcp/test/unit/commerce-gate.test.ts` asserts the gated strings are + character-level subsequences of the default ones, so any rewording fails. + Include them via `{{include:rendering/score-bar}}` etc. Don't duplicate content across templates — extract a snippet if you find yourself copy-pasting. diff --git a/README.md b/README.md index e448db84..512d304d 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ If you use Claude on the web, Claude Desktop, or ChatGPT, the fastest path is a - **Name:** `Leadbay` - **URL:** `https://mcp.leadbay.app/mcp` — one URL for every region (`/fr/mcp` remains a compatibility alias) +- **On ChatGPT, use `https://mcp.leadbay.app/chatgpt/mcp` instead.** Same server, same sign-in, same leads — but the assistant cannot generate a top-up link or open your billing page there, because the OpenAI app directory does not allow an app to sell credits. Buy credits or change your plan in your Leadbay account instead. In Claude: **Settings → Connectors → + → Add custom connector**, paste the URL, then open the connector and **Connect**. Sign in with Leadbay, click **Approve**, and you're linked. The server handles OAuth in-app; updates are automatic — you never touch a config file. @@ -126,7 +127,8 @@ Every supported way to connect Leadbay MCP: | Method | Command / action | Platforms | Notes | |--------|------------------|-----------|-------| -| **Hosted connector (no install)** | Add custom connector → `https://mcp.leadbay.app/mcp` (all regions) | Claude web / Desktop, ChatGPT | Browser OAuth in-app. Nothing to install; auto-updates. | +| **Hosted connector (no install)** | Add custom connector → `https://mcp.leadbay.app/mcp` (all regions) | Claude web / Desktop | Browser OAuth in-app. Nothing to install; auto-updates. | +| **Hosted connector — ChatGPT** | Add custom connector → `https://mcp.leadbay.app/chatgpt/mcp` | ChatGPT | Same server; the top-up and billing tools are not registered (OpenAI directory rule). | | **`.dxt` / `.mcpb` bundle** | Download from [Releases](https://github.com/leadbay/mcp/releases/latest), double-click → **Install** | Claude Desktop | One-click desktop extension. | | **Guided installer (GUI)** | `npx -y -p @leadbay/mcp@latest installer` | macOS, Windows, Linux | Browser wizard: sign in with Leadbay, pick clients. Works for everyone. | | **Local dev build** | `pnpm --filter @leadbay/mcp installer -- --local` | macOS, Windows, Linux | Registers clients against your local build. OAuth automatic. Build from source first (above). | diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 97cdb412..5ea97298 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -538,6 +538,16 @@ export class LeadbayClient { } } + /** + * Whether this client may compose text that promotes a purchase. Default + * true. The MCP server sets it false for a host whose directory forbids + * promoting upgrades (see BuildServerOptions.includeCommerce); the only + * effect is that the QUOTA_EXCEEDED hint drops its two selling sentences. + * Set per client, and the hosted server builds one client per session, so + * this never leaks across tenants. + */ + commerce = true; + get baseUrl(): string { return this._baseUrl; } @@ -1008,8 +1018,16 @@ export class LeadbayClient { // agent can generate the URL itself instead of asking the user to // navigate to a website. Once the user has topped up, the previous // 429 is stale — retry the failed call. - `${hintBase}, OR top up AI credits — top-ups clear the throttle immediately. ` + - `Offer the user to generate a Stripe checkout URL via leadbay_create_topup_link, OR direct them to app.leadbay.ai → Billing. ` + + // + // The two selling sentences are dropped when `commerce` is off — this + // hint is text the agent reads out, and a host may forbid promoting a + // purchase. Nothing is reworded; the rest of the hint is unchanged, and + // "the user topped up (elsewhere), so retry" survives either way. + `${hintBase}` + + (this.commerce + ? `, OR top up AI credits — top-ups clear the throttle immediately. ` + + `Offer the user to generate a Stripe checkout URL via leadbay_create_topup_link, OR direct them to app.leadbay.ai → Billing. ` + : `. `) + `Check leadbay_account_status / leadbay_get_quota to see which resource window (daily/weekly/monthly) was hit. ` + `Once the user has topped up, the previous QUOTA_EXCEEDED is stale — re-call leadbay_account_status to refresh, then RETRY the original operation.`, endpoint, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 07dc174e..e84c17a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -443,3 +443,8 @@ export const compositeTools: Tool[] = [ ]; export const tools: Tool[] = [...compositeTools, ...granularTools]; + +// Descriptions with the {{commerce}} blocks deleted, emitted by promptforge for +// the templates that carry the marker. The MCP server swaps these in on a +// surface that may not promote a purchase; nothing is reworded. +export { NO_COMMERCE_TOOL_DESCRIPTIONS } from "./tool-descriptions.generated.js"; diff --git a/packages/core/src/tool-descriptions.generated.ts b/packages/core/src/tool-descriptions.generated.ts index 0a84ce6c..68c6f3fa 100644 --- a/packages/core/src/tool-descriptions.generated.ts +++ b/packages/core/src/tool-descriptions.generated.ts @@ -4978,3 +4978,346 @@ export const TOOL_DESCRIPTIONS = { } as const; export type ToolDescriptionName = keyof typeof TOOL_DESCRIPTIONS; + +// Descriptions with the {{commerce}} blocks deleted (no rewording). The MCP +// server swaps these in when commerce is gated off; tools absent from this +// map read identically on both surfaces. +export const NO_COMMERCE_TOOL_DESCRIPTIONS: Record = { + leadbay_account_status: `## WHEN TO USE + +Trigger phrases: "what's my account status", "how much quota do I have", "what lens am I on", "I topped up / I bought credits / I added credits". + +Do NOT use for: "show me leads" → \`leadbay_pull_leads\`. + +Prefer when: meta question about account, quota, active lens, or top-up recovery + +Examples that SHOULD invoke this tool: +- "What's my account status?" +- "How much quota do I have left this week?" + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "Show me today's leads." +- "What should I follow up on?" + +## RENDER (quick) + +Report user + org, AND quota whenever readable — include quota even on a plain +"what account?" ask. NEVER mention the lens unless asked (use +\`last_requested_lens_name\`, never the id). SILENT on quota ONLY when +\`quota_error\` set, \`unlimited_credits\` true, or quota null. Else render +Daily/Weekly/Monthly from \`quota.user\` (fall back to \`quota.org\` if \`user\` +absent) as \`$used / $cap (N% used) · resets\` (or a resource-count table when +\`spend[]\` empty). Never say raw "credits". + +--- + +Show the user's account state — admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **Show quota the way the web app does — a percentage-used + dollar-spend gauge per window, never raw "credits".** Each window in \`quota..spend[]\` carries \`current_units\` / \`max_units\` in dollar_cents (% used = the ratio, $ = \`/100\`); the \`quota..resources[]\` list gives the per-resource usage breakdown (\`count\`, plus \`max_units\` when a per-resource cap exists). **Pre-check the \`LENS_EXTRA_REFILL\` resource here before calling \`leadbay_extend_lens\`** — look in **\`quota.org.resources[]\`** first (admins), and fall back to **\`quota.user.resources[]\`** when \`quota.org\` is absent (non-admin callers only get the \`user\` group), matching the resource type **case-insensitively** (it may arrive as \`LENS_EXTRA_REFILL\` or \`lens_extra_refill\`). Its full requested batch must fit into the remaining daily quota or the call is rejected outright. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption. + +**After a user tops up, do NOT keep refusing — RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL \`leadbay_account_status\` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first — a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. **A stale quota snapshot is never a reason to gate-keep a topped-up user.** + +**\`notifications\` block.** The response now includes a top-level \`notifications\` array listing background work the user (or agent) initiated that has since completed (\`bulk_enrich\`, \`bulk_qualify\`, \`import\`). These are signals to revise prior agent outputs the just-finished work might have made stale — they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call \`leadbay_acknowledge_notification(notification_id)\`. Full handling protocol below. + +## GATE — INSPECT \`_meta.notifications\` ON EVERY RESPONSE + +Some Leadbay tool responses include a \`_meta.notifications\` array listing **background work the user (or you) initiated that has since completed**. The three relevant kinds: + +| Kind | What just finished | +|---|---| +| \`bulk_enrich\` | A bulk contact-enrichment job (emails / phone numbers attached to leads' contacts). | +| \`bulk_qualify\` | A bulk lead-qualification job (web-fetch + AI rescore, producing \`ai_agent_lead_score\` and qualification answers). | +| \`import\` | A CSV / CRM file import (rows resolved to leads in the user's pipeline). | + +**Your job when you see an entry: revise prior outputs the just-finished work might have made stale.** This is NOT a pending-task list. It's a "your earlier answer used data that has now changed" signal. + +| Kind | Outputs you've produced that may now be stale — refresh them | +|---|---| +| \`bulk_enrich\` | Outreach drafts mentioning these leads' contacts; contact lists; recommended-lead lists citing \`contact_count\`; NEXT STEPS that asked the user to wait for emails / phones. Re-fetch via \`leadbay_get_contacts(leadId)\` for the affected leads. | +| \`bulk_qualify\` | Lead rankings / shortlists you produced without \`ai_agent_lead_score\`; "today's leads"; followup maps; prepare-outreach picks. Re-pull via \`leadbay_pull_leads\` / \`leadbay_research_lead_by_id\`. | +| \`import\` | "Available leads" claims; pulls from the affected lens that ran before the import landed; followup planning that needed the imported set. Re-pull via \`leadbay_pull_leads\` / \`leadbay_pull_followups\`. | + +**After revising (or after confirming no prior output is affected):** call \`leadbay_acknowledge_notification(notification_id)\` so the entry stops resurfacing on every tool response. Ack-and-move-on is correct even when nothing was stale — that's how the inbox stays focused on what's actually pending. + +**Do NOT** interpret these entries as "things waiting for the user." The user expects you to handle them silently. They are signals to YOU — agent — that prior outputs need a refresh. + +**Poll a job you launched THIS turn; don't poll one from a PREVIOUS turn.** The rule splits by *when* the work was kicked off: + +- **Previous turn / before an MCP restart, and the user has NOT asked about it** — don't poll for it in the background. Simply continue the conversation; the next time you call any tool, the completed-work entry appears in \`_meta.notifications\` (also on \`leadbay_account_status.notifications\`). This is the ambient push path — leave it to do its job. **But if the user explicitly asks for status or to "wait for it to finish"** (e.g. a multi-turn flow where a job was launched in a prior turn and this turn says "wait for enrichment to finish, then …"), DO poll its status tool now until done, exactly as for a this-turn job below — the ambient push only surfaces *completed* work, so it can't answer a live "is it done / wait for it" request while the job is still running. +- **This turn (you just launched it)** — the DEFAULT is: do NOT end your turn on the "launched" ack; stay active and poll the job's status tool in a loop until it reports done, then report the finished result yourself, rather than spinning forever or deferring the result to a later turn. (Two exceptions, detailed below: the user explicitly asked NOT to wait / to run it in the background; or it's a large qualification/import that's async by design — in those cases hand back the handle instead of looping.) Each status tool has its OWN terminal signal — poll until: + - \`leadbay_bulk_enrich_status\` → \`all_done:true\` — OR \`overall_progress.done\` holds steady across several SPACED polls (~15–30s apart) over at least ~90s–2 min of elapsed time (some contacts are unresolvable, so \`all_done\` can stay false forever). Don't call a plateau from the first few back-to-back reads — early on \`done\` sits flat while the backend spins up. Once the plateau is real, report what resolved and name what didn't. + - \`leadbay_qualify_status\` → \`still_running\` is empty: every launched lead has finished or failed. (\`in_progress\` also reads \`false\` on the fast path, but it can be \`null\` on the legacy/fallback read — so treat an empty \`still_running\` as terminal on its own; only require \`in_progress:false\` when that field is actually present.) LIKE imports, large qualification runs are async by design: \`leadbay_bulk_qualify_leads\` defaults to \`wait_for_completion:false\` for \`count > 5\` or chained workflows because blocking can time out, and \`leadbay_qualify_status\` may take minutes/hours. So don't force a long polling loop on a big run — return the handle/progress and let completion arrive via \`_meta.notifications\` — UNLESS the user explicitly asked to wait, or it's a small run that finishes quickly. A small \`wait_for_completion:true\` run you can poll to \`still_running\` empty inline. + - \`leadbay_import_status\` → \`status:"complete"\` (or \`"failed"\`). BUT imports are the exception to the stay-active loop: a large \`leadbay_import_leads({wait_for_completion:false})\` is meant to return a handle and resolve over minutes, and the tool does ONE refresh pass per call. Don't block the conversation looping on it — surface the returned progress/handle and let the completion arrive via \`_meta.notifications\` — UNLESS the user explicitly asked you to wait for the import, or it's a small import that finishes quickly. + + Enrichment polls to completion in-turn BY DEFAULT — the exception is when the user explicitly said to start it in the background / not wait ("kick it off, I'll check later"), in which case hand back the bulk_id and let completion arrive via \`_meta.notifications\` (only when a notification id exists; if none was returned, tell the user to ask again / that you'll poll later, since nothing will auto-surface). For qualification and imports, poll inline only for small/quick runs or when the user explicitly asked you to wait; otherwise return the handle and let \`_meta.notifications\` deliver it. Either way, the user should never have to ask "is it done yet?" for work you kicked off in the same turn — you either report it or hand back a clear in-progress handle. + +Also surfaced as a top-level \`notifications\` array on \`leadbay_account_status\` — same shape, same handling. + + +--- + +## RENDERING — quota windows (percentage + $, like the frontend) + +Mirror the Leadbay web quota widget: three windows side by side — **Daily**, +**Weekly**, **Monthly** — each headlined by a **% used** gauge and a **$ spend / +$ cap** figure, with a per-resource usage breakdown underneath. **Never speak in +raw "credits"** for quota — the unit is a percentage and a dollar spend. + +**Include the quota whenever it is readable** — as part of the default account +answer, even when the user only asked "what account am I connected to?". The +sole reason to omit it is the silence gate below (unreadable quota, or an +unlimited account); it is NOT gated on the user explicitly asking for quota. + +**Silence gate (check FIRST).** Render NOTHING about quota when any of these +holds — do not mention quota at all, do not say "unreadable", never tell the user +to reconnect: +- \`quota\` is null, OR \`quota_error\` is set (a 401/403 backend quirk for plan-less + orgs — the same token read user/org fine), OR +- \`organization.unlimited_credits\` is true (internal/unlimited account — stay + silent on quota; never announce "unlimited"). + +**Pick the group (for DISPLAY only).** Prefer \`quota.user\` (present for every +caller). Use \`quota.org\` only when \`quota.user\` is absent (admins receive both — +still show the caller's own \`user\` view). Call the chosen group \`\` below. + +**Exception — lens-refill pre-checks read the refill row, ORG-first.** This +user-preference is for the display gauge ONLY. When you pre-check the +\`LENS_EXTRA_REFILL\` resource before \`leadbay_extend_lens\`, look for the row in +**\`quota.org.resources[]\` first** (admins get the org group, and the refill +quota is org-scoped there); when \`quota.org\` is absent — non-admin callers only +receive the \`user\` group — fall back to **\`quota.user.resources[]\`**. Match the +resource type case-insensitively (\`LENS_EXTRA_REFILL\` / \`lens_extra_refill\`). +Skipping the \`user\` fallback for non-admins would make the row invisible even +when the quota data exists, so the agent burns the write and hits the very 429 +this pre-check exists to avoid. + +**Per window (fixed order: daily → weekly → monthly).** Match entries by +\`window_type\` (\`"daily"\` / \`"weekly"\` / \`"monthly"\`). + +**Headline — when \`.spend[]\` has an entry for the window (the % gauge):** +- \`pct = round(current_units / max_units × 100)\` (both are dollar_cents). +- \`$used = (current_units / 100).toFixed(2)\`, \`$cap = (max_units / 100).toFixed(2)\`. +- 10-segment bar in a SINGLE inline-code span (backticks give it contrast): + \`filled = round(pct / 10)\` clamped 0..10; \`bar = "▰"×filled + "▱"×(10 − filled)\`. + Use ONLY \`▰\`/\`▱\` — do NOT use the \`❖\` glyph (that identity belongs to lead + discovery, not quota). +- Line: **\`\`** \`\` \`▰▰▱▱▱▱▱▱▱▱\` \`\` \`% used · $ / $ · resets \`. + e.g. \`**Daily** \` + \`\` \`▰▱▱▱▱▱▱▱▱▱\` \`\` + \` 7% used · $0.84 / $12.00 · resets in ~7 h\`. + +**Fallback — when \`.spend[]\` is empty** (internal / free orgs have no +OVERALL_SPEND quota): no gauge. Render the per-window resource breakdown as a +compact table instead — one row per resource in \`.resources[]\` for that +window: the friendly label + \`count\` (append \`/ \` only when +\`max_units\` is a number). This is the pre-existing behavior, preserved. + +**Resource labels (look up case-insensitively — lower-case \`resource_type\` +first).** Localize to \`user.language\` (FR canonical shown; English in parens): +- \`llm_completion\` → **Générations par IA** (AI generations) +- \`ai_rescore\` → **Leads qualifiés** (qualified leads) +- \`web_fetch\` → **Informations web** (web insights) +- \`contact_enrichment_phone\` → **Téléphones enrichis** (phones enriched) +- \`contact_enrichment_email\` → **E-mails enrichis** (emails enriched) + +Skip any resource type not in this map silently — never dump the raw +\`resource_type\` string at the user. + +**\`resets_at\`.** Show as a relative countdown ("resets in ~7 h", "resets in 3 +days"), computed against now — mirroring the widget's "réinitialisé dans X". The +raw value is an ISO-8601 timestamp. + +**Top-up (optional, subordinate).** When \`quota.topup\` is present, you MAY add one +small line below the windows: \`Top-up: $ of $ left\`. +Keep it secondary — the three window gauges are the headline. Omit when null. + +**Legend** (once, below): \`\` \`▰\` used · \`▱\` remaining \`\`. + + +--- + +WHEN TO USE: at the start of a session to know what the agent can/can't do, after a 429 to explain to the user which resource window was exhausted and when it resets, and after the user signals a top-up so the agent can resume the interrupted workflow. + +WHEN NOT TO USE: as a pre-flight gate before bulk ops — operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up — re-call this tool first, then proceed. +`, + leadbay_scan_portfolio_signals: `## WHEN TO USE + +Trigger phrases: "which of my leads ", "find leads that ", "scan my portfolio for ", "identify all the ones that since ", "who in Monitor has a signal", "build a campaign from leads with ". + +Do NOT use for: "research one named company" → \`leadbay_research_lead_by_name_fuzzy\`; "everything about lead " → \`leadbay_research_lead_by_id\`; "qualify my next N leads (they aren't researched yet)" → \`leadbay_bulk_qualify_leads\`; "just list my follow-ups" → \`leadbay_pull_followups\`. + +Prefer when: user wants to FILTER a known portfolio by a web-research signal in bulk — pass \`query\`, optionally \`since\`, \`city\`/\`set_filter\`, or \`leadIds\`; NEVER a country name in \`city\` — a whole-country ask means NO geo filter + +Examples that SHOULD invoke this tool: +- "Which of my leads acquired a company since 2025?" +- "Scan my Lyon portfolio for funding signals." +- "Find everyone in Monitor who changed CEO and build a campaign." + +Examples that should NOT invoke this tool (sound similar, route elsewhere): +- "Look up Acme Corp for me." +- "Show me my follow-ups." +- "Qualify my next 10 leads." + +## RENDER (quick) + +Cohort grouped by lead: one block per matched lead (name · location + +its matched signal entries, hot first, source-linked). Open with +"N match (M scanned)"; ALWAYS close with an honesty footer — +"scanned N · matched M · K not yet researched". Never present +not_researched leads as "no signal". Full layout below. + +--- + +Scan a known portfolio for a specific web-research signal in one call. This is +the bulk, read-only answer to "which of my leads have signal X" — the question +that otherwise forces a per-lead \`leadbay_research_lead_by_id\` loop (one full +profile call per lead, slow and quota-heavy). + +**Reads CACHED signals only — does not trigger new research.** For each lead in +scope it reads \`GET /leads/{id}/web_fetch\` (the already-computed web-research +signals) and filters the entries against \`query\`. It issues NO web_fetch POST, +so it does not consume AI qualification credits and does not re-crawl. Leads +that have no cached content (never qualified, or still in progress) are +reported in \`not_researched\` — they are **NOT** silently treated as "no +match". Qualify them with \`leadbay_bulk_qualify_leads\`, then re-scan. + +**Scope.** Pass \`leadIds\` for an explicit cohort, or omit it to scan the +Monitor portfolio. Narrow the Monitor scope with \`city\` / \`set_filter\` exactly +as \`leadbay_pull_followups\` does (store-then-apply server-side filter). + +**One workspace = one country — a country name is NEVER a location filter.** The admin-area index holds no country nodes, so \`"France"\` matches the *commune of Francs* and \`"United States"\` matches *Statesboro*: the call is silently fenced to one village and every conclusion from it is wrong. City AND country named? Keep the city, drop the country. + +**On \`code: "COUNTRY_LEVEL_LOCATION"\` read \`country_locations[].axis\` and \`[].kind\` — the recovery differs per case and they are NOT interchangeable, and do NOT retry with another spelling or a nearby city.** + +\`axis: "include"\`: + +- \`home_country\`, or "nationwide" / "everywhere" → drop that ONE value. Omit the geo argument (\`city\` / \`locations\` / \`location_ids\`) only if nothing else was on it — then the result covers the whole workspace. If other values remain, keep them and describe the result as those places. +- \`foreign_country\` ("leads in France" on a US workspace) → **unsupported, not unfiltered.** Do NOT re-run without the argument: whole-workspace results are US leads and answer nothing about France. Say the workspace holds only its own country's companies. +- \`supranational\` ("EU", "EMEA") → name what the workspace covers, then offer the whole-workspace view as an explicit choice rather than assuming it. +- \`country_indeterminate\` (custom/staging backend) → its country is unknown, so claim nothing about what it holds. + +\`axis: "exclude"\` reverses all of that — **never "omit the argument"**, which returns the very companies the user asked to remove. Excluding this workspace's own country would empty it; excluding any other country is a harmless no-op. Either way drop the value and ask what to carve out instead. + +On a lens-WRITING tool (\`new_lens\`, \`adjust_audience\`, \`update_lens_filter\`) write NOTHING, with no re-call in any form: when the country was the only scope; for ANY \`foreign_country\` or \`supranational\` INCLUDE however much else came with it — the sectors and sizes were QUALIFYING that territory, not a second request, so writing them alone saves a real audience for a territory nobody asked about; and for ANY non-\`foreign_country\` \`exclude\` hit, likewise — dropping it and writing the rest inverts the ask. + +**Never infer WHICH country this workspace serves from the user's wording** — "the whole US" does not make it one. Read \`_meta.region\` on any tool result — it outranks any recalled memory; on \`custom\`, claim nothing. + +Place names never go in \`keywords\`, \`sectors\` or \`refine_prompt\` — text matches, not geo filters. + The +scan is bounded by \`max_leads\` (default 200, hard cap 300); when the portfolio +is larger, \`truncated_at\` is set and coverage is partial — say so. + +**Query.** \`query\` is matched case- and accent-insensitively against each +signal entry's description, source, and section label. Comma- or +space-separated terms are OR'd ("M&A, acquisition, racheté" matches any). Use +\`since\` (ISO date) to keep only entries dated on/after it — entries with no +date are kept (a missing date is not evidence the event is old). + +**Result is campaign-ready.** \`matched[]\` carries \`lead_id\`, \`name\`, +\`location\`, and the matching \`matched_signals[]\` (section + hot + source + +date + description). Feed the matched \`lead_id\`s straight into +\`leadbay_add_leads_to_campaign\` / \`leadbay_create_campaign\`. + +**SIGNAL HONESTY — never infer signals from freshness.** \`stale_at\`, +\`web_fetch_in_progress\`, \`fetch_at\` are freshness markers, not signal +indicators — signal presence is read ONLY from the actual \`signals[]\` / +\`web_fetch.content\` entries. For "which of my leads have signal X" across a +portfolio, call **\`leadbay_scan_portfolio_signals\`** (bulk-reads cached +signals); don't loop \`leadbay_research_lead_by_id\` per lead or guess from +freshness. A lead with no cached content is \`not_researched\`, not "no match"; +never report a signal verdict for a lead you never read. + + +WHEN TO USE: when the user wants to filter a known +portfolio by a web-research signal across many leads at once — discovering a +cohort to act on, not inspecting a single lead. + +WHEN NOT TO USE: for a single named company +(leadbay_research_lead_by_name_fuzzy) or one lead by UUID +(leadbay_research_lead_by_id); to qualify leads that have no signals yet +(leadbay_bulk_qualify_leads); or to just list follow-ups with no signal filter +(leadbay_pull_followups). + +--- + +## RENDERING — bulk signal-scan results + +The output is a cohort, grouped by lead. Lead with the matches, end with an +honesty footer — never hide what wasn't scanned. + +### Matched leads + +Open with a one-line headline: \`**N leads match ""** (M scanned).\` + +Then one block per \`matched[]\` lead, ordered with \`hot\` matches first. Emit +each as a host-parseable per-lead block so the chat host's place-card +auto-detector can render it (per the repo "feed the address auto-detector" +convention): + +\`\`\` +### · + + +- ** ** — <🔥 if hot> ([source](), ) +\`\`\` + +- **Bold** the description of \`hot: true\` entries; leave cold entries plain. +- Render \`source\` as a markdown link \`([source](url), date)\`; omit the date + when null, omit the link when \`source\` is empty. +- Cap to the 3 strongest signals per lead (hot first, then by date desc); if a + lead has more, end its block with \`_+K more signals_\`. +- When \`name\` is null (the scan was scoped by \`leadIds\` and the read failed to + carry firmographics), fall back to \`### Lead \` — but prefer to enrich + the name via the matched lead's own data when available. + +### Honesty footer (ALWAYS print) + +A single italic line summarising coverage: + +\`_Scanned N · matched M · K had no cached signals (not yet researched)._\` + +- When \`not_researched\` is non-empty, this is load-bearing: state plainly that + those K leads were NOT searched and were NOT counted as "no match". Offer to + qualify them and re-scan (see NEXT STEPS). +- When \`truncated_at\` is set, add: \`_Coverage partial — only the first + leads were scanned; narrow the scope or raise max_leads._\` + +**Hide:** raw \`lead_id\` in prose (use it only for the campaign call), \`_meta\`, +empty arrays, any freshness field. NEVER present \`not_researched\` leads as +"no signal found". + + +--- + +## NEXT STEPS — after the signal scan + +**ALWAYS render NEXT STEPS via your host's next-step widget.** Use whichever is in your tool set — the NAME and SCHEMA differ: **\`ask_user_input_v0\`** (Claude chat / ChatGPT) takes plain-string options with \`type:"single_select"\`; **\`AskUserQuestion\`** (Claude cowork / Claude Code) takes object options \`{label, description}\` plus a required short \`header\` (≤12 chars) and \`multiSelect\`, NO \`type\` field, and never add an "Other" option (the host adds it). Match the schema to the tool you actually have — the wrong schema fails silently and you fall back to prose. Prose bullets are the fallback ONLY when NEITHER widget exists. Any turn that would end with a choice must be the widget — the widget IS the question. + +**If the tool result carries a \`next_steps\` object, that is the source of truth — use it directly.** Each option has a short \`.label\` (≤5 words) and a full \`.description\`. Map \`next_steps.options[]\` into your host widget VERBATIM and in order: for \`AskUserQuestion\` (cowork / Claude Code) pass each as \`{label, description}\`; for \`ask_user_input_v0\` (Claude chat / ChatGPT, string options only) pass each option's \`.description\` as the string (it's the full sentence). Do NOT reword, reorder, drop, or prose-ify them — they're built deterministically by the server so the offer (incl. the artifact option at position 0) fires every time. Fall back to the table below only when there is NO \`next_steps\` field. + +**One exception — skip the widget** when the user's original message contained a complete sequential instruction chain ("show me X and then do Y") AND all stated steps have been completed. In that case, end with STOP directly — the user stated their full plan and does not need a "what next?" prompt. +- Skip example: "Show me today's leads and then research the top one for me." → after research completes, emit STOP without the widget. +- Do NOT skip for: plain requests ("show me today's leads", "run my check-in"), recurring-language requests ("I do this every day"), or requests where only one action was stated. + +Pick 2–4 rows from the (Observation, Suggest, Calls) table below most relevant to the response, then call your host's widget with ITS schema (per the schema rules above — wrong schema fails silently): +- \`ask_user_input_v0\`: \`{questions:[{question,type:"single_select",options:["",""]}]}\` +- \`AskUserQuestion\`: \`{questions:[{question,header:"Next step",multiSelect:false,options:[{label:"<≤5 words>",description:""}]}]}\` + +User picks → call the matching \`Calls\` tool. Constraints: 2–4 mutually-exclusive options, AskUserQuestion labels ≤5 words (full text in \`description\`), max 3 questions. Table stays internal; never recite it. + +--- + + + +The scan exists to BUILD A COHORT, not just to list. The default next move is +almost always "turn the matched leads into a campaign." + +| Observation | Suggest | Calls | +|---------------------------------------------------|--------------------------------------------------------------|----------------------------------------------------------------------------------------| +| \`matched\` non-empty (top of menu) | "Build a campaign from the N matched leads" | leadbay_create_campaign / leadbay_add_leads_to_campaign(matched lead_ids) | +| \`not_researched\` non-empty | "K leads aren't researched yet — qualify them, then re-scan" | leadbay_bulk_qualify_leads(not_researched lead_ids) → re-run leadbay_scan_portfolio_signals | +| Zero matches but leads were researched | "Widen the query (synonyms) or relax \`since\`" | leadbay_scan_portfolio_signals(query: "", since: omit-or-earlier) | +| \`truncated_at\` set | "Scan only covered N — narrow scope or raise the cap" | leadbay_scan_portfolio_signals({city / set_filter}) or raise \`max_leads\` | +| One standout matched lead | "Open that lead's full brief" | leadbay_research_lead_by_id(leadId) | + +NEVER report leads in \`not_researched\` as if they had no matching signal — they +were never read. Distinguish "no signal X found" (researched, no match) from +"not yet researched" (no data to search) every time. +`, +}; diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 1593a6b0..a6fbdfb5 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog — @leadbay/mcp +## 0.34.0 — 2026-09-02 + +The OpenAI app directory rejects an app that sells digital goods — "plugins may +conduct commerce only for physical goods. Selling digital products or services +— including subscriptions, digital content, tokens, or credits — is not +allowed" — and separately forbids promoting the purchase: a plugin "must not +display subscription plans, initiate new subscriptions, or promote upgrades" or +"link directly to a checkout". Signing in to an existing paid account and using +what it entitles you to IS allowed. Anthropic's Software Directory Policy has +no equivalent clause; its nearest rule bars software that "executes financial +transactions on behalf of users", which our Stripe-URL handoff does not do. + +`POST /chatgpt/mcp` is the URL submitted to OpenAI. Same Hono app, same auth, +same handler, built with `includeCommerce: false`. Four things go away +together — the tools, and every text that would promote buying them, since +removing only the tools left the agent still pushing a top-up it could no +longer produce: + +- `leadbay_create_topup_link` and `leadbay_open_billing_portal` are not + registered (filtered after the catalogue arrays merge — both appear in + `compositeReadTools` and `granularReadTools`). +- Tool descriptions lose their `{{commerce}}` blocks + (`NO_COMMERCE_TOOL_DESCRIPTIONS`, emitted by promptforge). +- The `QUOTA_TOPUP` instruction paragraph is not pushed. `quota-topup.md` was + split verbatim: the selling paragraph is gated, and the neutral one that says + when to re-render the quota gauge became `quota-refresh.md`, pushed on both. +- `LeadbayClient.commerce` drops the two selling sentences from the + QUOTA_EXCEEDED hint ("OR top up AI credits…", "…or direct them to + app.leadbay.ai → Billing"). One client per session on hosted, so the flag + cannot leak across tenants. + +Nothing is reworded for ChatGPT. `{{commerce}}` only ever DELETES — there is no +second, softened wording anywhere to drift out of sync, and the Claude surface +keeps selling exactly as hard as before. Enforced two ways: +`commerce-gate.test.ts` asserts the gated description and instruction strings +are pure character-level *subsequences* of the Claude ones (any reworded +character fails), and the generated descriptions were diffed against `main` to +confirm the default rendering did not move by a byte. + +A path rather than an `initialize` clientInfo sniff, and rather than a query +parameter: the OAuth protected-resource identifier IS the path +(`/.well-known/oauth-protected-resource/chatgpt/mcp` → `resource: +https://mcp.leadbay.app/chatgpt/mcp`). A `?commerce=off` flag would leave both +URLs advertising the same resource, so a client reconnecting to the audience it +registered would get the selling tools back. RFC 8707 also says a resource URI +should carry no query component. + +`RESOURCE_PATHS` gains the path so OAuth discovery resolves for it, and the +installer's ChatGPT Desktop entry now hands out `HOSTED_MCP_URL_CHATGPT`. + ## 0.33.4 — 2026-09-02 `leadbay_enrich_contacts` moves from `granularWriteTools` to diff --git a/packages/mcp/installer/install-shared.ts b/packages/mcp/installer/install-shared.ts index 54703a72..724047e1 100644 --- a/packages/mcp/installer/install-shared.ts +++ b/packages/mcp/installer/install-shared.ts @@ -5,6 +5,15 @@ import { homedir } from "node:os"; export const HOSTED_MCP_URL = "https://mcp.leadbay.app/mcp"; +/** + * The URL we hand to ChatGPT. Same server, same auth, but served without the + * top-up and billing-portal tools: the OpenAI app directory forbids selling + * digital goods, and that applies to a connector a user adds by hand just as + * it does to a listed app. Claude keeps HOSTED_MCP_URL, which is unchanged. + * See COMMERCE_FREE_PATHS in src/http-server.ts. + */ +export const HOSTED_MCP_URL_CHATGPT = "https://mcp.leadbay.app/chatgpt/mcp"; + /** * Print the actionable fallback block. The guided installer ALWAYS tries to * open a browser; this is only printed by the entrypoint watchdog (#3805) when @@ -221,7 +230,7 @@ export async function detectClients(): Promise { } if (await isChatGptDesktopInstalled(home)) { - out.push({ id: "chatgpt-desktop", label: "ChatGPT Desktop", detail: HOSTED_MCP_URL }); + out.push({ id: "chatgpt-desktop", label: "ChatGPT Desktop", detail: HOSTED_MCP_URL_CHATGPT }); } const cursorPath = process.platform === "win32" ? `${home}\\.cursor\\mcp.json` : `${home}/.cursor/mcp.json`; diff --git a/packages/mcp/installer/installer-gui.ts b/packages/mcp/installer/installer-gui.ts index 255df151..80ffb545 100644 --- a/packages/mcp/installer/installer-gui.ts +++ b/packages/mcp/installer/installer-gui.ts @@ -12,6 +12,7 @@ import { detectClients, formatInstallOsLabel, HOSTED_MCP_URL, + HOSTED_MCP_URL_CHATGPT, type DetectedClient, } from "./install-shared.js"; import { inferRegionViaStargate, oauthLogin, openInBrowser } from "../src/oauth.js"; @@ -435,7 +436,7 @@ async function installInto(client: DetectedClient, session: LoginSession, includ : { ok: false, message: `config ${configRes.message}; ${exportRes.message}` }; } } else if (client.id === "chatgpt-desktop") { - res = { ok: true, message: "manual setup required; add this MCP URL in ChatGPT Settings > Apps: " + HOSTED_MCP_URL }; + res = { ok: true, message: "manual setup required; add this MCP URL in ChatGPT Settings > Apps: " + HOSTED_MCP_URL_CHATGPT }; } else if (client.id === "claude-desktop" && client.mode?.dxt && client.supportDir) { const dxtResult = await removeDxtExtension(client.supportDir); const jsonResult = await installInJsonConfig(client.configPath!, session.token, session.region, includeWrite, telemetryEnabled, LOCAL_BIN_PATH); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index d2d24547..3ead2db2 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@leadbay/mcp", - "version": "0.33.4", + "version": "0.34.0", "mcpName": "io.github.leadbay/leadbay-mcp", "description": "Model Context Protocol (MCP) server for Leadbay — AI lead discovery, qualification, and enrichment for Claude Desktop, Cursor, and Claude Code.", "type": "module", diff --git a/packages/mcp/server.json b/packages/mcp/server.json index 67db6d78..b6617e62 100644 --- a/packages/mcp/server.json +++ b/packages/mcp/server.json @@ -3,7 +3,7 @@ "name": "io.github.leadbay/leadbay-mcp", "title": "Leadbay", "description": "AI lead discovery, qualification, and outreach prep on your Leadbay account.", - "version": "0.33.4", + "version": "0.34.0", "repository": { "url": "https://github.com/leadbay/mcp", "source": "github", @@ -24,7 +24,7 @@ "registryType": "npm", "registryBaseUrl": "https://registry.npmjs.org", "identifier": "@leadbay/mcp", - "version": "0.33.4", + "version": "0.34.0", "transport": { "type": "stdio" }, @@ -36,7 +36,7 @@ }, { "type": "positional", - "value": "--package=@leadbay/mcp@0.33" + "value": "--package=@leadbay/mcp@0.34" }, { "type": "positional", diff --git a/packages/mcp/src/bin.ts b/packages/mcp/src/bin.ts index fa32b2c6..12600960 100644 --- a/packages/mcp/src/bin.ts +++ b/packages/mcp/src/bin.ts @@ -22,6 +22,7 @@ import { detectClaudeDesktopMode, formatInstallOsLabel, HOSTED_MCP_URL, + HOSTED_MCP_URL_CHATGPT, type DetectedClient, } from "../installer/install-shared.js"; export { detectClaudeDesktopMode, formatInstallOsLabel } from "../installer/install-shared.js"; @@ -1367,7 +1368,7 @@ export async function runInstall(args: string[]): Promise { } else if (c.id === "chatgpt-desktop") { res = { ok: true, - message: "manual setup required; add this MCP URL in ChatGPT Settings > Apps: " + HOSTED_MCP_URL, + message: "manual setup required; add this MCP URL in ChatGPT Settings > Apps: " + HOSTED_MCP_URL_CHATGPT, }; } else if (c.id === "claude-desktop" && c.mode?.dxt && c.supportDir) { const dxtResult = await removeDxtExtension(c.supportDir); diff --git a/packages/mcp/src/http-server.ts b/packages/mcp/src/http-server.ts index 8b8915dd..2587670a 100644 --- a/packages/mcp/src/http-server.ts +++ b/packages/mcp/src/http-server.ts @@ -9,6 +9,7 @@ // Endpoints: // POST /mcp Streamable HTTP transport (current MCP spec) // POST /fr/mcp Compat alias for the README's EU connector URL +// POST /chatgpt/mcp Same, minus every commerce surface (see COMMERCE_FREE_PATHS) // GET /sse, POST /messages Legacy SSE transport (older hosts) // GET /healthz Liveness probe for Fly/Render // @@ -324,9 +325,22 @@ function extractBearer(authHeader: string | undefined): string | undefined { // Build a fresh MCP server bound to the caller's resolved client. One server per // session — keeps tenant isolation explicit and avoids any cross-request state // leaking through the LeadbayClient. +// Paths that must not sell. The OpenAI app directory forbids selling digital +// goods of any kind — credits, top-ups, subscriptions — so the URL we submit +// to it is served WITHOUT leadbay_create_topup_link, leadbay_open_billing_portal, +// or any prose that offers them. Anthropic's directory has no such rule, so +// /mcp is unchanged and Claude users keep the top-up flow. +// +// This is a path, not a clientInfo sniff, on purpose: a reviewer connecting to +// this URL sees a catalog where the tools do not exist, rather than a promise +// that we suppress them. clientInfo is self-reported, unverified, and arrives +// after `instructions` has already been built. +const COMMERCE_FREE_PATHS = new Set(["/chatgpt/mcp"]); + function buildServerFromClient( client: LeadbayClient, - requestTelemetry: TelemetryHandle + requestTelemetry: TelemetryHandle, + resourcePath: string ): Server { const includeWrite = parseWriteEnv(); const includeAdvanced = process.env.LEADBAY_MCP_ADVANCED === "1"; @@ -334,6 +348,7 @@ function buildServerFromClient( version: VERSION, includeWrite, includeAdvanced, + includeCommerce: !COMMERCE_FREE_PATHS.has(resourcePath), logger, telemetry: requestTelemetry, }); @@ -354,7 +369,7 @@ const PRM_PREFIX = "/.well-known/oauth-protected-resource"; // the same single Stargate auth server and the token's `_fr`/`_us` suffix // self-routes tool calls — so `/fr/mcp` behaves identically to `/mcp`. Kept as an // alias (not a redirect) so those users don't 404. -const RESOURCE_PATHS = ["/mcp", "/sse", "/fr/mcp", "/fr/sse"] as const; +const RESOURCE_PATHS = ["/mcp", "/sse", "/fr/mcp", "/fr/sse", "/chatgpt/mcp"] as const; // Public origin of this request. Fly terminates TLS and forwards over http, so // trust x-forwarded-proto; fall back to the request URL (host + scheme). @@ -475,6 +490,7 @@ app.options("*", (c) => { const MCP_BODY_LIMIT = bodyLimit({ maxSize: 1 * 1024 * 1024 }); app.use("/mcp", MCP_BODY_LIMIT); app.use("/fr/mcp", MCP_BODY_LIMIT); // compat alias (see RESOURCE_PATHS) +app.use("/chatgpt/mcp", MCP_BODY_LIMIT); // commerce-free (see COMMERCE_FREE_PATHS) app.use("/messages", MCP_BODY_LIMIT); // Streamable HTTP transport. Stateless mode (no sessionIdGenerator) is the @@ -483,7 +499,7 @@ app.use("/messages", MCP_BODY_LIMIT); // passing `sessionIdGenerator: randomUUID`. async function handleStreamable( c: Context, - resourcePath: "/mcp" | "/fr/mcp" + resourcePath: "/mcp" | "/fr/mcp" | "/chatgpt/mcp" ): Promise { const foreign = rejectForeignOrigin(c); if (foreign) return foreign; @@ -509,7 +525,8 @@ async function handleStreamable( // events are suppressed per-request — product#3879. const server = buildServerFromClient( resolved.client, - await telemetryHandleForRequest(resolved.client) + await telemetryHandleForRequest(resolved.client), + resourcePath ); const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, @@ -566,6 +583,9 @@ app.all("/mcp", (c) => handleStreamable(c, "/mcp")); // Compat alias for the README's published EU connector URL. Same behavior as // /mcp — the token suffix, not the path, selects the region. app.all("/fr/mcp", (c) => handleStreamable(c, "/fr/mcp")); +// The URL submitted to the OpenAI app directory. Identical to /mcp except that +// nothing on it sells — see COMMERCE_FREE_PATHS. +app.all("/chatgpt/mcp", (c) => handleStreamable(c, "/chatgpt/mcp")); // Legacy SSE transport. Two endpoints: GET /sse opens the stream, POST // /messages?sessionId=... feeds JSON-RPC messages in. @@ -624,8 +644,10 @@ async function handleSse(c: Context, resourcePath: "/sse" | "/fr/sse"): Promise< sessionOptedOut: session.suppressed, fallbackEnabled: !session.suppressed, }) - ) + ), + resourcePath ); + await server.connect(transport); const sessionId = transport.sessionId; diff --git a/packages/mcp/src/server-instructions.generated.ts b/packages/mcp/src/server-instructions.generated.ts index a21f022e..12239625 100644 --- a/packages/mcp/src/server-instructions.generated.ts +++ b/packages/mcp/src/server-instructions.generated.ts @@ -17,10 +17,12 @@ export const FRICTION: string = `Problem reports: when the user asks you to repo export const MENTAL_MODEL: string = `How Leadbay works (mental model): Leadbay is a sales inbox, not a queryable database. Each day the user logs back in, a fresh batch of leads is delivered. Batch size is paced by how many leads the user has actually acted on recently — some workflows produce a big stream of smaller prospects, others a narrow stream of bigger ones. Pulling more won't produce more; the user acting on leads (outreach, skips, saves) does.`; // endregion: MENTAL_MODEL -// region: QUOTA_TOPUP (source: snippets/server-instructions/quota-topup.md) -export const QUOTA_TOPUP: string = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options — wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY — they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' — when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits — even WITHOUT re-calling account_status — treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep. +// region: QUOTA_REFRESH (source: snippets/server-instructions/quota-refresh.md) +export const QUOTA_REFRESH: string = `Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done — all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) — OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota — the per-window %/$ gauge (Daily/Weekly/Monthly) it returns — so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it — the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed — the requested email and/or phone_number present — NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action — not after every poll while work is still in progress.`; +// endregion: QUOTA_REFRESH -Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done — all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) — OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota — the per-window %/$ gauge (Daily/Weekly/Monthly) it returns — so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it — the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed — the requested email and/or phone_number present — NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action — not after every poll while work is still in progress.`; +// region: QUOTA_TOPUP (source: snippets/server-instructions/quota-topup.md) +export const QUOTA_TOPUP: string = `Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options — wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY — they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' — when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits — even WITHOUT re-calling account_status — treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep.`; // endregion: QUOTA_TOPUP // region: TRANSIENT_401 (source: snippets/server-instructions/transient-401.md) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 424bb720..f5239556 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -27,6 +27,7 @@ import { setTelemetry, granularReadTools, granularWriteTools, + NO_COMMERCE_TOOL_DESCRIPTIONS, COMPOSITE_FILE_TOOL_NAMES, type BulkTracker, type LeadbayClient, @@ -50,6 +51,7 @@ import { FRICTION, MENTAL_MODEL, QUOTA_TOPUP, + QUOTA_REFRESH, TRIGGERED_BY, TRANSIENT_401, ENRICHMENT_TERMINAL, @@ -352,7 +354,18 @@ export function buildServerInstructions(exposed: Set): string { // the telemetry setting (see leadbay/product#3718 review). parts.push(TRIGGERED_BY); parts.push(MENTAL_MODEL); - parts.push(QUOTA_TOPUP); + // The selling paragraph — only where selling is allowed. Keyed off the tool + // so the instructions never promote a purchase the host forbids, and never + // name a tool this server did not register (the iter-12 invariant the rest of + // this function follows). Not softened anywhere: it is present verbatim or + // absent. + if (has("leadbay_create_topup_link")) { + parts.push(QUOTA_TOPUP); + } + // When to re-render the quota gauge once paid work completes. Says nothing + // about buying, so both surfaces get it — its own snippet precisely so a + // future edit cannot land on one surface and miss the other. + parts.push(QUOTA_REFRESH); // Sits next to QUOTA_TOPUP because both govern the enrichment lifecycle: that // one says when to refresh the quota, this one says when to STOP enriching. // Gated on enrich_titles because it names that tool (#3504: never instruct the @@ -388,9 +401,39 @@ export function buildServerInstructions(exposed: Set): string { return parts.join("\n\n"); } +// Tools that sell, or link to a page that sells. Dropped wholesale on a +// commerce-free surface. See COMMERCE_FREE_NOTE below. +const COMMERCE_TOOL_NAMES = new Set([ + "leadbay_create_topup_link", + "leadbay_open_billing_portal", +]); + interface BuildServerOptions { includeAdvanced?: boolean; includeWrite?: boolean; + /** + * Default true. Set false for a surface whose host forbids selling digital + * goods — the OpenAI app directory: "plugins may conduct commerce only for + * physical goods. Selling digital products or services — including + * subscriptions, digital content, tokens, or credits — is not allowed". + * Anthropic's Software Directory Policy has no equivalent rule, so the + * Claude surface keeps commerce on. + * + * When false, three things go away together — the tools, and every text that + * would promote buying them: + * + * 1. COMMERCE_TOOL_NAMES are not registered. + * 2. Tool descriptions lose their `{{commerce}}` blocks + * (NO_COMMERCE_TOOL_DESCRIPTIONS, emitted by promptforge). + * 3. The QUOTA_TOPUP instruction paragraph is not pushed, and the client's + * QUOTA_EXCEEDED hint drops its two selling sentences. + * + * Nothing is reworded for ChatGPT. Every one of those is a deletion of text + * that is otherwise present verbatim, so the Claude surface keeps selling + * exactly as hard as it does today — asserted byte-for-byte by + * test/unit/commerce-gate.test.ts. + */ + includeCommerce?: boolean; logger?: ToolLogger; bulkTracker?: BulkTracker; // Server version reported on `initialize`. The CLI passes the build-time @@ -611,16 +654,24 @@ export function buildServer( // For composite-file tools (COMPOSITE_FILE_TOOL_NAMES) the field is also // declared as required + uses the stronger MANDATORY description; the // dispatch handler enforces presence by rejecting LAST_PROMPT_REQUIRED. + const includeCommerce = opts.includeCommerce !== false; + client.commerce = includeCommerce; const toolByName = new Map(); for (const t of exposedTools) { - if (!toolByName.has(t.name) && t.name !== "leadbay_login") { - toolByName.set( - t.name, - withTriggeredByMeta(t, { - mandatory: COMPOSITE_FILE_TOOL_NAMES.has(t.name), - }) - ); - } + if (toolByName.has(t.name) || t.name === "leadbay_login") continue; + // COMMERCE_FREE_NOTE: filtered here, after the catalogue arrays are + // merged, because these two are registered in both compositeReadTools + // and granularReadTools. + if (!includeCommerce && COMMERCE_TOOL_NAMES.has(t.name)) continue; + const noCommerce = includeCommerce + ? undefined + : NO_COMMERCE_TOOL_DESCRIPTIONS[t.name]; + toolByName.set( + t.name, + withTriggeredByMeta(noCommerce ? { ...t, description: noCommerce } : t, { + mandatory: COMPOSITE_FILE_TOOL_NAMES.has(t.name), + }) + ); } // Build instructions from the ACTUAL exposed name set so the agent system diff --git a/packages/mcp/test/unit/commerce-free-path.test.ts b/packages/mcp/test/unit/commerce-free-path.test.ts new file mode 100644 index 00000000..4fdc6480 --- /dev/null +++ b/packages/mcp/test/unit/commerce-free-path.test.ts @@ -0,0 +1,125 @@ +/** + * /chatgpt/mcp — the hosted URL we submit to the OpenAI app directory. + * + * Same server, same auth, but nothing on it sells. The gate is a PATH, not a + * clientInfo sniff, so this file proves the path actually reaches + * buildServer({ includeCommerce: false }) rather than trusting the flag exists. + * + * The catalog assertion runs over a REAL socket through the real Hono app and + * the real StreamableHTTP transport. `app.fetch(new Request())` cannot drive + * that transport (it needs Node req/res), and a route that silently fell back + * to the commerce-enabled server would look identical at the seam — the exact + * class of hosted-only regression that went unnoticed for two months when + * per-user stores were dropped from the HTTP entrypoint. + * + * New file — does not modify the existing http-* tests. + */ + +import { describe, it, expect, beforeEach, afterAll, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory } from "../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import type { AddressInfo } from "node:net"; +import { serve } from "@hono/node-server"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { app } from "../../src/http-server.js"; + +const TOKEN = "o.test-token_us"; +const ME = { + method: "GET" as const, + path: "/1.6/users/me", + status: 200, + body: { id: 1, email: "rep@leadbay.test", organization: { id: "org-1" } }, +}; + +beforeEach(() => resetHttpMock()); + +function initRequest(url: string): Request { + return new Request(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }), + }); +} + +describe("/chatgpt/mcp is a registered, discoverable resource", () => { + it("POST with no token → 401 challenge (the route exists; it is not a 404)", async () => { + mockHttp([]); + const res = await app.fetch(initRequest("https://mcp.test/chatgpt/mcp")); + expect(res.status).toBe(401); + expect(res.headers.get("www-authenticate") ?? "").toContain( + 'resource_metadata="https://mcp.test/.well-known/oauth-protected-resource/chatgpt/mcp"' + ); + }); + + it("OAuth protected-resource metadata resolves for the path", async () => { + mockHttp([]); + const res = await app.fetch( + new Request("https://mcp.test/.well-known/oauth-protected-resource/chatgpt/mcp") + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { resource: string }; + // Must echo the requested path, not silently collapse to /mcp — otherwise + // ChatGPT's sign-in fails audience validation. + expect(body.resource).toBe("https://mcp.test/chatgpt/mcp"); + }); +}); + +// ── Real socket, real transport ─────────────────────────────────────────────── + +let listener: ReturnType | undefined; +let baseUrl = ""; + +async function startServer(): Promise { + if (baseUrl) return baseUrl; + await new Promise((resolve) => { + listener = serve({ fetch: app.fetch, port: 0, hostname: "127.0.0.1" }, (info: AddressInfo) => { + baseUrl = `http://127.0.0.1:${info.port}`; + resolve(); + }); + }); + return baseUrl; +} + +afterAll(async () => { + await new Promise((resolve) => { + if (!listener) return resolve(); + listener.close(() => resolve()); + }); +}); + +async function listToolNames(path: string): Promise> { + const base = await startServer(); + const transport = new StreamableHTTPClientTransport(new URL(`${base}${path}`), { + requestInit: { headers: { authorization: `Bearer ${TOKEN}` } }, + }); + const client = new Client({ name: "commerce-path-test", version: "0.0.1" }, {}); + try { + await client.connect(transport); + return new Set((await client.listTools()).tools.map((t) => t.name)); + } finally { + await client.close().catch(() => {}); + } +} + +describe("hosted catalog over the wire", () => { + it("/mcp still serves the commerce tools", async () => { + mockHttp([ME, ME, ME, ME]); + const names = await listToolNames("/mcp"); + expect(names).toContain("leadbay_create_topup_link"); + expect(names).toContain("leadbay_open_billing_portal"); + expect(names).toContain("leadbay_account_status"); + }); + + it("/chatgpt/mcp serves the same catalog MINUS the commerce tools", async () => { + mockHttp([ME, ME, ME, ME]); + const names = await listToolNames("/chatgpt/mcp"); + expect(names).not.toContain("leadbay_create_topup_link"); + expect(names).not.toContain("leadbay_open_billing_portal"); + // Everything else is still there — this is a gate, not a crippled server. + expect(names).toContain("leadbay_account_status"); + expect(names).toContain("leadbay_pull_leads"); + }); +}); diff --git a/packages/mcp/test/unit/commerce-gate.test.ts b/packages/mcp/test/unit/commerce-gate.test.ts new file mode 100644 index 00000000..2f8767e2 --- /dev/null +++ b/packages/mcp/test/unit/commerce-gate.test.ts @@ -0,0 +1,220 @@ +/** + * buildServer({ includeCommerce: false }) — the surface served to a host whose + * directory forbids selling digital goods AND forbids promoting the purchase + * (the OpenAI app directory). + * + * Four things go away together. Dropping the tools alone was not enough: the + * instructions, the tool descriptions and the QUOTA_EXCEEDED hint all still + * pushed the user toward a top-up, which is the "promote upgrades" clause. + * + * 1. leadbay_create_topup_link / leadbay_open_billing_portal unregistered + * 2. `{{commerce}}` blocks deleted from tool descriptions + * 3. the QUOTA_TOPUP instruction paragraph not pushed + * 4. the client's QUOTA_EXCEEDED hint drops its two selling sentences + * + * Every one is a DELETION. Nothing is reworded for ChatGPT, so the Claude + * surface keeps selling exactly as hard as it does today — which is the other + * half of what this file asserts, and the half most likely to rot. + * + * New file — does not modify server.test.ts. + */ + +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { mockHttp, resetHttpMock, httpsMockFactory } from "../harness.js"; + +vi.mock("node:https", () => httpsMockFactory()); + +import { LeadbayClient } from "@leadbay/core"; +import { buildServer } from "../../src/server.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; + +const BASE = "https://api-us.leadbay.app"; +const COMMERCE_TOOLS = ["leadbay_create_topup_link", "leadbay_open_billing_portal"]; + +// Phrases whose only job is to move the user toward paying. None may reach a +// commerce-free agent, from any surface. +const SELLING_PHRASES = [ + "Top-ups always beat waiting", + "top up now (I can generate the link)", + "Offer the top-up link", + "Stripe checkout URL", + "top-up link", + "wait-or-top-up offer", + "OFFER it on every quota wall", +]; + +async function connect(includeCommerce?: boolean) { + const client = new LeadbayClient(BASE, "u.test-token"); + const server = buildServer(client, { + includeWrite: true, + includeAdvanced: true, + ...(includeCommerce === undefined ? {} : { includeCommerce }), + }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const mcpClient = new Client({ name: "test", version: "0.0.1" }, {}); + await Promise.all([ + server.connect(serverTransport), + mcpClient.connect(clientTransport), + ]); + return { server, mcpClient, client }; +} + +const instructionsOf = (server: unknown) => (server as any)._instructions as string; + +// The QUOTA_EXCEEDED envelope the client hands the agent, built the real way +// (through mapErrorResponse with a genuine 429 + Retry-After). +const quotaError = (client: LeadbayClient): any => + (client as any).mapErrorResponse(429, "{}", "/x", { "retry-after": "30" }); + +/** + * Deletion-only check: `part` must be reachable from `whole` by removing + * characters and nothing else. Any reworded, softened or newly written + * character breaks the subsequence, which is exactly what must never happen. + */ +function isDeletionOf(part: string, whole: string): boolean { + // Index by UTF-16 unit, not by code point: these descriptions carry astral + // characters (emoji in the follow-up badges), and `for..of` would yield a + // two-unit code point that can never equal a one-unit `part[i]`. + let i = 0; + for (let j = 0; j < whole.length && i < part.length; j++) { + if (part[i] === whole[j]) i++; + } + return i === part.length; +} + +beforeEach(() => resetHttpMock()); + +describe("commerce gate — the Claude surface still sells, unchanged", () => { + it("exposes both commerce tools by default", async () => { + mockHttp([]); + const { mcpClient } = await connect(); + const names = new Set((await mcpClient.listTools()).tools.map((t) => t.name)); + for (const n of COMMERCE_TOOLS) expect(names).toContain(n); + }); + + it("keeps every selling phrase in the instructions and descriptions", async () => { + mockHttp([]); + const { server, mcpClient } = await connect(); + const corpus = [ + instructionsOf(server), + ...(await mcpClient.listTools()).tools.map((t) => t.description ?? ""), + ].join("\n"); + for (const phrase of SELLING_PHRASES) { + expect(corpus, `Claude must keep: ${phrase}`).toContain(phrase); + } + }); + + it("keeps the two selling sentences in the QUOTA_EXCEEDED hint", () => { + const client = new LeadbayClient(BASE, "u.test-token"); + expect(client.commerce).toBe(true); + const err: any = quotaError(client); + expect(err.hint).toContain("OR top up AI credits"); + expect(err.hint).toContain("leadbay_create_topup_link"); + expect(err.hint).toContain("app.leadbay.ai → Billing"); + }); + + it("explicit includeCommerce:true is identical to omitting it", async () => { + mockHttp([]); + const a = await connect(); + const b = await connect(true); + const listA = (await a.mcpClient.listTools()).tools.map((t) => `${t.name}\n${t.description}`); + const listB = (await b.mcpClient.listTools()).tools.map((t) => `${t.name}\n${t.description}`); + expect(listB).toEqual(listA); + expect(instructionsOf(b.server)).toBe(instructionsOf(a.server)); + }); +}); + +describe("commerce gate — includeCommerce:false", () => { + it("registers neither commerce tool, and drops nothing else", async () => { + mockHttp([]); + const withNames = new Set( + (await (await connect(true)).mcpClient.listTools()).tools.map((t) => t.name) + ); + const withoutNames = new Set( + (await (await connect(false)).mcpClient.listTools()).tools.map((t) => t.name) + ); + expect([...withNames].filter((n) => !withoutNames.has(n)).sort()).toEqual( + [...COMMERCE_TOOLS].sort() + ); + expect([...withoutNames].filter((n) => !withNames.has(n))).toEqual([]); + }); + + it("no selling phrase survives anywhere the agent reads", async () => { + mockHttp([]); + const { server, mcpClient } = await connect(false); + const corpus = [ + instructionsOf(server), + ...(await mcpClient.listTools()).tools.map((t) => `${t.name}\n${t.description ?? ""}`), + ].join("\n"); + for (const phrase of SELLING_PHRASES) { + expect(corpus, `must not reach ChatGPT: ${phrase}`).not.toContain(phrase); + } + for (const n of COMMERCE_TOOLS) expect(corpus).not.toContain(n); + }); + + it("the QUOTA_EXCEEDED hint drops its selling sentences but keeps the rest", async () => { + mockHttp([]); + const { client } = await connect(false); + expect(client.commerce).toBe(false); + const err: any = quotaError(client); + expect(err.hint).not.toContain("top up AI credits"); + expect(err.hint).not.toContain("leadbay_create_topup_link"); + expect(err.hint).not.toContain("Billing"); + // Still tells the agent what to do. + expect(err.hint).toContain("Wait 30s before retrying"); // retry-after preserved + expect(err.hint).toContain("which resource window"); + expect(err.hint).toContain("RETRY the original operation"); + }); + + it("deletes rather than rewords — every description is a pure deletion of Claude's", async () => { + mockHttp([]); + const withDescs = new Map( + (await (await connect(true)).mcpClient.listTools()).tools.map((t) => [t.name, t.description ?? ""]) + ); + let gated = 0; + for (const t of (await (await connect(false)).mcpClient.listTools()).tools) { + const full = withDescs.get(t.name)!; + const gatedDesc = t.description ?? ""; + expect( + isDeletionOf(gatedDesc, full), + `${t.name}: the commerce-free description is not a pure deletion of the Claude one` + ).toBe(true); + if (gatedDesc !== full) gated++; + } + // Guard the guard: if the marker stopped matching anything, the check above + // would pass trivially on N identical strings. + expect(gated, "expected some descriptions to actually lose a commerce block").toBeGreaterThan(0); + }); + + it("the instructions are a pure deletion of Claude's too", async () => { + mockHttp([]); + const full = instructionsOf((await connect(true)).server); + const gated = instructionsOf((await connect(false)).server); + expect(gated).not.toBe(full); + expect(isDeletionOf(gated, full)).toBe(true); + }); + + it("keeps the guidance that is about NOT gate-keeping a user who paid elsewhere", async () => { + mockHttp([]); + const { server, mcpClient } = await connect(false); + const desc = + (await mcpClient.listTools()).tools.find((t) => t.name === "leadbay_account_status") + ?.description ?? ""; + expect(desc).toContain("A stale quota snapshot is never a reason to gate-keep"); + // And the neutral half of the quota instruction still ships. + expect(instructionsOf(server)).toContain("Show the refreshed quota AFTER a paid action"); + }); + + it("calling a dropped tool errors — it is genuinely gone, not just hidden", async () => { + mockHttp([]); + const { mcpClient } = await connect(false); + const res: any = await mcpClient.callTool({ + name: "leadbay_create_topup_link", + arguments: {}, + }); + expect(res.isError).toBe(true); + expect(res.content[0].text).toContain("Unknown Leadbay tool"); + expect(res.content[0].text).not.toContain("leadbay_open_billing_portal"); + }); +}); diff --git a/packages/promptforge/snippets/next-steps/scan-portfolio-signals.md b/packages/promptforge/snippets/next-steps/scan-portfolio-signals.md index 3626ad9e..1cb1cbd1 100644 --- a/packages/promptforge/snippets/next-steps/scan-portfolio-signals.md +++ b/packages/promptforge/snippets/next-steps/scan-portfolio-signals.md @@ -12,7 +12,9 @@ almost always "turn the matched leads into a campaign." | Zero matches but leads were researched | "Widen the query (synonyms) or relax `since`" | leadbay_scan_portfolio_signals(query: "", since: omit-or-earlier) | | `truncated_at` set | "Scan only covered N — narrow scope or raise the cap" | leadbay_scan_portfolio_signals({city / set_filter}) or raise `max_leads` | | One standout matched lead | "Open that lead's full brief" | leadbay_research_lead_by_id(leadId) | +{{commerce}} | `quota_exceeded` | "Wait for reset OR top up to finish the scan" | leadbay_create_topup_link | +{{/commerce}} NEVER report leads in `not_researched` as if they had no matching signal — they were never read. Distinguish "no signal X found" (researched, no match) from diff --git a/packages/promptforge/snippets/rendering/scan-portfolio-signals.md b/packages/promptforge/snippets/rendering/scan-portfolio-signals.md index 96795393..37238390 100644 --- a/packages/promptforge/snippets/rendering/scan-portfolio-signals.md +++ b/packages/promptforge/snippets/rendering/scan-portfolio-signals.md @@ -39,7 +39,9 @@ A single italic line summarising coverage: qualify them and re-scan (see NEXT STEPS). - When `truncated_at` is set, add: `_Coverage partial — only the first leads were scanned; narrow the scope or raise max_leads._` +{{commerce}} - When `quota_exceeded` is true, add the wait-or-top-up offer. +{{/commerce}} **Hide:** raw `lead_id` in prose (use it only for the campaign call), `_meta`, empty arrays, any freshness field. NEVER present `not_researched` leads as diff --git a/packages/promptforge/snippets/server-instructions/quota-refresh.md b/packages/promptforge/snippets/server-instructions/quota-refresh.md new file mode 100644 index 00000000..e83f7334 --- /dev/null +++ b/packages/promptforge/snippets/server-instructions/quota-refresh.md @@ -0,0 +1 @@ +Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done — all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) — OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota — the per-window %/$ gauge (Daily/Weekly/Monthly) it returns — so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it — the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed — the requested email and/or phone_number present — NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action — not after every poll while work is still in progress. diff --git a/packages/promptforge/snippets/server-instructions/quota-topup.md b/packages/promptforge/snippets/server-instructions/quota-topup.md index d920b524..ef9d2218 100644 --- a/packages/promptforge/snippets/server-instructions/quota-topup.md +++ b/packages/promptforge/snippets/server-instructions/quota-topup.md @@ -1,3 +1 @@ Quota & top-ups: when a tool returns QUOTA_EXCEEDED / 429, the user has TWO options — wait for the window reset (daily / weekly / monthly resets shown in leadbay_account_status), OR top up AI credits (top-ups clear the throttle IMMEDIATELY — they are not subject to the same window). Always offer BOTH options; default-recommending 'wait until tomorrow' is wrong when a 30-second top-up unblocks the same call. If the host exposes leadbay_create_topup_link, OFFER it on every quota wall: 'Want me to generate a top-up link?' — when the user says yes, call leadbay_create_topup_link and surface the returned Stripe URL as a clickable link for the user to open in their browser. (Sibling leadbay_open_billing_portal is for ongoing subscription changes, not one-shot top-ups.) AFTER the user has topped up: do NOT keep refusing operations. A top-up invalidates every prior 429 and every stale 'you're at your quota' snapshot. The moment the user signals they topped up / bought credits / added credits — even WITHOUT re-calling account_status — treat the previous quota state as void and RETRY the originally failed call. (Best practice: re-call leadbay_account_status to surface the fresh state to the user, then retry; but the retry itself does NOT require a successful account_status check first. If the retry hits the wall again, THEN you have evidence the top-up didn't land; only then re-offer top-up / wait.) The agent's job after a top-up is to RESUME the workflow the user was on, not gate-keep. - -Show the refreshed quota AFTER a paid action has actually COMPLETED: when leadbay_bulk_enrich_status reports the job done — all_done, OR a plateau you've decided is terminal (overall_progress.done stopped climbing across spaced polls, so some contacts are unresolvable and all_done stays false) — OR a top-up the user confirmed landed, call leadbay_account_status once and render the refreshed quota — the per-window %/$ gauge (Daily/Weekly/Monthly) it returns — so the user sees where they now stand. Wait for genuine completion: leadbay_enrich_contacts only LAUNCHES an async reveal (it returns a hint to check back in ~60s), so do NOT refresh quota right after it — the usage isn't reflected yet. For that single-contact flow, refresh only once a re-read of the lead's contacts (leadbay_research_lead_by_id; leadbay_get_contacts where exposed) shows the REQUESTED channel actually landed — the requested email and/or phone_number present — NOT enrichment.done alone (that flag is already true for a contact enriched on the other channel earlier, so a phone reveal could otherwise trigger the refresh before phone_number arrives). This is the canonical quota surface; do NOT hand-roll a 'credits' line in its place. Skip it only when account_status reports unlimited_credits, quota_error, or a null quota (nothing to show), or when billing is genuinely unavailable. Do it ONCE per completed action — not after every poll while work is still in progress. diff --git a/packages/promptforge/src/assembler.ts b/packages/promptforge/src/assembler.ts index 5b192903..c1cc443a 100644 --- a/packages/promptforge/src/assembler.ts +++ b/packages/promptforge/src/assembler.ts @@ -2,6 +2,11 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; import { join, relative } from "node:path"; import { parseTemplate, type Frontmatter, type ParsedTemplate, type Routing, FrontmatterError } from "./frontmatter.js"; import { resolveSnippets } from "./snippets.js"; +import { + hasCommerceMarkers, + renderCommerce, + validateCommerceMarkers, +} from "./commerce.js"; /** * Generate the `## WHEN TO USE` block from a tool's routing @@ -70,6 +75,13 @@ export interface AssembledArtifact { frontmatter: Frontmatter; body: string; sourcePath: string; + /** + * The same description with the `{{commerce}}` blocks deleted. Set only when + * the template carries the marker. Emitted alongside `body` so the MCP server + * can serve it on a host that forbids promoting a purchase. Nothing is + * reworded — see src/commerce.ts. + */ + noCommerceBody?: string; } export interface AssembleResult { @@ -239,10 +251,16 @@ export function assemble(opts: AssembleOptions): AssembleResult { ? applyDescriptionHeader(parsed.frontmatter, resolved) : resolved; + const markerError = validateCommerceMarkers(finalBody); + if (markerError) throw new AssemblyError(markerError, path); + const artifact: AssembledArtifact = { frontmatter: parsed.frontmatter, - body: finalBody.trimEnd() + "\n", + body: renderCommerce(finalBody, "with").trimEnd() + "\n", sourcePath: path, + ...(hasCommerceMarkers(finalBody) && expectedKind === "tool-description" + ? { noCommerceBody: renderCommerce(finalBody, "without").trimEnd() + "\n" } + : {}), }; if (expectedKind === "prompt") { diff --git a/packages/promptforge/src/commerce.ts b/packages/promptforge/src/commerce.ts new file mode 100644 index 00000000..5c341350 --- /dev/null +++ b/packages/promptforge/src/commerce.ts @@ -0,0 +1,90 @@ +// `{{commerce}}` — mark prose that only exists where selling is allowed. +// +// The OpenAI app directory forbids promoting upgrades or linking to a checkout; +// Anthropic's directory has no such rule. So a handful of paragraphs must be +// present on the Claude surface and absent on the ChatGPT one. +// +// {{commerce}} +// **Top-ups always beat waiting.** … +// {{/commerce}} +// +// This marker DELETES; it never substitutes. There is no second, softened +// wording anywhere — the commerce-free rendering is the same text minus the +// marked blocks, and the default rendering is byte-for-byte what the template +// would produce with the marker lines removed. Claude's prompts keep selling +// exactly as hard as they do today. +// +// Two shapes: +// +// Block — each marker alone on its line; the lines between them go together. +// Inline — both markers inside one line. The span must carry its own leading +// space INSIDE the markers, `resets{{commerce}} (or top up){{/commerce}}.`, +// so deleting it leaves `resets.` and not a double space. + +export type CommerceMode = "with" | "without"; + +const TAG = "commerce"; +const MARKER = new RegExp(`\\{\\{/?${TAG}\\}\\}`, "g"); +// A marker alone on its line takes the line's newline with it, so the kept +// block reads exactly as if the markers had never been written. +const OWN_LINE_MARKER = new RegExp(`\\{\\{/?${TAG}\\}\\}\\n`, "g"); +// A deleted block takes its closing newline too. +const BLOCK = new RegExp(`\\{\\{${TAG}\\}\\}[\\s\\S]*?\\{\\{/${TAG}\\}\\}\\n?`, "g"); + +export function hasCommerceMarkers(body: string): boolean { + return MARKER.test(body); +} + +/** + * Reject a template whose markers do not pair up or that nests one pair inside + * another. Both would leak a literal `{{commerce}}` into a shipped description, + * which is the one failure mode nothing downstream catches. + */ +export function validateCommerceMarkers(body: string): string | null { + const open = (body.match(new RegExp(`\\{\\{${TAG}\\}\\}`, "g")) ?? []).length; + const close = (body.match(new RegExp(`\\{\\{/${TAG}\\}\\}`, "g")) ?? []).length; + if (open !== close) { + return `unbalanced {{${TAG}}} markers: ${open} opening, ${close} closing`; + } + for (const mode of ["with", "without"] as const) { + const leaked = renderCommerce(body, mode).match(MARKER); + if (leaked) { + return `{{${TAG}}} markers survive the "${mode}" rendering (nested pairs?): ${leaked[0]}`; + } + } + return null; +} + +export function renderCommerce(body: string, mode: CommerceMode): string { + if (!hasCommerceMarkers(body)) return body; + if (mode === "without") { + return deleteBlocks(body); + } + // Keep the content, drop the markers — own-line pairs first so paragraph + // spacing survives untouched, then whatever is left is inline. + return body.replace(OWN_LINE_MARKER, "").replace(MARKER, ""); +} + +function deleteBlocks(body: string): string { + const lines = body.split("\n"); + const out: string[] = []; + let dropping = false; + for (let i = 0; i < lines.length; i++) { + const marker = lines[i].trim(); + if (dropping) { + if (marker === `{{/${TAG}}}`) { + dropping = false; + // The block stood alone between blank lines — take one of them, so the + // paragraphs that survive end up one blank line apart, not two. + if (out[out.length - 1] === "" && lines[i + 1] === "") i++; + } + continue; + } + if (marker === `{{${TAG}}}`) { + dropping = true; + continue; + } + out.push(lines[i].replace(BLOCK, "")); + } + return out.join("\n"); +} diff --git a/packages/promptforge/src/emit.ts b/packages/promptforge/src/emit.ts index 3e5708ae..2373421c 100644 --- a/packages/promptforge/src/emit.ts +++ b/packages/promptforge/src/emit.ts @@ -155,7 +155,22 @@ export function emit(result: AssembleResult): EmitOutput { toolParts.push(` ${t.frontmatter.name},\n`); } toolParts.push("} as const;\n\n"); - toolParts.push("export type ToolDescriptionName = keyof typeof TOOL_DESCRIPTIONS;\n"); + toolParts.push("export type ToolDescriptionName = keyof typeof TOOL_DESCRIPTIONS;\n\n"); + + // Same descriptions with the {{commerce}} blocks deleted — nothing reworded. + // The MCP server swaps these in on a host that forbids promoting a purchase. + // Tools absent from the map carry no commerce prose and are used as-is. + toolParts.push( + "// Descriptions with the {{commerce}} blocks deleted (no rewording). The MCP\n" + + "// server swaps these in when commerce is gated off; tools absent from this\n" + + "// map read identically on both surfaces.\n", + ); + toolParts.push("export const NO_COMMERCE_TOOL_DESCRIPTIONS: Record = {\n"); + for (const t of result.toolDescriptions) { + if (!t.noCommerceBody) continue; + toolParts.push(` ${t.frontmatter.name}: \`${escapeBacktick(t.noCommerceBody)}\`,\n`); + } + toolParts.push("};\n"); return { promptsModule: promptParts.join(""), diff --git a/packages/promptforge/src/index.ts b/packages/promptforge/src/index.ts index 3d653744..8250c1d4 100644 --- a/packages/promptforge/src/index.ts +++ b/packages/promptforge/src/index.ts @@ -12,6 +12,12 @@ export { export type { Frontmatter, PromptArgument, ParsedTemplate } from "./frontmatter.js"; export { resolveSnippets, listSnippetsReferenced, SnippetError } from "./snippets.js"; +export { + hasCommerceMarkers, + renderCommerce, + validateCommerceMarkers, +} from "./commerce.js"; +export type { CommerceMode } from "./commerce.js"; export { assemble, AssemblyError } from "./assembler.js"; export type { AssembledArtifact, AssembleResult, AssembleOptions } from "./assembler.js"; diff --git a/packages/promptforge/test/commerce-markers.test.ts b/packages/promptforge/test/commerce-markers.test.ts new file mode 100644 index 00000000..52fbda35 --- /dev/null +++ b/packages/promptforge/test/commerce-markers.test.ts @@ -0,0 +1,139 @@ +/** + * `{{commerce}}` — the marker that deletes purchase-promoting prose on a host + * that forbids promoting it. + * + * The load-bearing property is that the DEFAULT rendering is byte-identical to + * the template with the marker lines simply removed. Claude's prompts must not + * shift because a template learned that one of its paragraphs is optional. The + * marker only ever deletes — there is no second, softened wording to drift. + * + * New file — does not modify assembler.test.ts. + */ + +import { describe, it, expect } from "vitest"; +import { + hasCommerceMarkers, + renderCommerce, + validateCommerceMarkers, +} from "../src/commerce.js"; + +describe("renderCommerce — paragraph blocks", () => { + const body = [ + "Intro paragraph.", + "", + "{{commerce}}", + "Offer the top-up link.", + "", + "A second selling paragraph.", + "{{/commerce}}", + "", + "Closing paragraph.", + "", + ].join("\n"); + + it('"with" reads exactly as if the markers were never written', () => { + expect(renderCommerce(body, "with")).toBe( + [ + "Intro paragraph.", + "", + "Offer the top-up link.", + "", + "A second selling paragraph.", + "", + "Closing paragraph.", + "", + ].join("\n") + ); + }); + + it('"without" deletes the block and leaves no blank-line crater', () => { + expect(renderCommerce(body, "without")).toBe( + ["Intro paragraph.", "", "Closing paragraph.", ""].join("\n") + ); + }); +}); + +describe("renderCommerce — inline spans", () => { + // The leading space lives INSIDE the markers so deleting the span does not + // leave "resets , and". + const body = "…when it resets{{commerce}} (and to offer the top-up){{/commerce}}, and after that."; + + it('"with" keeps the span and its authored spacing', () => { + expect(renderCommerce(body, "with")).toBe( + "…when it resets (and to offer the top-up), and after that." + ); + }); + + it('"without" leaves clean prose', () => { + expect(renderCommerce(body, "without")).toBe("…when it resets, and after that."); + }); +}); + +describe("renderCommerce — table rows", () => { + const body = [ + "| a | b |", + "{{commerce}}", + "| top up | leadbay_create_topup_link |", + "{{/commerce}}", + "| other | leadbay_account_status |", + "", + ].join("\n"); + + it("keeps the table contiguous in both renderings", () => { + expect(renderCommerce(body, "with")).toBe( + ["| a | b |", "| top up | leadbay_create_topup_link |", "| other | leadbay_account_status |", ""].join("\n") + ); + expect(renderCommerce(body, "without")).toBe( + ["| a | b |", "| other | leadbay_account_status |", ""].join("\n") + ); + }); +}); + +describe("renderCommerce — it only ever deletes", () => { + it("the commerce-free output is always a subsequence of the default one", () => { + const bodies = [ + "a\n\n{{commerce}}\nb\n{{/commerce}}\n\nc\n", + "x{{commerce}} y{{/commerce}} z", + "| r |\n{{commerce}}\n| s |\n{{/commerce}}\n", + ]; + for (const body of bodies) { + const full = renderCommerce(body, "with"); + const gated = renderCommerce(body, "without"); + let i = 0; + for (let j = 0; j < full.length && i < gated.length; j++) { + if (gated[i] === full[j]) i++; + } + expect(i, `not a pure deletion: ${JSON.stringify(body)}`).toBe(gated.length); + } + }); + + it("a body with no markers is returned verbatim, blank-line runs included", () => { + const body = "One.\n\n\n\nTwo.\n"; + expect(renderCommerce(body, "with")).toBe(body); + expect(renderCommerce(body, "without")).toBe(body); + expect(hasCommerceMarkers(body)).toBe(false); + }); +}); + +describe("validateCommerceMarkers", () => { + it("accepts balanced markers", () => { + expect(validateCommerceMarkers("a{{commerce}}b{{/commerce}}c")).toBeNull(); + expect(validateCommerceMarkers("no markers here")).toBeNull(); + }); + + it("rejects an unclosed block", () => { + expect(validateCommerceMarkers("a{{commerce}}b")).toMatch(/unbalanced/); + }); + + it("rejects an unopened block", () => { + expect(validateCommerceMarkers("a{{/commerce}}b")).toMatch(/unbalanced/); + }); + + it("rejects a nested pair, which balances but leaks a marker", () => { + // Counts pair up (2 open, 2 close) so the arithmetic check passes; only + // rendering exposes the stray {{/commerce}} left behind. + expect( + validateCommerceMarkers("{{commerce}}a{{commerce}}b{{/commerce}}c{{/commerce}}") + ).toMatch(/survive the "without" rendering/); + }); +}); diff --git a/packages/promptforge/tool-descriptions/composite/account-status.md.tmpl b/packages/promptforge/tool-descriptions/composite/account-status.md.tmpl index b5c11a3d..3878aeb7 100644 --- a/packages/promptforge/tool-descriptions/composite/account-status.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/account-status.md.tmpl @@ -39,11 +39,13 @@ annotations: --- Show the user's account state — admin rights, language, last-active lens, quota usage across daily/weekly/monthly windows, and whether the org's intelligence is mid-regeneration. **Show quota the way the web app does — a percentage-used + dollar-spend gauge per window, never raw "credits".** Each window in `quota..spend[]` carries `current_units` / `max_units` in dollar_cents (% used = the ratio, $ = `/100`); the `quota..resources[]` list gives the per-resource usage breakdown (`count`, plus `max_units` when a per-resource cap exists). **Pre-check the `LENS_EXTRA_REFILL` resource here before calling `leadbay_extend_lens`** — look in **`quota.org.resources[]`** first (admins), and fall back to **`quota.user.resources[]`** when `quota.org` is absent (non-admin callers only get the `user` group), matching the resource type **case-insensitively** (it may arrive as `LENS_EXTRA_REFILL` or `lens_extra_refill`). Its full requested batch must fit into the remaining daily quota or the call is rejected outright. Quota windows also hint at the user's consumption pace: heavy recent activity (ai_rescore / web_fetch near their window limits) is a signal that Leadbay will deliver a larger fresh batch next time the user logs back in, since batch size is paced by real consumption. +{{commerce}} **Top-ups always beat waiting.** When a quota window is hit, the user has two options: wait for the window reset (`resets_at` in each quota entry) OR top up AI credits. Top-ups clear the throttle IMMEDIATELY; they are not subject to the same window. When you tell the user about a 429 / quota exhaustion, ALWAYS surface both options — "wait until " or "top up now (I can generate the link)" — and let them pick. Never default-recommend "wait until tomorrow" when a 30-second top-up unblocks the same operation. **Offer the top-up link via `leadbay_create_topup_link`.** When the user accepts the top-up offer, call `leadbay_create_topup_link` and surface the returned Stripe checkout URL as a clickable link. The user completes payment in their browser; nothing is charged just by generating the URL. For ongoing subscription changes (plan upgrade / payment method), use `leadbay_open_billing_portal` instead. +{{/commerce}} -**After a user tops up, do NOT keep refusing — RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL `leadbay_account_status` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first — a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet. If the retry hits the wall again, only then re-offer top-up / wait. **A stale quota snapshot is never a reason to gate-keep a topped-up user.** +**After a user tops up, do NOT keep refusing — RETRY.** If the user signals they topped up / bought credits / added credits, the previous QUOTA_EXCEEDED is invalidated the moment the Stripe webhook lands. RE-CALL `leadbay_account_status` to pick up the new state AND retry the originally failed call. The retry itself does not require a successful account_status check first — a topped-up user has cleared the throttle whether or not your cached snapshot reflects it yet.{{commerce}} If the retry hits the wall again, only then re-offer top-up / wait.{{/commerce}} **A stale quota snapshot is never a reason to gate-keep a topped-up user.** **`notifications` block.** The response now includes a top-level `notifications` array listing background work the user (or agent) initiated that has since completed (`bulk_enrich`, `bulk_qualify`, `import`). These are signals to revise prior agent outputs the just-finished work might have made stale — they're NOT a pending-task list for the user. After revising (or confirming nothing is affected), call `leadbay_acknowledge_notification(notification_id)`. Full handling protocol below. @@ -55,6 +57,6 @@ Show the user's account state — admin rights, language, last-active lens, quot --- -{{include:headers/tool-when-to-use}} at the start of a session to know what the agent can/can't do, after a 429 to explain to the user which resource window was exhausted and when it resets (and to offer the top-up alternative), and after the user signals a top-up so the agent can resume the interrupted workflow. +{{include:headers/tool-when-to-use}} at the start of a session to know what the agent can/can't do, after a 429 to explain to the user which resource window was exhausted and when it resets{{commerce}} (and to offer the top-up alternative){{/commerce}}, and after the user signals a top-up so the agent can resume the interrupted workflow. {{include:headers/tool-when-not-to-use}} as a pre-flight gate before bulk ops — operations themselves return 429; this tool is for context, not gating. And: a recent quota snapshot showing "exhausted" is NOT a reason to refuse a write call when the user has just topped up — re-call this tool first, then proceed. diff --git a/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl b/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl index c99af40c..65d4cb64 100644 --- a/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl +++ b/packages/promptforge/tool-descriptions/composite/scan-portfolio-signals.md.tmpl @@ -81,9 +81,11 @@ date are kept (a missing date is not evidence the event is old). date + description). Feed the matched `lead_id`s straight into `leadbay_add_leads_to_campaign` / `leadbay_create_campaign`. +{{commerce}} On a 429 mid-scan, partial `matched` is returned with `quota_exceeded: true` — offer the user wait-for-reset OR a top-up link (both unblock; a top-up clears the throttle immediately). +{{/commerce}} {{include:gates/signal-honesty}}