Backstop MCP - Architecture
8 min read
Overview
Backstop MCP is a single Python process: a FastMCP server on Starlette and uvicorn, serving streamable HTTP on /mcp. It stores no CRM data beyond in-memory caches and the caller's own system-user record. Each tool call reads or writes Backstop live, as the calling user, through one shared HTTP client.
Code Layout
src/backstop_mcp/
app.py builds the app: logging, metrics, FastMCP, tools, routes, lifespan
main.py entry point (`backstop-mcp`), loads .env, runs uvicorn
config.py one settings class per concern
dependencies.py cached providers: configs, engine, Backstop client factory, OAuth provider
logging.py JSON logging setup
metrics.py OpenTelemetry instruments exported on /metrics
teardown.py on shutdown, closes the HTTP pool and database engine, clears cached providers
server/ how features are exposed over MCP
instructions.py server instructions sent to every client
tools/registry.py the registered tools (listed in Tools)
tenant_guidance.py appends per-deployment text to descriptions
session_revoked.py turns a revoked session into HTTP 401
features/ what the server does, one package per domain
backstop_client/ HTTP transport to Backstop
caching/ TTL cache with single-flight fetch
db/ SQLAlchemy engine, models, Alembic migrations
utils/ shared helpers, including log concealment of loginsThe tree shows the main parts only; a few small top-level modules (dates.py, models.py, …) are left out.
Settings are described in Configuration; every tool is listed in Tools.
features/ never imports server/, and backstop_client/ never imports features/ or config. tests/test_layering.py enforces these rules, and that every tool file is registered. Features share one shape (queries/, commands/, responses, tools/), each part present only when needed; see AGENT_README.md.
Feature package | Domain |
|---|---|
| Login form, credential storage, encryption, cleanup |
| Turning a name, email, or id into one Backstop party; the ambiguity policy |
| Organizations, people, employment, addresses |
| Current vs former employment; |
| Activities and tasks |
| Opportunities (the pipeline) |
| Products, holdings, investors, time series, capital flows |
| Custom-field catalog, groups, value writes |
| Reference vocabularies |
| Saved Report Center reports |
| Building and parsing Backstop UI URLs (no HTTP) |
| Shared mechanics: paging, bulk write outcomes, side-loads, delete confirmation |
HTTP Surface
Path | Methods | Purpose |
|---|---|---|
| GET, POST, DELETE | MCP streamable HTTP endpoint. Requires a bearer token |
| GET | OAuth authorization server metadata |
| GET | Protected resource metadata for |
| POST | Dynamic client registration (RFC 7591), open to any client |
| GET, POST | Starts the OAuth flow; redirects to the login form |
| POST | Exchanges codes and refresh tokens. PKCE |
| POST | Token revocation |
| GET, POST | The hosted Backstop credential form |
| GET | Process is up |
| GET | PostgreSQL is reachable ( |
| GET | Prometheus metrics |
FastMCP places its own request-context and bearer-token middlewares outermost: every request carrying a bearer token is hashed and looked up there, before any of the app's own middleware runs. The app then adds four, outermost first:
Request body limit, 4 MiB, the same cap as the MCP SDK's streamable HTTP transport. It also bounds the unauthenticated login POST.
OpenTelemetry ASGI instrumentation.
HTTP request metrics from
unique_mcp.monitoring.setup_ops.Session-revoked rewrite. When a tool call confirms the user's Backstop credential is dead, this turns the in-flight
200into401so the client reconnects at once. See Authentication: When Backstop rejects the credential.
Request Lifecycle
Party resolution
Most party-scoped tools accept either search (a name or email) or a trusted party_id with its search_type (organizations, people, contacts, or employees). The four collections have separate id spaces.
Input | Lookup |
|---|---|
| Trusted as given. Not looked up, except for an optional name fetch some tools use to echo the name back |
An email | Exact match on each email field of that collection, separately, merged by id |
A name |
|
The outcome follows one policy, in features/resolution.py:
One match: resolved. The tool echoes the resolved id, type, and name so a wrong match is visible.
Several matches on a single-party call: the user is asked to pick, through MCP elicitation.
Several matches inside a batch: everything that resolved plus every unresolved item, in one payload, so the model asks once.
The client cannot elicit, the user declines or cancels, or nobody answers within
RESOLUTION_ELICIT_TIMEOUT_SECONDS: the same structured candidate list as step 3.No match:
not_found, naming the query that was used.
The server supports both elicitation styles in the MCP specification: a mid-call elicitation/create request (sent on the standalone GET stream for streamable HTTP), and, for clients on protocol version 2026-07-28, an InputRequiredResult the client answers by calling the tool again.
Products resolve differently, through accounts/resolve_product.py: quick search misses product short names, so a product name is matched in memory over the whole product catalog, with an exact short name winning over a longer name that contains it.
Backstop Client
backstop_client/ is the only code that talks HTTP to Backstop.
Concern | Behaviour |
|---|---|
Connection pool | One |
Headers |
|
Authentication | HTTP Basic with the caller's username and API token, resolved once per client call ( |
Per-user gate | At most |
Timeouts |
|
429 | Classified from the body as concurrency, minute, hour, or day. Only concurrency |
Nothing else retries | 5xx responses, timeouts, network errors, and |
401 | Re-checked by probing |
Pagination | JSON:API |
Errors | Backstop's JSON:API |
Paged Searches
search_organizations, search_people, search_opportunities (rows mode), and search_activities (rows mode) return about SEARCH_RESULT_SIZE rows per call, plus a continuation with an opaque cursor when more may match. The first three return at most that many. search_activities reads whole pages until it has at least that many, so it can return up to about twice as many.
Many filters are applied in memory after the read, because Backstop rejects them as query filters. A search therefore reads Backstop pages until it has a full page of matches or reaches the end.
The cursor stores the position of the next unread record and a fingerprint of every other argument. Reusing it with different arguments is rejected rather than returning rows from a different query.
Organizations, people, and opportunities are read in id order (
sort=id). Backstop orders records that tie on a sort field differently between requests, and/peoplereturns500forsort=name, so offset paging by name would repeat or skip rows.Aggregate modes (
mode="aggregate") read every match in one call and return counts, without a cursor.
Caching
All caches are in process memory: each pod keeps its own, and they are lost on restart. Catalogs are shared by every user of the pod; report runs are per caller.
Cache | Contents | Lifetime |
|---|---|---|
Custom-field definitions and groups | Tenant schema, shared by all users | 120 minutes, on by default |
Activity tags, contact sources, contact categories, system users, time zones | Small vocabularies | 24 hours when enabled, off by default |
Opportunity stages, entity-relationship types | Small vocabularies | 60 minutes, always on |
Report runs | One running or finished | 1 hour, at most 1,000 runs |
Catalog caches use one shared fetch for concurrent callers. With the cache flag off, that sharing still happens, but nothing is kept afterwards. The catalog caches hold instance-wide schema rather than per-user data: custom-field definitions carry no permission attribute in Backstop.
If a refresh fails, a catalog whose cache is enabled keeps serving the previous value (the response's cache field says stale). With the cache off, the default for the small vocabularies, the failure reaches the caller. Opportunity stages and entity-relationship types never serve stale.
Persistence
PostgreSQL holds only authentication state:
backstop_credentials: one row per Backstop username, with the Fernet-encrypted credential and the system-user record captured at login.mcp_auth_*: OAuth clients, pending authorizations, authorization codes, hashed tokens, login attempts. These tables come from the sharedmcp-credential-authpackage.
Every request with a bearer token reads the token table, which is why /ready depends on the database. Schema changes are Alembic migrations in db/migrations/versions/. See Authentication: Data stored.
Observability
Logs
Structured JSON on stderr, through unique_mcp.logging (the chart labels pods so Loki parses it). Events use dotted names with fields in extra, for example auth.login.succeeded, backstop.request.failed, backstop.auth_recheck.decision, resolution.elicit.timed_out, tenant_guidance.applied. Tool-level events conceal Backstop logins as sha256:<hex> unless LOGS_DIAGNOSTICS_DATA_POLICY=disclose. Backstop client events (backstop.request.*, backstop.auth_recheck.*) currently carry the username in clear. API tokens are never logged.
Each tool call also logs one completion line with its name, status, and duration.
Metrics
Served on /metrics in Prometheus format. setup_ops contributes the HTTP request metrics and the mcp_* tool metrics; the rest are defined in metrics.py.
Metric | Type | What it tells you |
|---|---|---|
| Counter ( | Upstream Backstop calls and their status codes |
| Histogram ( | Latency of one Backstop request |
| Histogram ( | Time waiting for a per-user slot. Rising values mean users hit the 5-request limit |
| Counter ( |
|
| Counter ( | Tool calls by tool and outcome (the exception class name on failure) |
| Histogram ( | Tool-call latency |
| Counter ( |
|
| Counter ( | Custom-field definition loads, by source ( |
| Histogram ( | Every catalog read and what answered it: |
| Histogram ( | Actual catalog walks. Subtract its |
The chart's BackstopFilterIgnored alert fires when backstop_filter_ignored_total increases over 15 minutes. In search_activities, rows that contradict a filter are dropped and the filter is named in server_filter_ignored.
Traces
Several tools, queries, and commands open OpenTelemetry spans (for example reports.run, accounts.product_investors, opportunity_writes.command.update), and the ASGI middleware spans each HTTP request. The server does not install a trace exporter, so with the shipped image these spans are not exported anywhere.