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 meanslocation.companyId— set only on agency keys; the sole authorization input for cross-location access.
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
verifyMcpKey(lib/mcp/keys.ts) returnsscopeType+companyId. An agency key with nocompanyIdfails closed (returnsnull).- The transport route (
app/api/mcp/[transport]/route.ts) passes both throughauthInfo.extra. wrapTool(lib/mcp/tools.ts) strips any client-suppliedlocationIdfrom the args and callsresolveEffectiveLocation:- Location key: always its own bound location; the client
locationIdis ignored (the historical tenancy invariant). - Agency key: the client MUST pass a
locationId; it is verified againstghlInstallations(internal.ghlInstallations.getLocationCompanyInternal) and rejected unlessinstallation.companyId === key.companyId.
- Location key: always its own bound location; the client
- Tool calls are audit-logged (
mcpToolCalls) under the resolved location, so per-sub-account attribution survives for agency keys.
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):
- Admin-linked Slack channel (
locationSlackChannels, Admin → Locations) — authoritative; a human tied that channel to that sub-account. - 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.
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).formDatais never returned; onlyhasFormData+ a field count. The caller’slimit(default 50, max 200) only trims the returned timeline; classification always runs on the full in-window evidence (counts.leadEventsInWindowvsleadEventsReturned).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, …).
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):
creditReportRequestsviaby_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.tsclassifies it (freeze, lock, no-hit / SSN mismatch, invalid address, invalid identity, below minimum score) andlib/underwriting/creditScoreCodes.tsexplains 9002 / 9003 no-score sentinels.creditPullFundingHoldsviaby_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.autoCreditPullEventsviaby_contact_location(newest 5): why automation did or did not fire.contactPipelineStatesviaby_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 returnsnot_foundinstead of anokverdict. 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 atapp/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)
Onlyget_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
locationIdoutside its owncompanyId. - 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.companyIdis 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’scompanyIdis unknown until the sub-account re-authorizes, so completion stampsghlInstallations.transferredOutAt. While that marker is set,getLocationCompanyInternalreturnsnullandlist_locationsskips 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 viaupsertInstallation— not by the token-refresh cron — which re-establishes the true owningcompanyId. - As with all MCP bearer tokens, agency keys belong in machine-local client
config, never in a repo-tracked file (see
AGENTS.mdSecret hygiene).
Convex functions (convex/mcpApiKeys.ts)
createAgencyKeyInternal/revokeAgencyKeyInternal— lifecycle; revoke enforceskey.companyId === args.companyIdtenancy.listAgencyKeysInternal— keys for one company (filtered to true agency rows).listRecentToolCallsByKeyInternal— recent calls per key viaby_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 returnsscopeType+companyId.
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).
