> ## Documentation Index
> Fetch the complete documentation index at: https://docs.myfundingmachine.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Agency mcp keys

# 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`):

| code | confidence | meaning |
| - | - | - |
| `opportunity_linked` | confirmed | a lead event in the window carries an `opportunityId` |
| `opportunity_created_unlinked` | confirmed | a completed creation lock holds an `opportunityId` no event links |
| `creation_in_progress` | confirmed | pending lock inside its TTL |
| `creation_stalled` | confirmed | pending lock past its TTL (claimed, never completed/released) |
| `opportunity_create_failed` | confirmed | the **newest** lead event in the window carries `opportunityCreateError`: the CRM definitely rejected (4xx) the create for that submission; the sanitized status, upstream message and correlation id are quoted so recovery can target that event. An older event's rejection is listed as evidence only |
| `no_intake_signal` | confirmed | auto-pull recorded `skipped:no_signal`; creation skipped by design |
| `duplicate_skipped_opportunity` | **inferred** | ≥1 event with no form data, no lock ever claimed, no link, no `skipped:duplicate` marker — the contact-created webhook wrote first and the form-submission webhook was collapsed as a retransmit |
| `no_events` | confirmed | nothing stored in the window |
| `unknown` | none | nothing above applies; evidence lists what IS known |

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).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.