mattrguerra/meridian-orders-mcpmainPrivate on GitHub, published here in full

http.ts

Language TypeScript4.3 KB118 lines

mcp-server/src/lib/http.ts

  1. import { assertLiveRequestsAllowed, type PlinthConfig } from "./config.js";
  2. import { PlinthApiError, RateLimitError } from "./errors.js";
  3. import {
  4. assertReadMethod,
  5. assertSafeParams,
  6. assertUrlIsRead,
  7. RESERVED_PARAM_KEYS,
  8. type ReadMethod,
  9. } from "./guard.js";
  10. import { RATE_LIMIT_RETRY_MS, sleep, type RateLimiter } from "./rateLimit.js";
  11. /** Ambient context for a Plinth request: env-derived config + optional limiter. */
  12. export interface PlinthRequestContext {
  13. config: PlinthConfig;
  14. limiter?: RateLimiter;
  15. }
  16. /** Query params (excluding `method` and `api_key`, which are added here). */
  17. export type PlinthParams = Record<string, string | number | undefined>;
  18. /**
  19. * Build the request URL for a read method. Pure and side-effect free, so it is
  20. * safe to unit test without touching the network. `api_key` is appended by
  21. * plinthRequest().
  22. *
  23. * Failsafe: server-controlled params (`method`, `api_key`) are skipped in the
  24. * caller loop and `method` is set LAST, so no caller-supplied param can
  25. * overwrite the dispatched method, even through the free-form passthrough tools.
  26. */
  27. export function buildUrl(baseUrl: string, method: ReadMethod, params: PlinthParams): URL {
  28. assertReadMethod(method);
  29. const url = new URL(baseUrl);
  30. for (const [key, value] of Object.entries(params)) {
  31. if (RESERVED_PARAM_KEYS.has(key.trim().toLowerCase())) continue;
  32. if (value !== undefined && value !== "") {
  33. url.searchParams.set(key, String(value));
  34. }
  35. }
  36. // Set last so the dispatched method can never be shadowed by a caller param.
  37. url.searchParams.set("method", method);
  38. return url;
  39. }
  40. /**
  41. * Detect the API's documented error shapes. Success bodies never carry these
  42. * keys, so a hit means the call failed even though HTTP status was 200.
  43. *
  44. * Note: a failed lookup can come back under `error` or as `notice: "No match"`;
  45. * on orders.splitShipments "No match" means "not a multi-address order" rather
  46. * than a hard failure. Callers disambiguate via PlinthApiError.raw.
  47. */
  48. export function detectApiError(body: unknown): PlinthApiError | undefined {
  49. if (body && typeof body === "object") {
  50. const rec = body as Record<string, unknown>;
  51. const message =
  52. (typeof rec.error === "string" && rec.error) ||
  53. (typeof rec.notice === "string" && rec.notice) ||
  54. undefined;
  55. if (message) {
  56. if (/too many requests/i.test(message)) return new RateLimitError(body);
  57. return new PlinthApiError(message, body);
  58. }
  59. }
  60. return undefined;
  61. }
  62. /**
  63. * Execute a single read request against the Plinth API.
  64. *
  65. * Runtime-agnostic: uses only `fetch` and `URL`, both native to the Cloudflare
  66. * Workers isolate and to Node 18+. Config is injected (no globals) so the same
  67. * function runs unchanged on either runtime.
  68. *
  69. * Gated: assertLiveRequestsAllowed() throws unless the operator has explicitly
  70. * opted in (PLINTH_ALLOW_LIVE_REQUESTS=true and an API key).
  71. *
  72. * Rate limiting: when a limiter is supplied on the context, every attempt first
  73. * awaits limiter.acquire(), which delays until a slot is free so the 10/10s cap
  74. * is never exceeded (see lib/rateLimit.ts). As a backstop, a RateLimitError
  75. * that still slips through triggers one retry after a full window.
  76. */
  77. export async function plinthRequest(
  78. ctx: PlinthRequestContext,
  79. method: ReadMethod,
  80. params: PlinthParams = {},
  81. ): Promise<unknown> {
  82. const { config, limiter } = ctx;
  83. assertLiveRequestsAllowed(config);
  84. // Failsafe layers 1 & 2: method must be on the read allowlist, and no
  85. // caller param may carry a reserved key or signal write intent.
  86. assertReadMethod(method);
  87. assertSafeParams(params);
  88. const url = buildUrl(config.baseUrl, method, params);
  89. url.searchParams.set("api_key", config.apiKey as string);
  90. // Failsafe layer 4: final check that the wire URL is a read before any I/O.
  91. assertUrlIsRead(url);
  92. const MAX_ATTEMPTS = 2;
  93. for (let attempt = 1; ; attempt++) {
  94. // Respect the 10/10s cap: the limiter delays until a slot is free.
  95. if (limiter) await limiter.acquire();
  96. const res = await fetch(url, { method: "GET" });
  97. const body: unknown = await res.json();
  98. const apiError = detectApiError(body);
  99. if (!apiError) return body;
  100. // Backstop: a rate-limit error slipped through; wait a window, retry once.
  101. if (apiError instanceof RateLimitError && attempt < MAX_ATTEMPTS) {
  102. await sleep(RATE_LIMIT_RETRY_MS);
  103. continue;
  104. }
  105. throw apiError;
  106. }
  107. }