MCP server for the Dealops AI quoting agent
Exposes the AI-quoting pipeline as MCP tools so Claude (or any MCP host) can orchestrate quote generation — with a hosted, API-key-authed endpoint on the main server.
A new MCP server in apps/server/src/dealops2/aiQuoting/mcp/ that wraps the AI-quoting pipeline as ~13 tools and 3 prompts, plus a hosted endpoint at POST /api/mcp/quoting.
This inverts the built-in SSE pipeline. There, Stage 4 (Gemini) is the orchestrator. Here, the host model (Claude) decides which tools to call; the server just surfaces deterministic Dealops capabilities.
Reads come from prod Plaid (Salesforce + corpus, read-only). The only write is create_pricing_quote, which persists to the Plaid Test Org. Two guards refuse to mount/start against the prod Plaid org.
Hosted endpoint uses a Dealops API key, validated by a new shared validateApiKey (reused by the tRPC middleware) and gated to DEALOPS_INTERNAL_STAFF against a fresh role lookup, not the cached one.
1. Why this exists
Dealops has an AI-quoting pipeline that takes a Salesforce opportunity and produces a V2 pricing quote. Today, Stage 4 of that pipeline (Gemini) is the orchestrator — it reads context, calls comparables, picks SKUs, sets prices, and writes the quote.
This PR ships an MVP that inverts that control flow. The same deterministic capabilities (fetch opportunity context, find similar accounts, analyze pricing benchmarks, resolve a pricebook, persist a quote) are exposed as Model Context Protocol tools. An MCP host like Claude Desktop or Claude Code becomes the brain and decides which tools to call, in what order, with what arguments.
Let internal staff drive end-to-end AI quoting from Claude against the Plaid Test Org, with no UI in the loop — to evaluate how well a frontier host model can do the reasoning the SSE pipeline currently bakes in.
2. Two ways to run the same tools
The PR provides both a standalone process (for local dev) and a hosted endpoint mounted on the main server. They share the transport plumbing (httpShared.ts) and the server factory (server.ts); they differ only in how they authenticate and where they get the quote author from.
pnpm run mcp:quoting boots its own Express app on MCP_QUOTING_PORT (default 5060).
- Auth: optional shared bearer in
MCP_QUOTING_AUTH_TOKEN, constant-time compared. - Quote author: static, from
MCP_QUOTING_USER_ID. - Endpoint:
POST /mcp+GET /health. - Refuses to start if quote org is prod Plaid.
Mounted on the main server as an Express sub-router at /api/mcp/quoting.
- Auth: Dealops API key via
Authorization: Bearer <key>orx-api-key. - Quote author: per-request, the API key's user — so each created quote attributes correctly.
- Authorization: restricted to
DEALOPS_INTERNAL_STAFF, re-checked against current DB role. - Refuses to mount if quote org is prod Plaid.
3. The tools the host gets
Read tools carry readOnlyHint: true; only create_pricing_quote writes. Each tool wraps an existing pipeline function (single source of truth) and returns JSON text.
| Tool | Kind | What it returns |
|---|---|---|
get_opportunity_context | read | Stage 1 — SFDC opp + account + Gong call summaries + meetings. Primary evidence. |
find_similar_accounts | read | Stage 2 — ranked comparable accounts (uses Gemini fuzzy-match internally). |
get_historical_deals | read | Stage 2 + 3 — past deals from similar accounts, with actual quoted unit prices. |
analyze_pricing_benchmarks | read | Per-product price bands (min/median/max + sample size). Headline analysis tool. |
Filter-driven reads over the corpus, not anchored to a single opp.
| Tool | Kind | What it returns |
|---|---|---|
list_corpus_filters | read | Distinct vertical / subvertical / gtmSegment / industry / country values with counts. |
find_accounts | read | Accounts matching profile filters + their deal counts. |
fetch_opportunities | read | Opportunity metadata matching filters (fast browse, no line items). |
fetch_past_deals | read | Deals hydrated with product line items + actual quoted prices. |
| Tool | Kind | What it does |
|---|---|---|
resolve_pricebook | read | Stage 1.5 — pricebook + currency → concrete Pricebook row. |
get_product_catalog | read | Scoped SKU universe for a pricebook. |
lookup_list_price | read | List-price ceiling for a product (quoted prices are capped at this). |
create_pricing_quote | WRITE | Stage 5 + 6 — assemble + persist a PricingQuote. Returns pricingQuoteId and any dropped products. |
| Tool | Kind | What it does |
|---|---|---|
get_pricing_quote | read | Read a created quote back: engine-computed output + stored AI rationale. |
explain_quote | read | AI rationale + pricing-engine calculation trace (stored milestone, else ephemeral recompute). |
list_pricebooks | read | The quote org's pricebooks + valid pricebook/currency inputs. |
Host-invocable playbooks that tell the model how to chain the tools. The server still does no reasoning.
| Prompt | Kind | Purpose |
|---|---|---|
build_quote | prompt | Full opp → persisted quote workflow (reads + the one write). |
research_opportunity | prompt | Read-only: gather and summarize evidence, no write. |
explore_comparables | prompt | Read-only: browse the corpus for pricing patterns. |
4. The orchestration the host is expected to run
The build_quote prompt encodes this. None of it is hard-wired in the server.
pricingQuoteId + droppedProducts. Prices are hard-capped at list during mapping.5. Shared validateApiKey: factoring the auth path
To support both the tRPC external API and the new MCP endpoint with one source of truth, the API-key validation logic is extracted into apps/server/src/lib/apiKey/validateApiKey.ts.
tRPC apiKeyAuth middleware inlined encryption, Redis cache (10-min TTL), DB lookup with revokedAt: null, and cache rewriting — 63 lines, all local to that middleware.
The middleware shrinks to a call into validateApiKey(rawKey). The same function powers the MCP endpoint. Same encryption, same revocation check, same Redis cache key.
// trpc/middleware/apiKeyAuth.ts — now
const validated = await validateApiKey(apiKey);
if (!validated) throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Invalid API key.' });
return next({ ctx: { ...ctx, organization: validated.organization,
user: validated.user, apiKey: validated.apiKey } });
6. Two safety guards against writing to prod Plaid
The only mutating tool is create_pricing_quote, and the design wants it to persist only to the Plaid Test Org. Three layers enforce that.
In mcp/index.ts: if MCP_QUOTING_ORG_ID === PLAID_PROD_ORG_ID, log a warning and process.exit(1) before binding.
In routes/quotingMcp.ts: if the quote org resolves to prod Plaid, log and return from setupQuotingMcpRoutes. The server still boots; this route is just absent.
Inside create_pricing_quote itself, defense in depth: throw if orgId === PLAID_PROD_ORG_ID, regardless of how it got there.
7. Authorization: don't trust the cached role
The hosted endpoint's auth middleware does two distinct checks. The second is the careful one.
validateApiKey(rawKey) — encrypt, hit Redis (10-min TTL), fall back to DB, ensure not revoked. Returns user + org.prisma.user.findUnique({ where: { id }, select: { role: true } }) — Redis might hold a stale role from before a demotion.DEALOPS_INTERNAL_STAFF or DEALOPS_INTERNAL_STAFF__AS_USER. Otherwise return 403 JSON-RPC error.req.mcpUserId = validated.user.id — this becomes the quote author downstream.8. Per-request server, stateless transport
Both entry points share httpShared.ts, which constructs a fresh McpServer + StreamableHTTPServerTransport per POST and tears them down on connection close. GET and DELETE return JSON-RPC method not allowed (no server-push, no sessions).
// routes/quotingMcp.ts — the hosted handler
router.post('/quoting', authenticate, async (req, res) => {
const userId = req.mcpUserId ?? null;
// Per-request server so create_pricing_quote attributes to the key's user.
const server = createQuotingMcpServer({ ...config, userId });
await handleStatelessMcpPost(server, req, res);
});
9. Stage-5 hardening on create_pricing_quote
The write path adds checks that would have been implicit in the SSE pipeline but matter when an external host is composing the input.
Products with no positive unitPrice and no positive tier pricePerUnit are dropped before mapping, with a droppedProducts entry tagged zero_price. Prevents $0 lines from silently distorting TCV.
If any volumeRamp.length !== subscriptionTerms, throw with the offending product names. Stage 5 would otherwise silently fall back to a flat volume.
The pricebook's currency must be in SUPPORTED_CURRENCIES from @dealops/types/v2/annotatedNumber; otherwise throw rather than persist an unsupported currency.
userId comes only from config (standalone) or the API key (hosted) — never from the tool input. Quote attribution cannot be spoofed.
10. Calculation-trace logic, deduplicated
Both the tRPC pricingEngine.explainQuote handler and the new MCP explain_quote tool need the same logic: prefer a stored milestone trace, otherwise recompute ephemerally from the saved quote input. That logic moves into a new service.
services/pricingengine/resolveCalculationTrace.ts — takes a PricingQuote, an org id, and an optional atTraceId. Returns ExplainQuoteResult | null.
trpc/router/pricingEngine/explainQuote.ts shrinks from ~70 lines of trace logic to a single call into the service. The MCP tool calls the same service, so they can't drift.
11. Admin UI to mint keys
A new internal-staff-only Admin tab, MCP Setup, lets staff generate / revoke API keys and copy a ready-to-paste connection snippet for Claude Code or Claude Desktop.
The tab's userCanView requires all three: internal-staff role, current org === Plaid Test Org id e846ccc7-…, and the access_api_keys permission. The hosted endpoint enforces internal-staff again at request time, so this is just the UI half.
// Generated snippet (key filled in by the "new key" modal)
claude mcp add --transport http dealops-quoting \
https://<host>/api/mcp/quoting \
--header "Authorization: Bearer <dealops-api-key>"
12. What it doesn't change
- The existing SSE AI-quoting pipeline (Stages 1–6, including the Stage 4 Gemini orchestrator) is untouched. This is a parallel entry point.
- No new tRPC procedures. No new Prisma migration. No changes to the
PricingQuote/PricingQuoteInputtypes. - No writes to Salesforce.
get_opportunity_contextruns SOQLSELECTonly. - No changes to the tRPC
apiKeysrouter behavior — only the inline middleware was extracted into the shared helper. - No new public REST surface. The hosted endpoint speaks MCP/JSON-RPC, not REST.
- The
pipeline/findSimilarAccounts.tschange is a single-line visibility tweak:rowToAccountProfilegoes from local toexport, reused byfetchTools.ts.
13. Risks & open questions
MCP_QUOTING_ORG_ID (Plaid Test Org). API keys are per-user but the tool surface doesn't accept an org parameter — extending this to multi-tenant will need an org-selection step in the auth flow.
build_quote prompt explicitly tells the host to match by product name and warns that unknown SKUs get dropped. Worth watching how often the host gets this wrong.
revokedAt to the DB. The 10-minute Redis cache means a revoked key may still authenticate for up to 10 minutes — same behavior the tRPC middleware has today, but worth flagging given this PR widens the surface.