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,
);
}
|