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.

PR dealops#6053 Author @yijunz166 Branch yijunz/mcp-mvp Files 26 + 3324 / - 123 Status Open · MVP

What it adds

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.

Who's the brain

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.

Read / write boundary

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.

Auth

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.

MCP host (Claude)
Hosted endpoint
Standalone process
Pipeline stages
Plaid Test Org (write)
Plaid prod (read-only)

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.

Goal

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.

Standalone — local dev

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.
Hosted — deployed

Mounted on the main server as an Express sub-router at /api/mcp/quoting.

  • Auth: Dealops API key via Authorization: Bearer <key> or x-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.

ToolKindWhat it returns
get_opportunity_contextreadStage 1 — SFDC opp + account + Gong call summaries + meetings. Primary evidence.
find_similar_accountsreadStage 2 — ranked comparable accounts (uses Gemini fuzzy-match internally).
get_historical_dealsreadStage 2 + 3 — past deals from similar accounts, with actual quoted unit prices.
analyze_pricing_benchmarksreadPer-product price bands (min/median/max + sample size). Headline analysis tool.

4. The orchestration the host is expected to run

The build_quote prompt encodes this. None of it is hard-wired in the server.

1
get_opportunity_contextRead the deal, account, and Gong call summaries — the primary evidence.
2
analyze_pricing_benchmarksAnchor on per-product price bands from comparable accounts.
3
get_product_catalog & lookup_list_pricePick valid SKUs from THIS catalog (by name — historical IDs come from a different prod catalog) and check list-price ceilings.
4
DecideChoose products, monthly volumes, and unit prices, each justified by evidence + benchmarks.
5
create_pricing_quotePersist. Returns 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.

Before

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.

After

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.

Standalone boot guard

In mcp/index.ts: if MCP_QUOTING_ORG_ID === PLAID_PROD_ORG_ID, log a warning and process.exit(1) before binding.

Hosted mount guard

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.

Tool-level guard

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.

1
AuthenticatevalidateApiKey(rawKey) — encrypt, hit Redis (10-min TTL), fall back to DB, ensure not revoked. Returns user + org.
2
Re-read role from DBprisma.user.findUnique({ where: { id }, select: { role: true } }) — Redis might hold a stale role from before a demotion.
3
Check membershipMust be DEALOPS_INTERNAL_STAFF or DEALOPS_INTERNAL_STAFF__AS_USER. Otherwise return 403 JSON-RPC error.
4
Stamp the user on the requestreq.mcpUserId = validated.user.id — this becomes the quote author downstream.
Why the fresh role re-check? The Redis cache stores the full user object including role. If a staffer is de-staffed and the cache hasn't expired (up to 10 minutes), the cached role would still pass the gate. The fresh DB read makes demotion effective immediately.

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.

Zero-price drop

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.

Ramp-length check

If any volumeRamp.length !== subscriptionTerms, throw with the offending product names. Stage 5 would otherwise silently fall back to a flat volume.

Currency check

The pricebook's currency must be in SUPPORTED_CURRENCIES from @dealops/types/v2/annotatedNumber; otherwise throw rather than persist an unsupported currency.

Author is not a host arg

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.

New file

services/pricingengine/resolveCalculationTrace.ts — takes a PricingQuote, an org id, and an optional atTraceId. Returns ExplainQuoteResult | null.

Refactored caller

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.

Two-sided gate

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

13. Risks & open questions

Auth model is single-tenant for now. Quotes only ever write to the configured 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.
Catalog mismatch is real. Comparables and benchmarks come from prod Plaid's catalog; the write target is the Test Org's catalog, which has different product IDs. The 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.
Cache invalidation on revoke. Revoking an API key in the existing system writes 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.
The boot/mount guards are belt-and-suspenders. Three independent layers (boot, mount, tool) all check for prod Plaid as the quote org. Hard to misconfigure into a real-quote-on-prod situation.