Skip to main content

Agency-scoped MCP keys

Last updated: 2026-10-07 The client-facing MCP server (app/api/mcp) historically minted only location-scoped keys: each key is hard-bound to one GHL sub-account and every tool call reads that locationId from the verified key context, never from the client. Agency keys add a second scope: one key that can reach any sub-account under a single GHL company (agency), for an external “super agent” (e.g. Claude Desktop) that works across the whole agency. Both location and agency credentials require the explicit credit_reports scope for raw report access (FM-2781). Standard read access retains summaries and diagnostics.

Data model

mcpApiKeys (convex/schemas/mcp.ts) gained two optional fields, so existing rows need no migration:
  • scopeType: "location" | "agency" — absent means location.
  • companyId — set only on agency keys; the sole authorization input for cross-location access.
Agency keys are stored with locationId set to the sentinel agency:<companyId>. This keeps locationId required and lets the existing by_location / by_location_revokedAt indexes (and the per-scope active-key limit) work with no new index — the same sentinel pattern getAgencyInstallationForLocationInternal uses. The sentinel is never parsed for authorization; the explicit companyId field is.

Request flow

  1. verifyMcpKey (lib/mcp/keys.ts) returns scopeType + companyId. An agency key with no companyId fails closed (returns null).
  2. The transport route (app/api/mcp/[transport]/route.ts) passes both through authInfo.extra.
  3. wrapTool (lib/mcp/tools.ts) strips any client-supplied locationId from the args and calls resolveEffectiveLocation:
    • Location key: always its own bound location; the client locationId is ignored (the historical tenancy invariant).
    • Agency key: the client MUST pass a locationId; it is verified against ghlInstallations (internal.ghlInstallations.getLocationCompanyInternal) and rejected unless installation.companyId === key.companyId.
  4. Tool calls are audit-logged (mcpToolCalls) under the resolved location, so per-sub-account attribution survives for agency keys.
A list_locations tool lets an agency agent discover which sub-accounts a key can reach (for a location key it returns just the one bound location).

resolve_location (Support tenancy, 2026-08-25)

resolve_location is the agency-key tool Support calls from an operator Slack event to get one Product Location ID before any applicant lookup. It never guesses: a successful answer includes status: "ok", one locationId, locationName (or null when the installation has no stored name), and matchedBy (slack_channel, slack_user, or email). Check the returned location name with the operator before reading applicant records. A miss is not_found; ambiguity is not_unique with a bounded candidate list. Resolution order (convex/mcpLocationResolve.ts, marketplace-safe internal reads — no support-actor gate; authorization is the agency key’s companyId):
  1. Admin-linked Slack channel (locationSlackChannels, Admin → Locations) — authoritative; a human tied that channel to that sub-account.
  2. Only when the channel yields nothing reachable: exact operator email (crmUserScopeCache.by_email_normalized, slackInvites.by_email_normalized; canonicalized to lowercase) or user id (crmUserScopeCache.by_user_id) on an installed sub-account.
The name comes from the same company-owned installation read used to authorize the match, without a second list_locations call. Identity hints are still combined to detect ambiguity: if email and user id agree, matchedBy reports email (the first matching identity signal); if they disagree, the result stays not_unique. Every candidate is intersected with the key’s company membership (same rule as every other agency tool — sentinel rows and transferred-out locations are skipped), so channel links or identity rows pointing outside the company count as a miss rather than a match or a leak. There is deliberately no fuzzy name matching here; the sender/channel-name fallback in the Support Command Center stays where it is. Exact-key index scans run to completion before returning a unique tenant; they stop as soon as two company-owned candidates prove ambiguity. This prevents foreign or duplicate rows from hiding a later company candidate. The company membership scan remains capped at 2000 and not_unique output remains bounded. Calls are audit-logged under the operation name resolve_location. Location-scoped keys are refused: they are already bound to their own tenant and learn nothing about other sub-accounts’ links. Before enabling the tool after its first deploy, populate the optional emailNormalized key on legacy identity rows by running both oneOffMigrations:backfillCrmUserScopeNormalizedEmail20260826 and oneOffMigrations:backfillSlackInviteNormalizedEmail20260826 with {"dryRun":true} first, then page each migration with dryRun:false until isDone is true. Email-based resolution checks both normalized indexes for an absent key and fails explicitly while either backfill is incomplete; it never returns not_found from a partially searchable legacy dataset. New cache refreshes and Slack-invite upserts always maintain the key ("" marks a processed scope row with no email).

trace_applicant_submission (missing-applicant digs, FM-2510)

Read-only diagnostics for “the applicant submitted but never showed up”. Takes a required locationId plus contactId (preferred) or applicant email, and an optional ISO window (default last 30 days, max 180). Returns a redacted, oldest-first timeline of the stored records for that one contact in that one sub-account, plus a classification. Data, via existing indexes only (no new tables, no CRM calls, no webhook replay, no credit pull):
  • leadEvents.by_contact_location_createdAt — the lead events (hard cap 200 in the window). formData is never returned; only hasFormData + a field count. The caller’s limit (default 50, max 200) only trims the returned timeline; classification always runs on the full in-window evidence (counts.leadEventsInWindow vs leadEventsReturned).
  • applicantCreationLocks.by_contact_pipeline — creation locks (pending / completed + opportunityId). Locks are ephemeral (pending TTL 5 min, completed rows cleaned after ~1 h), so their absence proves nothing.
  • autoCreditPullEvents.by_contact_location — the webhook auto-pull decisions (triggered, skipped:duplicate, skipped:no_signal, …).
Every set is read cap+1 so overflow is detectable. When any set overflowed, evidenceComplete is false and every absence-based classification collapses to unknown (positive proof such as a linked opportunity still stands). Email lookup scans leadEvents.by_location_createdAt newest-first inside the named location only (cap 500 rows). More than one matching contact returns contact_not_unique with candidates; a scan that hit its cap returns email_lookup_inconclusive even with one visible match — the tool never picks from a partial scan. Tenancy is fail-closed twice: the agency/location resolution in wrapTool (wrong company → rejected before any trace read), and then contact_not_in_location when no stored record of any kind (any window) ties the contact to the resolved location. no_events is only ever reported for the queried window of a contact that IS known in the location. Classification codes (lib/mcp/traceApplicantSubmission.ts): Only duplicate_skipped_opportunity is an inference and it says so; the output never asserts a pipeline card exists unless a stored record names it. Redaction: email masked (j***@example.com), phone masked (***4567), SSN/DOB/token shapes scrubbed from free text (lastNotificationError, opportunityCreateError, source). Available to both key scopes (a location key is pinned to its own sub-account as always); audited under trace_applicant_submission. Tests: tests/mcpTraceApplicantSubmission.test.ts.

get_credit_pull_failure (Support diagnosis, 2026-09-04)

get_credit_pull_failure is the agency-key tool Support calls to answer “why did this applicant’s credit pull fail (or never happen)?” for one contact in one sub-account. Both locationId and contactId are required; location keys are refused before any data access, same as get_last_login. It is a read-only projection of three stored sources, read through existing indexes in ONE fail-closed Convex query (convex/creditPullDiagnostics.ts:getAgencyCreditPullDiagnostics, which re-checks ghlInstallations.companyId / transferredOutAt in the same transaction as the reads):
  • creditReportRequests via by_contact_location_createdAt (newest 3). The pull route only persists completed pulls, so a bureau-side failure lives inside the stored payload; lib/utils/creditPullIssues.ts classifies it (freeze, lock, no-hit / SSN mismatch, invalid address, invalid identity, below minimum score) and lib/underwriting/creditScoreCodes.ts explains 9002 / 9003 no-score sentinels.
  • creditPullFundingHolds via by_location_contact_status, one bounded read per status so bureau-PDF holds sharing the index cannot hide the blocking credit-pull hold: a pull the wallet check refused and the state of its one automatic resume.
  • autoCreditPullEvents via by_contact_location (newest 5): why automation did or did not fire.
  • contactPipelineStates via by_contact_location (existence only). A contact with no pipeline row, report, hold, or event in the location has no stored proof it belongs there, so the tool returns not_found instead of an ok verdict. Verifying the contact against the live CRM is deliberately out of scope (stored data only).
lib/mcp/creditPullFailure.ts:diagnoseCreditPullFailure turns that snapshot into one verdict — wallet_hold, bureau_issue, no_score, pull_succeeded, auto_pull_skipped, auto_pull_triggered_no_report, or no_pull_recorded — plus hasStoredReport, the evidence, and recommended next steps. Precedence is by time: an open hold or an automation decision newer than the newest stored report supersedes it (except skipped:has_report, the only outcome recorded after checking a report exists). A duplicate delivery also leaves intact a report stored within the same 60-second deduplication window, but it supersedes older reports. Thus an old good report never hides a newer skip or a trigger that stored nothing. A trigger with no report is reported as cause unknown — autoCreditPullEvents records that automation fired, not which later step failed. The stored payload is never emitted: the response carries no report contents, names, or addresses, and every free-text string that came from storage (bureau status, hold error, event source) passes through redactSensitiveText (SSN, DOB, bearer / fmk_ / JWT / long opaque tokens). A missing, transferred-out, or foreign installation is reported as not_found with no data. Out of scope by design: the tool never starts or retries a pull, never replays intake webhooks, never claims or releases a hold, and never re-runs underwriting. Calls are audit-logged under get_credit_pull_failure at the resolved location. Tests: tests/creditPullFailureDiagnosis.test.ts (verdict fixtures, output hygiene, redaction) and tests/mcpCreditPullFailureTool.test.ts (scope, fail-closed tenancy, redaction through the tool path, read-only: the only Convex mutation is the audit log).

Minting (admin only)

Agency keys are minted at app/api/admin/mcp-agency-keys (GET list, POST create/revoke), gated by requireAdminSession(request) without the agency-session bypass — a bare agency session must not mint an agency-spanning credential. Keys default to scopes: ['read']. The optional create parameter includeCreditReports: true adds credit_reports; the admin create dialog exposes this as Allow full credit reports, off by default. Keys remain read-only and mint-once: only the SHA-256 hash reaches Convex. Minting is refused for a company with no installed sub-accounts (it would be a dead credential). UI: the AgencyMcpKeysCard on /admin/locations (Admin -> Locations). Pick an agency from the selector (populated from installed sub-accounts), then mint / revoke and view recent cross-location activity.

Raw report permission and audit privacy (FM-2781)

Only get_credit_report_json and get_credit_report_pdf_url require both read and credit_reports. wrapTool checks these before location resolution or report reads, for agency keys, location keys, and OAuth grants alike. The REST GET /api/v1/credit-reports and /api/v1/credit-reports/pdf-url routes enforce the same pair of scopes in runPublicApiRead before Location resolution or report handlers. REST audit arguments use the same masker as MCP. There is no legacy bypass: existing read-only support credentials lose raw report access and keep their other tools. Summaries (get_credit_summary, get_underwriting_result) stay on read. For report-enabled assistants, create a replacement key with the explicit opt-in, update the assistant, then revoke its old key. The Operator Settings card and its admin mirror use the same opt-in. Do not enable report access for Claude Tag’s workspace-wide support key. OAuth discovery advertises the scope; clients must explicitly request it and the consent page describes bureau and identity data. Existing grants stay read-only until reauthorized; refresh preserves the grant’s scopes. MCP argument summaries use lib/mcp/auditArgs.ts before truncation. An allowlist preserves structural selectors and record IDs; email, search query, cursor, other PII, nested values, and unknown future fields are masked. Lookup handlers receive the original values. This applies to successful and denied calls, including resolve_location and trace_applicant_submission. Existing audit rows are not rewritten; no production data migration is part of this change.

Security notes

  • One agency key widens the blast radius from a single sub-account to a whole agency, so the admin-only mint gate and the per-call company-membership check are the load-bearing parts. An agency key can never name a locationId outside its own companyId.
  • Agency transfers mean an operator’s sub-accounts may not all share one companyId. An agency key is scoped to exactly one company; it does not span companies. A cross-company (system-wide) key is intentionally NOT implemented.
  • Transferred-out locations fail closed. ghlInstallations.companyId is the ownership source of truth and is refreshed on every (re)auth (the OAuth callback → upsertInstallation). When a location transfer completes (convex/admin/locationTransfers.ts), the new owner’s companyId is unknown until the sub-account re-authorizes, so completion stamps ghlInstallations.transferredOutAt. While that marker is set, getLocationCompanyInternal returns null and list_locations skips the location, so a former agency’s key can no longer reach a location it no longer owns. The marker is cleared only by a real (re)auth via upsertInstallation — not by the token-refresh cron — which re-establishes the true owning companyId.
  • As with all MCP bearer tokens, agency keys belong in machine-local client config, never in a repo-tracked file (see AGENTS.md Secret hygiene).

Convex functions (convex/mcpApiKeys.ts)

  • createAgencyKeyInternal / revokeAgencyKeyInternal — lifecycle; revoke enforces key.companyId === args.companyId tenancy.
  • listAgencyKeysInternal — keys for one company (filtered to true agency rows).
  • listRecentToolCallsByKeyInternal — recent calls per key via by_apiKey_createdAt (agency calls are logged under their real location, so the by-location audit query does not group them under the agency).
  • getKeyByHashInternal — now returns scopeType + companyId.
Membership helpers live in convex/ghlInstallations.ts (getLocationCompanyInternal, listLocationsForCompanyInternal) and are token-free (they never return OAuth tokens).

Tests

tests/mcpAgencyKeys.test.ts (data model, fail-closed verification, membership enforcement, admin-route gating) and the updated tests/mcpTools.test.ts (location keys ignore client locationId; agency keys require a company-member location; audit uses the resolved location).