File size: 5,858 Bytes
3464008
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
// #4866 — wm_api_usage emission for the MCP surface.
//
// /mcp rewrites straight to this handler and never passes server/gateway.ts,
// so before this module the endpoint had ZERO rows in Axiom: auth rejections,
// quota 429s, and successes were all invisible (the #4859 paying-customer
// diagnosis had to be reconstructed from REST-side rows). One RequestEvent is
// emitted per POST / SSE-replay GET via ctx.waitUntil, reusing the gateway's
// builders so the envelope is byte-compatible with REST rows and joinable on
// customer_id.
import {
  buildRequestEvent,
  deriveAcceptLanguage,
  deriveCountry,
  deriveExecutionRegion,
  deriveHost,
  deriveIp,
  deriveIpCity,
  deriveIpRegion,
  deriveReferer,
  deriveReqBytes,
  deriveRequestId,
  deriveSentryTraceId,
  deriveUserAgent,
  emitUsageEvents,
  type RequestReason,
  type WaitUntilCtx,
} from '../../server/_shared/usage';
import type { AuthKind } from '../../server/_shared/usage-identity';
import type { McpAuthContext } from './types';

// Which stage of the /mcp funnel produced the terminal Response. Set by the
// handler at each return site; combined with the HTTP status it maps onto the
// closed RequestReason union without parsing response bodies.
export type McpPhase =
  | 'auth'       // credential resolution rejected (invalid key/bearer, backend down)
  | 'precheck'   // identity ok, entitlement/token pre-check rejected
  | 'billing'    // pre-check rejected with a billing-verification denial (#4770)
  | 'limit'      // per-minute rate limit
  | 'dispatch'   // tools/call quota (429) / reservation unavailable (503)
  | 'malformed'  // unparseable JSON-RPC envelope
  | 'transport'  // method/SSE-transport level (405, replay 4xx)
  | 'ok';        // served (JSON-RPC-level errors still ride HTTP 200 → ok)

export interface McpUsage {
  phase: McpPhase;
  authKind: AuthKind;
  customerId: string | null;
  principalId: string | null;
  /** Set true for surfaces that must not emit (OPTIONS/HEAD, manifest GET). */
  skip: boolean;
}

export function createMcpUsage(): McpUsage {
  return { phase: 'ok', authKind: 'anon', customerId: null, principalId: null, skip: false };
}

/** Attribute the resolved principal. env_key principals are operator keys —
 *  never log raw key material; the hashed principal is already covered by the
 *  gateway's convention of leaving customer_id null for enterprise keys. */
export function setUsageContext(usage: McpUsage, context: McpAuthContext): void {
  if (context.kind === 'pro') {
    usage.authKind = 'mcp_oauth';
    usage.customerId = context.userId;
    usage.principalId = context.userId;
    return;
  }
  if (context.kind === 'user_key') {
    usage.authKind = 'user_api_key';
    usage.customerId = context.userId;
    usage.principalId = context.userId;
    return;
  }
  usage.authKind = 'enterprise_api_key';
}

export function mcpReasonFor(phase: McpPhase, status: number): RequestReason {
  switch (phase) {
    case 'auth':
      return status === 503 ? 'auth_unavailable' : 'auth_401';
    case 'precheck':
      return status === 503 ? 'auth_unavailable' : 'tier_403';
    case 'billing':
      // Mirrors server/gateway.ts's classification of the same denial: a
      // billing-verification 503 is provider-verification churn, not the
      // auth backend being unreachable — keeping it out of auth_unavailable
      // stops Axiom outage alerts from paging on ordinary billing states.
      return status === 503 ? 'billing_verification_503' : 'tier_403';
    case 'limit':
      return 'rate_limit_429';
    case 'dispatch':
      if (status === 429) return 'rate_limit_429';
      if (status === 503) return 'rate_limit_degraded';
      return 'ok';
    case 'malformed':
      return 'malformed_request';
    case 'transport':
      return status === 405 ? 'method_not_allowed' : 'malformed_request';
    default:
      return 'ok';
  }
}

/**
 * Build + register the request event on ctx.waitUntil. Must NEVER throw or
 * delay the response — all failure modes are swallowed (emitUsageEvents
 * already no-ops without USAGE_TELEMETRY/token and circuit-breaks on sink
 * errors).
 */
export function emitMcpRequestEvent(
  req: Request,
  res: Response,
  usage: McpUsage,
  durationMs: number,
  ctx?: WaitUntilCtx,
): void {
  if (!ctx || usage.skip) return;
  try {
    const pathname = (() => {
      try { return new URL(req.url).pathname; } catch { return '/mcp'; }
    })();
    const resBytesRaw = Number(res.headers.get('content-length'));
    const event = buildRequestEvent({
      requestId: deriveRequestId(req),
      domain: 'mcp',
      route: pathname,
      method: req.method,
      status: res.status,
      durationMs,
      reqBytes: deriveReqBytes(req),
      resBytes: Number.isFinite(resBytesRaw) && resBytesRaw >= 0 ? resBytesRaw : 0,
      customerId: usage.customerId,
      principalId: usage.principalId,
      authKind: usage.authKind,
      // Tier/planKey are not re-resolved here — the pre-checks consume the
      // entitlement internally and the extra lookup isn't worth a second
      // Convex round-trip per request. Join on customer_id in Axiom instead.
      tier: 0,
      planKey: null,
      country: deriveCountry(req),
      ipCity: deriveIpCity(req),
      ipRegion: deriveIpRegion(req),
      executionRegion: deriveExecutionRegion(req),
      executionPlane: 'vercel-edge',
      originKind: 'mcp',
      cacheTier: 'no-store',
      ip: deriveIp(req),
      userAgent: deriveUserAgent(req),
      uaHash: null,
      referer: deriveReferer(req),
      acceptLanguage: deriveAcceptLanguage(req),
      host: deriveHost(req),
      sentryTraceId: deriveSentryTraceId(req),
      reason: mcpReasonFor(usage.phase, res.status),
    });
    emitUsageEvents(ctx, [event]);
  } catch {
    // Telemetry must never affect the response path.
  }
}