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

guard.ts

Language TypeScript4.0 KB97 lines

mcp-server/src/lib/guard.ts

  1. /**
  2. * Write-prevention failsafe (defense in depth).
  3. *
  4. * This server is read-only by contract. But the Plinth API is a single dispatch
  5. * endpoint where the `method` query param selects the action, and two tools
  6. * (get_product / get_inventory) accept a free-form passthrough param bag. That
  7. * combination means a caller (including an LLM that fabricates arguments, tries
  8. * to smuggle a write method through the passthrough bag, or guesses a real
  9. * write method name by chance) could otherwise attempt a mutation.
  10. *
  11. * These guards make that structurally impossible. They are independent of the
  12. * TypeScript `ReadMethod` type on purpose: the type is a compile-time promise,
  13. * this is the runtime enforcement that still holds if someone casts to `any`,
  14. * edits a handler, or calls the client from plain JS. Every layer fails closed:
  15. * a violation stops the request rather than letting it through.
  16. *
  17. * Layer 1 assertReadMethod: the dispatched method must be on the allowlist.
  18. * Layer 2 assertSafeParams: no reserved/override keys, no write-intent keys.
  19. * Layer 3 buildUrl (http.ts) sets method/api_key last and skips reserved
  20. * keys, so params can never override them.
  21. * Layer 4 assertUrlIsRead: final check on the built wire URL before fetch.
  22. */
  23. /** The ONLY methods this server may ever dispatch. Frozen source of truth. */
  24. export const READ_METHODS = Object.freeze([
  25. "orders.fetch",
  26. "statuses.list",
  27. "customers.fetch",
  28. "orders.splitShipments",
  29. "products.fetch",
  30. "inventory.fetch",
  31. ] as const);
  32. export type ReadMethod = (typeof READ_METHODS)[number];
  33. const READ_METHOD_SET: ReadonlySet<string> = new Set(READ_METHODS);
  34. /** Params the server sets itself; a caller may never provide or override them. */
  35. export const RESERVED_PARAM_KEYS: ReadonlySet<string> = new Set(["method", "api_key"]);
  36. /**
  37. * Param keys that signal a mutation. Read methods never take these, so their
  38. * presence is treated as an attempted write and rejected. Kept deliberately
  39. * narrow to avoid colliding with legitimate read params (e.g. `email`,
  40. * `status_code`, `shop`, `date_field`).
  41. */
  42. const WRITE_INTENT_KEY =
  43. /^(set|insert|update|delete|destroy|drop|truncate|create|remove|write|save|approve|cancel|notify|adjust)/i;
  44. /** Raised whenever any failsafe layer detects an attempt to leave read-only territory. */
  45. export class WriteAttemptError extends Error {
  46. constructor(message: string) {
  47. super(`Write-prevention failsafe tripped: ${message}`);
  48. this.name = "WriteAttemptError";
  49. }
  50. }
  51. /** Layer 1: runtime backstop for the compile-time ReadMethod type. */
  52. export function assertReadMethod(method: string): asserts method is ReadMethod {
  53. if (!READ_METHOD_SET.has(method)) {
  54. throw new WriteAttemptError(
  55. `method "${method}" is not one of the ${READ_METHODS.length} allowed read methods ` +
  56. `(${READ_METHODS.join(", ")}).`,
  57. );
  58. }
  59. }
  60. /** Layer 2: reject reserved/override keys and any key signaling write intent. */
  61. export function assertSafeParams(params: Record<string, unknown>): void {
  62. for (const key of Object.keys(params)) {
  63. const k = key.trim().toLowerCase();
  64. if (RESERVED_PARAM_KEYS.has(k)) {
  65. throw new WriteAttemptError(
  66. `param "${key}" is reserved (the server sets it) and cannot be supplied by a caller.`,
  67. );
  68. }
  69. if (WRITE_INTENT_KEY.test(k)) {
  70. throw new WriteAttemptError(
  71. `param "${key}" signals write intent; only read parameters are permitted.`,
  72. );
  73. }
  74. }
  75. }
  76. /** Layer 4: final gate on the fully-built URL, immediately before fetch. */
  77. export function assertUrlIsRead(url: URL): void {
  78. const methods = url.searchParams.getAll("method");
  79. if (methods.length !== 1) {
  80. throw new WriteAttemptError(
  81. `built request URL must carry exactly one "method" param (found ${methods.length}).`,
  82. );
  83. }
  84. const [method] = methods;
  85. if (method === undefined || !READ_METHOD_SET.has(method)) {
  86. throw new WriteAttemptError(`built request URL method "${method}" is not an allowed read method.`);
  87. }
  88. }