File size: 21,538 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
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
// MCP resources registry β€” split into two tiers by data sensitivity:
//
//   1. PUBLIC_RESOURCE_REGISTRY (surfaced via `resources/list`) β€” concrete,
//      anonymously-readable, quota-exempt resources that return ONLY
//      non-sensitive freshness / health metadata (never billable data). An
//      anonymous agent (or an agent-readiness scanner) MUST be able to
//      `resources/read` every entry `resources/list` advertises, so these
//      are served without auth and without spending quota β€” the same public
//      posture as `prompts/list` and `describe_tool`. Their `read()` runs a
//      direct cache probe (no `dispatchToolsCall`, no Pro reservation).
//
//   2. TEMPLATE_RESOURCE_REGISTRY (surfaced via `resources/templates/list`) β€”
//      data-bearing URI templates. A concrete instantiation `resources/read`
//      routes through the SAME `dispatchToolsCall` path `tools/call` uses, so
//      auth, Pro daily quota, telemetry, and per-tool budget gating are
//      inherited unchanged. Asymmetric auth between resources and the
//      equivalent `tools/call` is a known MCP data-leak / quota-bypass vector
//      (a Pro user at the daily cap could otherwise keep reading data through
//      resources for free), so the symmetry is load-bearing and proven by
//      tests/mcp-resources.test.mjs. Templates live in
//      `resources/templates/list` (NOT `resources/list`) because a literal
//      `{iso2}` URI can never resolve to data β€” surfacing a template in
//      `resources/list` would break an anonymous validator's `resources/read`
//      probe of it.
//
// Stability contract:
//   - URIs use canonical kebab-case slugs (CHOKEPOINT_SLUGS in ./slugs.ts)
//     and ISO 3166-1 alpha-2 / uppercase tickers. Slugs are pinned in a
//     hand-curated table so a cache refresh / upstream rename never breaks
//     a bookmarked URI.
//   - Every resources/read response carries `cached_at` + `stale` in the
//     content payload. Cache-tool-backed resources already have this from
//     the cacheEnvelope shape; RPC-tool-backed resources (just country
//     risk in v1) get the envelope explicitly wrapped here; the public
//     seed-meta freshness resource IS the envelope.
//
// resources/read response shape (per MCP spec):
//   { contents: [{ uri, mimeType, text }] }
// where `text` is the JSON-stringified payload INCLUDING `cached_at` and
// `stale`. mimeType is `application/json` for every resource here.

import type {
  McpAuthContext,
  McpHandlerDeps,
  PublicResourceDef,
  TemplateResourceDef,
} from '../types';
import { dispatchToolsCall } from '../dispatch';
import { evaluateFreshness } from '../freshness';
import { rpcError, rpcOk, withMcpNoStore } from '../rpc';
import { readJsonFromUpstash } from '../../_upstash-json.js';
import { CHOKEPOINT_SLUGS } from './slugs';

// ---------------------------------------------------------------------------
// Public resource freshness reader
// ---------------------------------------------------------------------------
// Market-data bootstrap freshness is a single seed-meta key at the same
// 30-minute budget the get_market_data cache tool uses (api/mcp/registry/
// cache-tools.ts `_seedMetaKey` / `_maxStaleMin`), so the envelope this
// resource emits is identical to the freshness portion of a
// get_market_data call β€” but computed WITHOUT dispatching the tool (no
// data fetch, no Pro reservation), because the resource surfaces only the
// envelope. Robust by construction: a missing/unreachable cache yields a
// valid `{cached_at: null, stale: true}` envelope rather than an error,
// so the anonymous read never surfaces empty content.
const MARKET_FRESHNESS_CHECK = { key: 'seed-meta:market:stocks', maxStaleMin: 30 } as const;

async function readMarketFreshness(): Promise<string> {
  const meta = await readJsonFromUpstash(MARKET_FRESHNESS_CHECK.key).catch(() => null);
  const { cached_at, stale } = evaluateFreshness([MARKET_FRESHNESS_CHECK], [meta]);
  return JSON.stringify({ cached_at, stale });
}

// ---------------------------------------------------------------------------
// Public (concrete, anon-readable, quota-exempt) resources β†’ resources/list
// ---------------------------------------------------------------------------
export const PUBLIC_RESOURCE_REGISTRY: PublicResourceDef[] = [
  {
    uri: 'worldmonitor://seed-meta/freshness',
    name: 'Seed-Meta Freshness',
    description: 'Cache-freshness probe for the high-cadence market-data bootstrap pipeline. Returns ONLY the envelope (cached_at + stale) β€” no quote payload, no auth, no quota. Use this as a cheap health check to detect a stuck seeder. v1 covers market freshness only; an aggregate freshness resource spanning energy + maritime + risk feeds is a follow-up if customers ask.',
    mimeType: 'application/json',
    read: readMarketFreshness,
  },
];

// ---------------------------------------------------------------------------
// Template (data-bearing, gated, quota-symmetric) resources
//   β†’ resources/templates/list
// ---------------------------------------------------------------------------
// URI parsing is hand-rolled: three templates don't justify a URI-template
// library. Each paramExtractor returns null when the URI doesn't even start
// with the right prefix (cheap reject), an {ok: false, reason} when the
// shape matches but a component is invalid, or an {ok: true, args} when
// the URI resolves cleanly to synthetic tools/call arguments.
export const TEMPLATE_RESOURCE_REGISTRY: TemplateResourceDef[] = [
  {
    uriTemplate: 'worldmonitor://countries/{iso2}/risk',
    name: 'Country Risk',
    description: 'Composite Instability Index (CII) score 0–100 with unrest/conflict/security/news components, travel-advisory level, and OFAC sanctions exposure for a single ISO 3166-1 alpha-2 country. URI param {iso2} is lowercase alpha-2 (e.g. "de", "us", "ir").',
    mimeType: 'application/json',
    tool: 'get_country_risk',
    // RPC tool β€” wrap freshness against the regional-snapshot-canonical
    // risk-scores seed-meta key (30min budget matches the upstream cadence).
    freshnessWrap: { seedMetaKey: 'seed-meta:intelligence:risk-scores', maxStaleMin: 30 },
    paramExtractor: (uri: string) => {
      if (!uri.startsWith('worldmonitor://countries/')) return null;
      const m = /^worldmonitor:\/\/countries\/([a-z]{2})\/risk$/.exec(uri);
      const iso2 = m?.[1];
      if (!iso2) {
        return {
          ok: false,
          reason: 'Expected worldmonitor://countries/{iso2}/risk where {iso2} is lowercase ISO 3166-1 alpha-2.',
        };
      }
      return { ok: true, args: { country_code: iso2.toUpperCase() } };
    },
  },
  {
    uriTemplate: 'worldmonitor://chokepoints/{slug}/status',
    name: 'Chokepoint Status',
    description: 'Maritime chokepoint transit summary: today total / tanker / cargo counts, week-over-week change, risk level, incident count, disruption percentage, and risk narrative. URI param {slug} is one of the hand-curated kebab-case identifiers (suez, strait-of-malacca, strait-of-hormuz, bab-el-mandeb, panama-canal, taiwan-strait, cape-of-good-hope, strait-of-gibraltar, bosphorus, korea-strait, dover-strait, kerch-strait, lombok-strait).',
    mimeType: 'application/json',
    tool: 'get_chokepoint_status',
    paramExtractor: (uri: string) => {
      if (!uri.startsWith('worldmonitor://chokepoints/')) return null;
      const m = /^worldmonitor:\/\/chokepoints\/([a-z][a-z0-9-]*)\/status$/.exec(uri);
      const slug = m?.[1];
      if (!slug) {
        return {
          ok: false,
          reason: 'Expected worldmonitor://chokepoints/{slug}/status where {slug} is a hand-curated kebab-case identifier.',
        };
      }
      const matcher = CHOKEPOINT_SLUGS[slug];
      if (!matcher) {
        const known = Object.keys(CHOKEPOINT_SLUGS).join(', ');
        return { ok: false, reason: `Unknown chokepoint slug "${slug}". Known slugs: [${known}].` };
      }
      // Project envelope-only via a fixed jmespath argument is NOT applied
      // here β€” chokepoint status callers want the transit-summaries data
      // body, not just the freshness envelope. The cacheEnvelope from
      // get_chokepoint_status already includes {cached_at, stale}.
      return { ok: true, args: { chokepoint: matcher } };
    },
  },
  {
    uriTemplate: 'worldmonitor://markets/{symbol}/quote',
    name: 'Market Quote',
    description: 'Single-symbol quote slice from the market-data bootstrap cache. URI param {symbol} is the uppercase ticker (e.g. "AAPL", "GC=F", "BTC-USD"). Matches equity / commodity / crypto / Gulf / sector / ETF-flow tickers β€” same case-insensitive matcher as get_market_data({symbols: [...]}).',
    mimeType: 'application/json',
    tool: 'get_market_data',
    paramExtractor: (uri: string) => {
      if (!uri.startsWith('worldmonitor://markets/')) return null;
      // Symbol grammar: leading uppercase letter, then up to 15 more
      // uppercase letters / digits / dash / equals / dot. Covers AAPL,
      // BTC-USD, GC=F, BRK.B. Lowercase tickers are explicitly invalid β€”
      // canonical wire shape from the bootstrap cache is uppercase.
      const m = /^worldmonitor:\/\/markets\/([A-Z][A-Z0-9.=-]{0,15})\/quote$/.exec(uri);
      const symbol = m?.[1];
      if (!symbol) {
        return {
          ok: false,
          reason: 'Expected worldmonitor://markets/{symbol}/quote where {symbol} is an uppercase ticker (e.g. "AAPL", "GC=F", "BTC-USD").',
        };
      }
      return { ok: true, args: { symbols: [symbol], asset_class: ['equity', 'commodity', 'crypto', 'gulf', 'etf', 'sectors'] } };
    },
  },
];

// ---------------------------------------------------------------------------
// Public list shapes
// ---------------------------------------------------------------------------
// Per MCP spec, resources/list entries carry {uri, name, description,
// mimeType} and resources/templates/list entries carry {uriTemplate, name,
// description, mimeType}. Internal authoring fields (tool, paramExtractor,
// freshnessWrap, read) stay internal.
export interface PublicResourceShape {
  uri: string;
  name: string;
  description: string;
  mimeType: string;
}

export interface ResourceTemplateShape {
  uriTemplate: string;
  name: string;
  description: string;
  mimeType: string;
}

export const RESOURCE_LIST_RESPONSE: PublicResourceShape[] = PUBLIC_RESOURCE_REGISTRY.map((r) => ({
  uri: r.uri,
  name: r.name,
  description: r.description,
  mimeType: r.mimeType,
}));

export const RESOURCE_TEMPLATE_LIST_RESPONSE: ResourceTemplateShape[] = TEMPLATE_RESOURCE_REGISTRY.map((r) => ({
  uriTemplate: r.uriTemplate,
  name: r.name,
  description: r.description,
  mimeType: r.mimeType,
}));

// Exact-match set of the concrete public URIs. The handler consults this to
// decide whether a `resources/read` is anonymously servable β€” ONLY the exact
// concrete URIs in PUBLIC_RESOURCE_REGISTRY qualify; a template
// instantiation (country risk, chokepoint, market quote) never does, so the
// data-leak / quota-bypass protection on those is untouched.
const PUBLIC_RESOURCE_URIS: ReadonlySet<string> = new Set(PUBLIC_RESOURCE_REGISTRY.map((r) => r.uri));

export function isPublicResourceUri(uri: unknown): boolean {
  return typeof uri === 'string' && PUBLIC_RESOURCE_URIS.has(uri);
}

// ---------------------------------------------------------------------------
// resources/read β€” public (anonymous, quota-exempt) dispatcher
// ---------------------------------------------------------------------------
// Serves a concrete PUBLIC_RESOURCE_REGISTRY entry via its direct `read()`.
// No auth context, no dispatchToolsCall, no Pro reservation β€” the content is
// metadata-only (a freshness envelope), so this is safe to serve to an
// anonymous caller, mirroring `prompts/list` / `describe_tool`. The handler
// only routes a request here when `isPublicResourceUri(uri)` is true, so the
// `-32602` fallback below is a fail-explicit guard for a broken invariant.
export async function buildPublicResourceResponse(
  body: { id?: unknown; params?: unknown },
  corsHeaders: Record<string, string>,
): Promise<Response> {
  const outerId = body.id ?? null;
  const params = body.params as { uri?: unknown } | null;
  if (!params || typeof params.uri !== 'string') {
    return rpcError(outerId, -32602, 'Invalid params: missing resource uri', corsHeaders);
  }
  const def = PUBLIC_RESOURCE_REGISTRY.find((r) => r.uri === params.uri);
  if (!def) {
    return rpcError(outerId, -32602, `Unknown public resource uri "${params.uri}".`, corsHeaders);
  }
  // `read()` is documented "MUST be robust", but enforce it at the boundary so
  // a future PUBLIC_RESOURCE_REGISTRY entry whose reader throws surfaces a clean
  // -32603 (mirroring the sibling fail-explicit guards in buildResourceResponse)
  // instead of bubbling an unhandled rejection through mcpHandler to the edge
  // runtime. The current reader (readMarketFreshness) already catches internally.
  let text: string;
  try {
    text = await def.read();
  } catch {
    return rpcError(outerId, -32603, 'Internal error: resource reader failed', corsHeaders);
  }
  return rpcOk(
    outerId,
    { contents: [{ uri: def.uri, mimeType: def.mimeType, text }] },
    corsHeaders,
  );
}

// ---------------------------------------------------------------------------
// resources/read β€” gated (auth + quota-symmetric) template dispatcher
// ---------------------------------------------------------------------------
// Resolves a concrete template instantiation to its content by synthesizing a
// tools/call body and invoking dispatchToolsCall β€” that path runs the same
// Pro daily-quota reservation, telemetry emission, and per-tool budget gate
// the tools/call surface does, so auth + quota symmetry is structural rather
// than duplicated. Resource-shape wrapping happens AFTER dispatch returns:
//   1. Match the URI against a template; -32602 on no-match or malformed
//      component.
//   2. Synthesize a tools/call JSON-RPC body with the matched tool +
//      extracted args.
//   3. Await dispatchToolsCall β€” Response back is the standard JSON-RPC
//      envelope. Bubble up error envelopes (auth, quota cap exceeded,
//      tool errors, budget exceeded) by re-emitting them under the
//      OUTER id.
//   4. On success: extract the dispatcher's content[0].text. For cache-
//      tool-backed resources this already contains the cacheEnvelope
//      `{cached_at, stale, data}`. For RPC-tool-backed resources (just
//      country risk), read the configured seed-meta key and wrap with
//      `{cached_at, stale, ...rawPayload}` so the freshness contract
//      holds uniformly.
//   5. Re-emit as resources/read shape: `{contents: [{uri, mimeType, text}]}`
//      under the outer id, preserving the standard rpcOk envelope.
export async function buildResourceResponse(
  req: Request,
  context: McpAuthContext,
  deps: McpHandlerDeps,
  body: { id?: unknown; params?: unknown },
  corsHeaders: Record<string, string>,
  ctx?: { waitUntil: (p: Promise<unknown>) => void },
  // Forwarded verbatim to the dispatcher so a template read is capped at the
  // caller's PLAN allowance, exactly like the equivalent tools/call. Dropping
  // it here would reopen the quota asymmetry this path exists to close.
  mcpDailyLimit?: number | null,
): Promise<Response> {
  const outerId = body.id ?? null;
  const params = body.params as { uri?: unknown } | null;
  if (!params || typeof params.uri !== 'string') {
    return rpcError(outerId, -32602, 'Invalid params: missing resource uri', corsHeaders);
  }
  const uri = params.uri;

  // Find the first template entry whose paramExtractor returns non-null.
  // null = prefix mismatch (try next entry). ok:false = prefix matched but
  // component invalid (terminate with -32602). ok:true = resolved.
  let matched: { def: TemplateResourceDef; args: Record<string, unknown> } | null = null;
  let lastReason: string | null = null;
  for (const def of TEMPLATE_RESOURCE_REGISTRY) {
    const r = def.paramExtractor(uri);
    if (r === null) continue;
    if (!r.ok) {
      lastReason = r.reason;
      // Don't try further entries β€” the prefix matched, so this entry is
      // the one the caller meant. The reason explains the malformed
      // component (unknown slug, bad iso2 case, etc.).
      break;
    }
    matched = { def, args: r.args };
    break;
  }
  if (!matched) {
    const msg = lastReason
      ?? `Unknown resource uri "${uri}". Issue resources/list (concrete resources) and resources/templates/list (parameterised URI templates) to discover the supported URI shapes.`;
    return rpcError(outerId, -32602, msg, corsHeaders);
  }

  // Synthesize a tools/call body. The inner id is internal β€” never reaches
  // the wire β€” but dispatchToolsCall threads it through, so use a stable
  // sentinel for debuggability if a telemetry line leaks it.
  const innerBody = {
    id: '__resources_read__',
    params: { name: matched.def.tool, arguments: matched.args },
  };

  // dispatchToolsCall handles auth-symmetric quota reservation, per-tool
  // budget gate, and telemetry emission. Returns a Response with
  // the standard JSON-RPC envelope. We parse, repackage, and re-emit
  // under the OUTER id.
  const dispatched = await dispatchToolsCall(req, context, deps, innerBody, corsHeaders, ctx, mcpDailyLimit);

  // Parse the dispatched body. dispatched.json() is safe β€” the dispatcher
  // always emits JSON-RPC, never streams or returns null bodies for these
  // success/error cases.
  const innerBodyParsed: {
    error?: { code: number; message: string };
    result?: { content?: Array<{ type?: string; text?: string }> };
  } = await dispatched.json();

  if (innerBodyParsed.error) {
    // Preserve the inner code (quota -32029, budget-exceeded comes back as
    // a 200 with _budget_exceeded inside content[0].text β€” handled below
    // as a success-shape envelope, not an error β€” see PR 4 design).
    //
    // Forward Retry-After from the inner response so quota-exhaustion
    // (429 with seconds-until-UTC-midnight) and reservation-failure (503
    // with 5s) honour the same client back-off contract tools/call does.
    // Without this, a correctly-implemented client back-off would retry
    // immediately on resources/read while waiting correctly on tools/call
    // β€” directly contradicting the auth-symmetry contract.
    const errorHeaders: Record<string, string> = withMcpNoStore({ 'Content-Type': 'application/json', ...corsHeaders });
    const retryAfter = dispatched.headers.get('Retry-After');
    if (retryAfter !== null) errorHeaders['Retry-After'] = retryAfter;
    return new Response(
      JSON.stringify({ jsonrpc: '2.0', id: outerId, error: innerBodyParsed.error }),
      { status: dispatched.status, headers: errorHeaders },
    );
  }

  const innerText = innerBodyParsed.result?.content?.[0]?.text;
  if (typeof innerText !== 'string') {
    return rpcError(outerId, -32603, 'Internal error: resource dispatcher returned no text payload', corsHeaders);
  }

  // Freshness wrap. Cache-tool-backed resources already carry
  // `{cached_at, stale, data}` from the cacheEnvelope; pass through
  // unchanged. RPC-tool-backed resources (just country risk) need an
  // explicit wrap against the configured seed-meta key.
  let wrappedText: string;
  if (matched.def.freshnessWrap) {
    let rawPayload: unknown;
    try {
      rawPayload = JSON.parse(innerText);
    } catch {
      // A parse failure means the underlying RPC returned non-JSON,
      // which should already have been a -32603 inside the dispatcher β€”
      // defensive fallback: surface as -32603.
      return rpcError(outerId, -32603, 'Internal error: resource payload was not valid JSON', corsHeaders);
    }
    // Soft-error envelopes (PR 4 _budget_exceeded, PR 1.4 _jmespath_error)
    // come back as 200 with the sentinel inside content[0].text β€” NOT as
    // a JSON-RPC error. Pass these through unwrapped so the structured
    // sentinel survives. Merging with {cached_at, stale} would otherwise
    // produce a hybrid shape where the soft-error sentinel sits alongside
    // freshness fields, and clients that detect via top-level key
    // presence would see "valid-looking" content with the error buried
    // as an inner field.
    if (
      rawPayload !== null
      && typeof rawPayload === 'object'
      && !Array.isArray(rawPayload)
      && (('_budget_exceeded' in rawPayload) || ('_jmespath_error' in rawPayload))
    ) {
      wrappedText = innerText;
    } else {
      const { seedMetaKey, maxStaleMin } = matched.def.freshnessWrap;
      const meta = await readJsonFromUpstash(seedMetaKey).catch(() => null);
      const { cached_at, stale } = evaluateFreshness(
        [{ key: seedMetaKey, maxStaleMin }],
        [meta],
      );
      // Merge envelope ahead of payload fields so the standard shape is
      // visible first when humans inspect the response.
      const merged = (rawPayload !== null && typeof rawPayload === 'object' && !Array.isArray(rawPayload))
        ? { cached_at, stale, ...(rawPayload as Record<string, unknown>) }
        : { cached_at, stale, data: rawPayload };
      wrappedText = JSON.stringify(merged);
    }
  } else {
    wrappedText = innerText;
  }

  return rpcOk(
    outerId,
    {
      contents: [{ uri, mimeType: matched.def.mimeType, text: wrappedText }],
    },
    corsHeaders,
  );
}