@classytic/arc 2.15.4 → 2.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +1 -0
  2. package/bin/arc.js +12 -0
  3. package/dist/{BaseController-dx3m2J8V.mjs → BaseController-DlCCTIxJ.mjs} +61 -19
  4. package/dist/{HookSystem-Iiebom92.mjs → HookSystem-Cmf7-Etp.mjs} +8 -4
  5. package/dist/{QueryCache-D41bfdBB.d.mts → QueryCache-SvmT_9ti.d.mts} +1 -1
  6. package/dist/{ResourceRegistry-CTERg_2x.mjs → ResourceRegistry-f48hFk3m.mjs} +52 -9
  7. package/dist/audit/index.d.mts +1 -1
  8. package/dist/audit/index.mjs +4 -2
  9. package/dist/auth/index.d.mts +4 -4
  10. package/dist/auth/index.mjs +4 -4
  11. package/dist/auth/redis-session.d.mts +1 -1
  12. package/dist/{betterAuthOpenApi--M_i87dQ.mjs → betterAuthOpenApi-ClWxaceA.mjs} +10 -6
  13. package/dist/buildHandler-BZX6zzDM.mjs +300 -0
  14. package/dist/cache/index.d.mts +3 -3
  15. package/dist/cache/index.mjs +3 -3
  16. package/dist/{caching-SM8gghN6.mjs → caching-TeHE8G-v.mjs} +1 -1
  17. package/dist/cli/commands/describe.d.mts +35 -1
  18. package/dist/cli/commands/describe.mjs +52 -12
  19. package/dist/cli/commands/docs.d.mts +1 -4
  20. package/dist/cli/commands/docs.mjs +4 -16
  21. package/dist/cli/commands/generate.d.mts +2 -20
  22. package/dist/cli/commands/generate.mjs +1 -546
  23. package/dist/cli/commands/init.d.mts +2 -40
  24. package/dist/cli/commands/init.mjs +1 -3045
  25. package/dist/cli/commands/introspect.mjs +53 -64
  26. package/dist/cli/index.d.mts +2 -2
  27. package/dist/cli/index.mjs +2 -2
  28. package/dist/{constants-Cxde4rpC.mjs → constants-TrJVIJl0.mjs} +7 -0
  29. package/dist/core/index.d.mts +3 -3
  30. package/dist/core/index.mjs +5 -5
  31. package/dist/{core-CvmOqEms.mjs → core-DBJ_j6rX.mjs} +222 -44
  32. package/dist/createActionRouter-DUpN3Dd1.mjs +288 -0
  33. package/dist/{createAggregationRouter-B0bPDf5b.mjs → createAggregationRouter-Dq-TUCuY.mjs} +3 -2
  34. package/dist/{createApp-PFegs47-.mjs → createApp-DNccuhyI.mjs} +16 -14
  35. package/dist/{defineEvent-D5h7EvAx.mjs → defineEvent-DRwY0fYm.mjs} +1 -1
  36. package/dist/docs/index.d.mts +2 -2
  37. package/dist/docs/index.mjs +1 -1
  38. package/dist/{errorHandler-Bk-AGhkU.mjs → errorHandler-DpoXQHZ9.mjs} +17 -14
  39. package/dist/errors-C1lX_jlm.d.mts +91 -0
  40. package/dist/{eventPlugin-CaKTYkYM.mjs → eventPlugin-C2cGqtRO.mjs} +1 -1
  41. package/dist/{eventPlugin-qXpqTebY.d.mts → eventPlugin-CtHC_av1.d.mts} +1 -1
  42. package/dist/events/index.d.mts +3 -3
  43. package/dist/events/index.mjs +5 -5
  44. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  45. package/dist/events/transports/redis.d.mts +1 -1
  46. package/dist/factory/index.d.mts +1 -1
  47. package/dist/factory/index.mjs +2 -2
  48. package/dist/{fields-COhcH3fk.d.mts → fields-Anj0xdih.d.mts} +1 -1
  49. package/dist/generate-BWFwgcCM.d.mts +38 -0
  50. package/dist/generate-CYac-OLv.mjs +654 -0
  51. package/dist/hooks/index.d.mts +1 -1
  52. package/dist/hooks/index.mjs +1 -1
  53. package/dist/idempotency/index.d.mts +2 -2
  54. package/dist/idempotency/index.mjs +1 -1
  55. package/dist/idempotency/redis.d.mts +1 -1
  56. package/dist/{index-BTqLEvhu.d.mts → index-3oIimXQn.d.mts} +12 -12
  57. package/dist/{index-BstGxcc3.d.mts → index-B-ulKx5P.d.mts} +55 -4
  58. package/dist/{index-BswOSJCE.d.mts → index-CkW0flkU.d.mts} +355 -16
  59. package/dist/index.d.mts +6 -6
  60. package/dist/index.mjs +7 -8
  61. package/dist/init-Dv71MsJr.d.mts +71 -0
  62. package/dist/init-HDvoO9L5.mjs +3098 -0
  63. package/dist/integrations/event-gateway.d.mts +2 -2
  64. package/dist/integrations/event-gateway.mjs +1 -1
  65. package/dist/integrations/index.d.mts +2 -2
  66. package/dist/integrations/jobs.mjs +3 -3
  67. package/dist/integrations/mcp/index.d.mts +239 -7
  68. package/dist/integrations/mcp/index.mjs +2 -528
  69. package/dist/integrations/mcp/testing.d.mts +2 -2
  70. package/dist/integrations/mcp/testing.mjs +6 -10
  71. package/dist/integrations/streamline.mjs +26 -1
  72. package/dist/integrations/websocket-redis.d.mts +1 -1
  73. package/dist/integrations/websocket.d.mts +1 -1
  74. package/dist/integrations/websocket.mjs +1 -0
  75. package/dist/loadResourcesFromEntry-BLMEI2Xa.mjs +51 -0
  76. package/dist/{resourceToTools-tFYUNmM0.mjs → mcpPlugin-7vGV51ED.mjs} +1021 -318
  77. package/dist/{memory-UBydS5ku.mjs → memory-QOLe11D5.mjs} +2 -0
  78. package/dist/middleware/index.d.mts +1 -1
  79. package/dist/middleware/index.mjs +1 -1
  80. package/dist/{openapi-BHXhoX8O.mjs → openapi-34T9yNwd.mjs} +47 -36
  81. package/dist/permissions/index.d.mts +2 -2
  82. package/dist/permissions/index.mjs +1 -1
  83. package/dist/{permissions-ohQyv50e.mjs → permissions-CTxMrreC.mjs} +2 -2
  84. package/dist/{pipe-Zr0KXjQe.mjs → pipe-DiCyvyPN.mjs} +1 -0
  85. package/dist/pipeline/index.d.mts +1 -1
  86. package/dist/pipeline/index.mjs +1 -1
  87. package/dist/plugins/index.d.mts +5 -5
  88. package/dist/plugins/index.mjs +10 -10
  89. package/dist/plugins/response-cache.mjs +5 -5
  90. package/dist/plugins/tracing-entry.d.mts +1 -1
  91. package/dist/plugins/tracing-entry.mjs +1 -1
  92. package/dist/{pluralize-DQgqgifU.mjs → pluralize-B9M8xvy-.mjs} +2 -1
  93. package/dist/presets/filesUpload.d.mts +4 -4
  94. package/dist/presets/filesUpload.mjs +2 -2
  95. package/dist/presets/index.d.mts +1 -1
  96. package/dist/presets/index.mjs +1 -1
  97. package/dist/presets/multiTenant.d.mts +1 -1
  98. package/dist/presets/multiTenant.mjs +4 -3
  99. package/dist/presets/search.d.mts +2 -2
  100. package/dist/presets/search.mjs +1 -1
  101. package/dist/{presets-BbkjdPeH.mjs → presets-C9BE6WaZ.mjs} +2 -2
  102. package/dist/{queryCachePlugin-m1XsgAIJ.mjs → queryCachePlugin-B4XMSSe7.mjs} +2 -2
  103. package/dist/{queryCachePlugin-CqMdLI2-.d.mts → queryCachePlugin-Biqzfbi5.d.mts} +2 -2
  104. package/dist/{redis-DiMkdHEl.d.mts → redis-Cyzrz6SX.d.mts} +1 -1
  105. package/dist/{redis-stream-D6HzR1Z_.d.mts → redis-stream-DT-YjzrB.d.mts} +1 -1
  106. package/dist/registry/index.d.mts +319 -2
  107. package/dist/registry/index.mjs +3 -3
  108. package/dist/registry-BBE23CDj.mjs +576 -0
  109. package/dist/{routerShared-DrOa-26E.mjs → routerShared-CZV5aabX.mjs} +3 -3
  110. package/dist/scope/index.d.mts +3 -3
  111. package/dist/scope/index.mjs +3 -3
  112. package/dist/{sse-Bz-5ZeTt.mjs → sse-BY6sTy4P.mjs} +1 -1
  113. package/dist/testing/index.d.mts +2 -2
  114. package/dist/testing/index.mjs +16 -7
  115. package/dist/testing/storageContract.d.mts +1 -1
  116. package/dist/types/index.d.mts +5 -5
  117. package/dist/types/storage.d.mts +1 -1
  118. package/dist/{types-C_s5moIu.mjs → types-Bi0r0vjG.mjs} +53 -1
  119. package/dist/{types-BQsjgQzS.d.mts → types-BsJMEQ4D.d.mts} +106 -12
  120. package/dist/{types-DrBaUwyV.d.mts → types-D-fYtKjb.d.mts} +33 -10
  121. package/dist/{types-CTYvcwHe.d.mts → types-DVfpSfx2.d.mts} +42 -1
  122. package/dist/utils/index.d.mts +1286 -2
  123. package/dist/utils/index.mjs +1 -1
  124. package/dist/{utils-_h9B3c57.mjs → utils-DC5ycPfr.mjs} +89 -40
  125. package/dist/{buildHandler-CcFOpJLh.mjs → validate-By96rH0r.mjs} +8 -299
  126. package/dist/{versioning-hmkPcDlX.d.mts → versioning-ZwX9tmbS.d.mts} +1 -1
  127. package/package.json +21 -28
  128. package/skills/arc/SKILL.md +300 -706
  129. package/skills/arc/references/auth.md +19 -7
  130. package/skills/arc-code-review/SKILL.md +1 -1
  131. package/skills/arc-code-review/references/arc-cheatsheet.md +100 -322
  132. package/dist/createActionRouter-S3MLVYot.mjs +0 -220
  133. package/dist/index-bRjYu21O.d.mts +0 -1320
  134. package/dist/org/index.d.mts +0 -66
  135. package/dist/org/index.mjs +0 -486
  136. package/dist/org/types.d.mts +0 -82
  137. package/dist/org/types.mjs +0 -1
  138. package/dist/registry-I-ogLgL9.mjs +0 -46
  139. /package/dist/{EventTransport-CT_52aWU.d.mts → EventTransport-C-2oAHtw.d.mts} +0 -0
  140. /package/dist/{EventTransport-DLWoUMHy.mjs → EventTransport-Hxvv5QQz.mjs} +0 -0
  141. /package/dist/{actionPermissions-CyUkQu6O.mjs → actionPermissions-Bjmvn7Eb.mjs} +0 -0
  142. /package/dist/{elevation-BXOWoGCF.d.mts → elevation-0YBpa663.d.mts} +0 -0
  143. /package/dist/{elevation-DgoeTyfX.mjs → elevation-Dci0AYLT.mjs} +0 -0
  144. /package/dist/{errorHandler-DFr45ZG4.d.mts → errorHandler-mHuyWzZE.d.mts} +0 -0
  145. /package/dist/{externalPaths-BD5nw6St.d.mts → externalPaths-DFg-2KTp.d.mts} +0 -0
  146. /package/dist/{interface-beEtJyWM.d.mts → interface-CH0OQudo.d.mts} +0 -0
  147. /package/dist/{interface-DfLGcus7.d.mts → interface-NwJ_qPlY.d.mts} +0 -0
  148. /package/dist/{keys-CGcCbNyu.mjs → keys-DopsCuyQ.mjs} +0 -0
  149. /package/dist/{loadResources-DBMQg_Aj.mjs → loadResources-ChQEj8ih.mjs} +0 -0
  150. /package/dist/{metrics-Qnvwc-LQ.mjs → metrics-TuOmguhi.mjs} +0 -0
  151. /package/dist/{replyHelpers-CK-FNO8E.mjs → replyHelpers-C-gD32oF.mjs} +0 -0
  152. /package/dist/{schemaIR-lYhC2gE5.mjs → schemaIR-Ctc89DSn.mjs} +0 -0
  153. /package/dist/{sessionManager-C4Le_UB3.d.mts → sessionManager-BqFegc0W.d.mts} +0 -0
  154. /package/dist/{storage-Dfzt4VTl.d.mts → storage-D2KZJAmn.d.mts} +0 -0
  155. /package/dist/{store-helpers-BkIN9-vu.mjs → store-helpers-B0sunfZZ.mjs} +0 -0
  156. /package/dist/{tracing-QJVprktp.d.mts → tracing-Dm8n7Cnn.d.mts} +0 -0
  157. /package/dist/{versioning-BUrT5aP4.mjs → versioning-B6mimogM.mjs} +0 -0
  158. /package/dist/{websocket-ChC2rqe1.d.mts → websocket-BkjeGZRn.d.mts} +0 -0
@@ -1,13 +1,16 @@
1
1
  import { p as isArcError } from "./errors-j4aJm1Wg.mjs";
2
- import { t as BaseController } from "./BaseController-dx3m2J8V.mjs";
3
- import { L as normalizePermissionResult } from "./permissions-ohQyv50e.mjs";
4
- import { t as executePipeline } from "./pipe-Zr0KXjQe.mjs";
5
- import { u as resolvePipelineSteps } from "./routerShared-DrOa-26E.mjs";
6
- import { n as executeAggregation, r as validateAggregations } from "./buildHandler-CcFOpJLh.mjs";
7
- import { t as resolveActionPermission } from "./actionPermissions-CyUkQu6O.mjs";
8
- import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-lYhC2gE5.mjs";
9
- import { t as pluralize } from "./pluralize-DQgqgifU.mjs";
2
+ import { t as BaseController } from "./BaseController-DlCCTIxJ.mjs";
3
+ import { L as normalizePermissionResult } from "./permissions-CTxMrreC.mjs";
4
+ import { t as executePipeline } from "./pipe-DiCyvyPN.mjs";
5
+ import { u as resolvePipelineSteps } from "./routerShared-CZV5aabX.mjs";
6
+ import { r as validateAggregations } from "./validate-By96rH0r.mjs";
7
+ import { n as executeAggregation } from "./buildHandler-BZX6zzDM.mjs";
8
+ import { t as pluralize } from "./pluralize-B9M8xvy-.mjs";
9
+ import { t as resolveActionPermission } from "./actionPermissions-Bjmvn7Eb.mjs";
10
+ import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-Ctc89DSn.mjs";
11
+ import { createHash, randomUUID } from "node:crypto";
10
12
  import { isHttpError, toErrorContract } from "@classytic/repo-core/errors";
13
+ import fp from "fastify-plugin";
11
14
  import { z } from "zod";
12
15
  //#region src/integrations/mcp/createMcpServer.ts
13
16
  /**
@@ -71,147 +74,6 @@ function registerPrompt(server, prompt) {
71
74
  srv.registerPrompt(prompt.name, config, (args) => prompt.handler(args));
72
75
  }
73
76
  //#endregion
74
- //#region src/integrations/mcp/fieldRulesToZod.ts
75
- /**
76
- * @classytic/arc — fieldRules → Zod Shape Converter
77
- *
78
- * Converts Arc's schemaOptions.fieldRules into flat Zod shapes
79
- * compatible with the MCP SDK's registerTool() inputSchema format.
80
- *
81
- * Returns `Record<string, z.ZodTypeAny>` (flat shape), NOT z.object().
82
- * The SDK wraps it internally.
83
- *
84
- * @example
85
- * ```typescript
86
- * import { fieldRulesToZod } from '@classytic/arc/mcp';
87
- *
88
- * const shape = fieldRulesToZod(resource.schemaOptions.fieldRules, {
89
- * mode: 'create',
90
- * hiddenFields: resource.schemaOptions.hiddenFields,
91
- * });
92
- * // shape = { name: z.string(), price: z.number() }
93
- * ```
94
- */
95
- const PAGINATION_SHAPE = {
96
- page: z.number().int().min(1).optional().describe("Page number (1-based)"),
97
- limit: z.number().int().min(1).max(100).optional().describe("Items per page (max 100)"),
98
- sort: z.string().optional().describe("Sort field, prefix with - for descending"),
99
- search: z.string().optional().describe("Full-text search query"),
100
- select: z.string().optional().describe("Comma-separated field list to project (e.g. 'name,price'). Prefix with '-' to exclude (e.g. '-description')."),
101
- populate: z.string().optional().describe("Comma-separated relation paths to hydrate (e.g. 'supplier,category'). Follows Mongoose populate syntax when the adapter is MongoKit.")
102
- };
103
- /**
104
- * Convert Arc fieldRules to a flat Zod shape.
105
- *
106
- * @returns Flat shape `Record<string, z.ZodTypeAny>` — pass directly to defineTool() or registerTool()
107
- */
108
- function fieldRulesToZod(fieldRules, options = {}) {
109
- const { mode = "create", hiddenFields = [], readonlyFields = [], extraHideFields = [] } = options;
110
- if (mode === "list") return buildListShape(fieldRules, options);
111
- if (!fieldRules) return {};
112
- const allHidden = new Set([...hiddenFields, ...extraHideFields]);
113
- const allReadonly = new Set(readonlyFields);
114
- const shape = {};
115
- for (const [name, rule] of Object.entries(fieldRules)) {
116
- if (rule.systemManaged || rule.hidden || allHidden.has(name)) continue;
117
- if (allReadonly.has(name)) continue;
118
- if (mode === "update" && rule.immutable) continue;
119
- const field = buildFieldSchema(rule);
120
- if (mode === "update") shape[name] = field.optional();
121
- else shape[name] = rule.required === true && !rule.optional ? field : field.optional();
122
- }
123
- return shape;
124
- }
125
- /** Build Zod type for a single field rule */
126
- function buildFieldSchema(rule) {
127
- if (rule.enum?.length) {
128
- const schema = z.enum(rule.enum);
129
- return rule.description ? schema.describe(rule.description) : schema;
130
- }
131
- const base = typeToZod(rule.type);
132
- if (base instanceof z.ZodString) {
133
- let s = base;
134
- if (rule.minLength != null) s = s.min(rule.minLength);
135
- if (rule.maxLength != null) s = s.max(rule.maxLength);
136
- if (rule.pattern) try {
137
- s = s.regex(new RegExp(rule.pattern));
138
- } catch {}
139
- return rule.description ? s.describe(rule.description) : s;
140
- }
141
- if (base instanceof z.ZodNumber) {
142
- let n = base;
143
- if (rule.min != null) n = n.min(rule.min);
144
- if (rule.max != null) n = n.max(rule.max);
145
- return rule.description ? n.describe(rule.description) : n;
146
- }
147
- return rule.description ? base.describe(rule.description) : base;
148
- }
149
- /** Map Arc field type string to base Zod type */
150
- function typeToZod(type) {
151
- switch (type) {
152
- case "string": return z.string();
153
- case "number": return z.number();
154
- case "boolean": return z.boolean();
155
- case "date": return z.string().describe("ISO 8601 date string");
156
- case "array": return z.array(z.any());
157
- case "object": return z.record(z.string(), z.any());
158
- default: return z.string();
159
- }
160
- }
161
- /** Operators that apply to numeric/date fields */
162
- const COMPARISON_OPS = new Set([
163
- "gt",
164
- "gte",
165
- "lt",
166
- "lte"
167
- ]);
168
- /** Map operator to a human-readable description suffix */
169
- function opDescription(op, fieldName) {
170
- switch (op) {
171
- case "gt": return `${fieldName} greater than`;
172
- case "gte": return `${fieldName} greater than or equal`;
173
- case "lt": return `${fieldName} less than`;
174
- case "lte": return `${fieldName} less than or equal`;
175
- case "ne": return `${fieldName} not equal to`;
176
- case "in": return `${fieldName} in comma-separated list`;
177
- case "nin": return `${fieldName} not in comma-separated list`;
178
- case "exists": return `${fieldName} exists (true/false)`;
179
- default: return `${fieldName} ${op}`;
180
- }
181
- }
182
- /** Build list/query shape with filterable fields, operators, and pagination */
183
- function buildListShape(fieldRules, options) {
184
- const { filterableFields = [], hiddenFields = [], extraHideFields = [], allowedOperators } = options;
185
- const allHidden = new Set([...hiddenFields, ...extraHideFields]);
186
- const shape = { ...PAGINATION_SHAPE };
187
- if (!fieldRules) return shape;
188
- for (const name of filterableFields) {
189
- if (allHidden.has(name)) continue;
190
- const rule = fieldRules[name];
191
- if (!rule) continue;
192
- if (rule.hidden || rule.systemManaged) continue;
193
- const filterField = buildFieldSchema(rule);
194
- shape[name] = filterField.optional();
195
- if (allowedOperators?.length) {
196
- const isNumericOrDate = rule.type === "number" || rule.type === "date";
197
- for (const op of allowedOperators) {
198
- if (COMPARISON_OPS.has(op) && !isNumericOrDate) continue;
199
- if (op === "eq") continue;
200
- if (op === "exists") {
201
- shape[`${name}_${op}`] = z.boolean().optional().describe(opDescription(op, name));
202
- continue;
203
- }
204
- if (op === "in" || op === "nin") {
205
- shape[`${name}_${op}`] = z.string().optional().describe(opDescription(op, name));
206
- continue;
207
- }
208
- shape[`${name}_${op}`] = filterField.optional().describe(opDescription(op, name));
209
- }
210
- }
211
- }
212
- return shape;
213
- }
214
- //#endregion
215
77
  //#region src/integrations/mcp/buildRequestContext.ts
216
78
  /**
217
79
  * Build an IRequestContext from MCP tool input and session auth.
@@ -254,12 +116,15 @@ function buildRequestContext(input, auth, operation, policyFilters, scopeOverrid
254
116
  query: expandOperatorKeys(input),
255
117
  body: void 0
256
118
  };
257
- case "get": return {
258
- ...base,
259
- params: { id: String(input.id ?? "") },
260
- query: {},
261
- body: void 0
262
- };
119
+ case "get": {
120
+ const { id, ...rest } = input;
121
+ return {
122
+ ...base,
123
+ params: { id: String(id ?? "") },
124
+ query: expandOperatorKeys(rest),
125
+ body: void 0
126
+ };
127
+ }
263
128
  case "create": return {
264
129
  ...base,
265
130
  params: {},
@@ -434,85 +299,529 @@ function toCallToolResult(result) {
434
299
  }] };
435
300
  }
436
301
  /**
437
- * Wrap a raw success payload as an MCP `CallToolResult`. Use when the
438
- * tool produced a value directly (action handler return, aggregation
439
- * rows, etc.) instead of an `IControllerResponse` envelope.
302
+ * Wrap a raw success payload as an MCP `CallToolResult`. Use when the
303
+ * tool produced a value directly (action handler return, aggregation
304
+ * rows, etc.) instead of an `IControllerResponse` envelope.
305
+ *
306
+ * Emits the value as JSON with no envelope — same no-envelope contract
307
+ * the HTTP wire follows. The `isError: true` flag on `CallToolResult`
308
+ * is the success/error discriminant for MCP, mirroring HTTP status.
309
+ */
310
+ function toCallToolSuccess(value) {
311
+ return { content: [{
312
+ type: "text",
313
+ text: JSON.stringify(value)
314
+ }] };
315
+ }
316
+ /**
317
+ * Wrap an error as an MCP `CallToolResult` with the canonical
318
+ * `ErrorContract` shape inside the text payload. Single source of truth
319
+ * for MCP error serialization — every tool surface (CRUD, action, route,
320
+ * aggregation) routes through here so the JSON shape an agent sees is
321
+ * identical to what an HTTP client sees.
322
+ *
323
+ * Accepts:
324
+ * - An `ArcError` (or any `HttpError`-shaped throw) → routes through
325
+ * `toErrorContract()` for the canonical conversion.
326
+ * - A partial contract `{code, message, status, details?}` → used as-is.
327
+ * - Any other `Error` → falls back to `arc.internal_error` 500.
328
+ */
329
+ function toCallToolError(input) {
330
+ let contract;
331
+ if (input instanceof Error) if (isArcError(input) || isHttpError(input)) contract = toErrorContract(input);
332
+ else contract = {
333
+ code: "arc.internal_error",
334
+ message: input.message || "Internal Server Error",
335
+ status: 500
336
+ };
337
+ else contract = {
338
+ code: input.code,
339
+ message: input.message,
340
+ ...input.status !== void 0 ? { status: input.status } : {},
341
+ ...input.details ? { details: input.details } : {}
342
+ };
343
+ return {
344
+ content: [{
345
+ type: "text",
346
+ text: JSON.stringify(contract)
347
+ }],
348
+ isError: true
349
+ };
350
+ }
351
+ /**
352
+ * Build the canonical permission-denied `CallToolResult` for an MCP
353
+ * tool. Discriminates 401 (no session — "Authentication required") from
354
+ * 403 (session present, denied — "Permission denied"). Mirrors the
355
+ * status split the HTTP `errorHandler` plugin uses.
356
+ */
357
+ function permissionDeniedResult(args) {
358
+ const authenticated = args.session != null;
359
+ return toCallToolError({
360
+ code: authenticated ? "arc.forbidden" : "arc.unauthorized",
361
+ message: args.reason ?? (authenticated ? `Permission denied for '${args.operation}' on '${args.resource}'` : "Authentication required"),
362
+ status: authenticated ? 403 : 401
363
+ });
364
+ }
365
+ /**
366
+ * Auto-create a BaseController from the resource's adapter for MCP use.
367
+ * Called when the resource has an adapter but no controller
368
+ * (e.g. `disableDefaultRoutes: true` skips controller creation in
369
+ * `defineResource`).
370
+ */
371
+ function createMcpController(resource) {
372
+ const repository = resource.adapter?.repository;
373
+ if (!repository) return void 0;
374
+ return new BaseController(repository, {
375
+ resourceName: resource.name,
376
+ schemaOptions: resource.schemaOptions,
377
+ tenantField: resource.tenantField,
378
+ idField: resource.idField,
379
+ matchesFilter: resource.adapter?.matchesFilter
380
+ });
381
+ }
382
+ //#endregion
383
+ //#region src/integrations/mcp/invokeController.ts
384
+ /**
385
+ * Invoke a controller method through the MCP synthetic-context path.
386
+ *
387
+ * Runs the same chain CRUD MCP tools run:
388
+ * 1. {@link evaluatePermission} — fails with the canonical
389
+ * `arc.unauthorized` / `arc.forbidden` `CallToolResult` shape on denial.
390
+ * 2. {@link buildRequestContext} — projects MCP input + session into
391
+ * `IRequestContext`. Permission `filters` / `scope` thread through
392
+ * identically to CRUD/action routes.
393
+ * 3. `controller[methodName](ctx)` — dispatch. Errors are routed through
394
+ * `toCallToolError` so `ArcError` / `HttpError` throws land as the same
395
+ * `ErrorContract` shape an HTTP client would see.
396
+ *
397
+ * @example Run a controller op from a custom MCP tool.
398
+ * ```ts
399
+ * defineTool('promote_post', {
400
+ * description: 'Promote a draft to published',
401
+ * inputSchema: { id: z.string() },
402
+ * handler: (input, ctx) =>
403
+ * invokeController(postController, 'update', { ...input, status: 'published' }, {
404
+ * session: ctx.session,
405
+ * resourceName: 'post',
406
+ * permissions: requireRoles(['editor']),
407
+ * }),
408
+ * });
409
+ * ```
410
+ *
411
+ * Workflow / scheduled-job callers pass a synthetic `session` they
412
+ * constructed from the job's owning identity — the shape is the same
413
+ * `McpAuthResult` the HTTP MCP plugin produces, so the same permission
414
+ * checks compose.
415
+ */
416
+ async function invokeController(controller, op, input, options) {
417
+ const { session, resourceName, actionName = op, permissions, methodName = op } = options;
418
+ const ctrl = controller;
419
+ try {
420
+ const method = ctrl[methodName];
421
+ if (typeof method !== "function") return toCallToolError({
422
+ code: "arc.not_implemented",
423
+ message: `Method '${methodName}' not available on '${resourceName}' controller`,
424
+ status: 501
425
+ });
426
+ const permResult = await evaluatePermission(permissions, session, resourceName, actionName, input);
427
+ if (permResult && !permResult.granted) return permissionDeniedResult({
428
+ resource: resourceName,
429
+ operation: actionName,
430
+ reason: permResult.reason,
431
+ session
432
+ });
433
+ return toCallToolResult(await method(buildRequestContext(input, session, op, permResult?.filters, permResult?.scope)));
434
+ } catch (err) {
435
+ return toCallToolError(err instanceof Error ? err : new Error(String(err)));
436
+ }
437
+ }
438
+ /**
439
+ * Wrap an async function as a {@link ToolDefinition} handler — handles the
440
+ * try/catch → canonical-error translation in one place so MCP tools that
441
+ * compose multiple internal calls don't reimplement the boilerplate.
442
+ *
443
+ * The wrapped function may return:
444
+ * - A `CallToolResult` — passed through unchanged (use this to short-circuit
445
+ * with `permissionDeniedResult` or a custom error shape).
446
+ * - An `IControllerResponse` envelope (`{ data, meta?, status?, headers? }`)
447
+ * — serialised via `toCallToolResult` so pagination meta etc. survive.
448
+ * - Any other value — serialised as JSON via `toCallToolSuccess` semantics.
449
+ *
450
+ * Errors raised inside the function are caught and routed through
451
+ * `toCallToolError`, so `ArcError` / `HttpError` throws collapse to the
452
+ * canonical `ErrorContract` shape MCP agents see for every other surface.
453
+ *
454
+ * @example Compose two controller calls in one tool.
455
+ * ```ts
456
+ * defineTool('archive_and_log', {
457
+ * description: '...',
458
+ * handler: mcpHandlerAdapter(async (input, ctx) => {
459
+ * await invokeController(postController, 'update', { ...input, archived: true }, opts);
460
+ * return invokeController(auditController, 'create', { event: 'archive', ...input }, opts);
461
+ * }),
462
+ * });
463
+ * ```
464
+ */
465
+ function mcpHandlerAdapter(fn) {
466
+ return async (input, ctx) => {
467
+ try {
468
+ const out = await fn(input, ctx);
469
+ if (isCallToolResult(out)) return out;
470
+ if (isControllerResponse(out)) return toCallToolResult(out);
471
+ return { content: [{
472
+ type: "text",
473
+ text: JSON.stringify(out ?? null)
474
+ }] };
475
+ } catch (err) {
476
+ return toCallToolError(err instanceof Error ? err : new Error(String(err)));
477
+ }
478
+ };
479
+ }
480
+ function isCallToolResult(value) {
481
+ return typeof value === "object" && value !== null && Array.isArray(value.content);
482
+ }
483
+ function isControllerResponse(value) {
484
+ return typeof value === "object" && value !== null && "data" in value && !("content" in value);
485
+ }
486
+ //#endregion
487
+ //#region src/integrations/mcp/crud-tools.ts
488
+ const ALL_CRUD_OPS = [
489
+ "list",
490
+ "get",
491
+ "create",
492
+ "update",
493
+ "delete"
494
+ ];
495
+ const CRUD_ANNOTATIONS = {
496
+ list: { readOnlyHint: true },
497
+ get: { readOnlyHint: true },
498
+ create: { destructiveHint: false },
499
+ update: {
500
+ destructiveHint: true,
501
+ idempotentHint: true
502
+ },
503
+ delete: {
504
+ destructiveHint: true,
505
+ idempotentHint: true
506
+ }
507
+ };
508
+ /**
509
+ * Build a handler that dispatches to the controller method for `op` via
510
+ * the shared {@link invokeController} pipeline. Permission check + context
511
+ * build + envelope/error conversion all live in one place — this factory
512
+ * only resolves the op-specific permission and threads it through.
513
+ */
514
+ function createCrudHandler(op, controller, resourceName, permissions) {
515
+ const permission = permissions?.[op];
516
+ return (input, ctx) => invokeController(controller, op, input, {
517
+ session: ctx.session,
518
+ resourceName,
519
+ permissions: permission
520
+ });
521
+ }
522
+ /**
523
+ * Default description for a CRUD tool. Enriches list descriptions with the
524
+ * configured filter/sort metadata so MCP clients can see what's queryable
525
+ * without reading the resource source.
526
+ *
527
+ * Exposed publicly so authors using the function-form `descriptions`
528
+ * override can call this from their own override to keep the auto-derived
529
+ * blurb intact and only append extra context.
530
+ */
531
+ function defaultCrudDescription(op, displayName, softDelete, queryMeta) {
532
+ const singular = displayName.toLowerCase();
533
+ switch (op) {
534
+ case "list": {
535
+ const parts = [`List ${pluralize(singular)} with optional filters and pagination.`];
536
+ if (queryMeta?.filterableFields?.length) parts.push(`Filterable fields: ${queryMeta.filterableFields.join(", ")}.`);
537
+ if (queryMeta?.allowedOperators?.length) parts.push(`Filter operators: ${queryMeta.allowedOperators.join(", ")} (use field[op]=value syntax).`);
538
+ if (queryMeta?.sortableFields?.length) parts.push(`Sortable fields: ${queryMeta.sortableFields.join(", ")}.`);
539
+ return parts.join(" ");
540
+ }
541
+ case "get": return `Get a single ${singular} by ID`;
542
+ case "create": return `Create a new ${singular}`;
543
+ case "update": return `Update an existing ${singular} by ID`;
544
+ case "delete": return softDelete ? `Delete a ${singular} by ID (soft delete — marks as deleted, not permanently removed)` : `Delete a ${singular} by ID`;
545
+ }
546
+ }
547
+ /**
548
+ * Resolve a {@link CrudDescriptionOverride} (string or function) into a
549
+ * concrete description. Centralises the function-form contract so every
550
+ * call site that consumes an override applies it the same way.
551
+ *
552
+ * Falls back to {@link defaultCrudDescription} when no override is set.
553
+ */
554
+ function resolveCrudDescription(override, meta) {
555
+ if (override === void 0) return meta.defaultDescription;
556
+ if (typeof override === "string") return override;
557
+ return override(meta);
558
+ }
559
+ //#endregion
560
+ //#region src/integrations/mcp/fieldRulesToZod.ts
561
+ /**
562
+ * @classytic/arc — fieldRules → Zod Shape Converter
563
+ *
564
+ * Converts Arc's schemaOptions.fieldRules into flat Zod shapes
565
+ * compatible with the MCP SDK's registerTool() inputSchema format.
566
+ *
567
+ * Returns `Record<string, z.ZodTypeAny>` (flat shape), NOT z.object().
568
+ * The SDK wraps it internally.
569
+ *
570
+ * @example
571
+ * ```typescript
572
+ * import { fieldRulesToZod } from '@classytic/arc/mcp';
573
+ *
574
+ * const shape = fieldRulesToZod(resource.schemaOptions.fieldRules, {
575
+ * mode: 'create',
576
+ * hiddenFields: resource.schemaOptions.hiddenFields,
577
+ * });
578
+ * // shape = { name: z.string(), price: z.number() }
579
+ * ```
580
+ */
581
+ const PAGINATION_SHAPE = {
582
+ page: z.number().int().min(1).optional().describe("Page number (1-based)"),
583
+ limit: z.number().int().min(1).max(100).optional().describe("Items per page (max 100)"),
584
+ sort: z.string().optional().describe("Sort field, prefix with - for descending"),
585
+ search: z.string().optional().describe("Full-text search query"),
586
+ select: z.string().optional().describe("Comma-separated field list to project (e.g. 'name,price'). Prefix with '-' to exclude (e.g. '-description')."),
587
+ populate: z.string().optional().describe("Comma-separated relation paths to hydrate (e.g. 'supplier,category'). Follows Mongoose populate syntax when the adapter is MongoKit.")
588
+ };
589
+ /**
590
+ * Convert Arc fieldRules to a flat Zod shape.
591
+ *
592
+ * @returns Flat shape `Record<string, z.ZodTypeAny>` — pass directly to defineTool() or registerTool()
593
+ */
594
+ function fieldRulesToZod(fieldRules, options = {}) {
595
+ const { mode = "create", hiddenFields = [], readonlyFields = [], extraHideFields = [] } = options;
596
+ if (mode === "list") return buildListShape(fieldRules, options);
597
+ if (!fieldRules) return {};
598
+ const allHidden = new Set([...hiddenFields, ...extraHideFields]);
599
+ const allReadonly = new Set(readonlyFields);
600
+ const shape = {};
601
+ for (const [name, rule] of Object.entries(fieldRules)) {
602
+ if (rule.systemManaged || rule.hidden || allHidden.has(name)) continue;
603
+ if (allReadonly.has(name)) continue;
604
+ if (mode === "update" && rule.immutable) continue;
605
+ const field = buildFieldSchema(rule);
606
+ if (mode === "update") shape[name] = field.optional();
607
+ else shape[name] = rule.required === true && !rule.optional ? field : field.optional();
608
+ }
609
+ return shape;
610
+ }
611
+ /** Build Zod type for a single field rule */
612
+ function buildFieldSchema(rule) {
613
+ if (rule.enum?.length) {
614
+ const schema = z.enum(rule.enum);
615
+ return rule.description ? schema.describe(rule.description) : schema;
616
+ }
617
+ const base = typeToZod(rule.type);
618
+ if (base instanceof z.ZodString) {
619
+ let s = base;
620
+ if (rule.minLength != null) s = s.min(rule.minLength);
621
+ if (rule.maxLength != null) s = s.max(rule.maxLength);
622
+ if (rule.pattern) try {
623
+ s = s.regex(new RegExp(rule.pattern));
624
+ } catch {}
625
+ return rule.description ? s.describe(rule.description) : s;
626
+ }
627
+ if (base instanceof z.ZodNumber) {
628
+ let n = base;
629
+ if (rule.min != null) n = n.min(rule.min);
630
+ if (rule.max != null) n = n.max(rule.max);
631
+ return rule.description ? n.describe(rule.description) : n;
632
+ }
633
+ return rule.description ? base.describe(rule.description) : base;
634
+ }
635
+ /** Map Arc field type string to base Zod type */
636
+ function typeToZod(type) {
637
+ switch (type) {
638
+ case "string": return z.string();
639
+ case "number": return z.number();
640
+ case "boolean": return z.boolean();
641
+ case "date": return z.string().describe("ISO 8601 date string");
642
+ case "array": return z.array(z.any());
643
+ case "object": return z.record(z.string(), z.any());
644
+ default: return z.string();
645
+ }
646
+ }
647
+ /** Operators that apply to numeric/date fields */
648
+ const COMPARISON_OPS = new Set([
649
+ "gt",
650
+ "gte",
651
+ "lt",
652
+ "lte"
653
+ ]);
654
+ /** Map operator to a human-readable description suffix */
655
+ function opDescription(op, fieldName) {
656
+ switch (op) {
657
+ case "gt": return `${fieldName} greater than`;
658
+ case "gte": return `${fieldName} greater than or equal`;
659
+ case "lt": return `${fieldName} less than`;
660
+ case "lte": return `${fieldName} less than or equal`;
661
+ case "ne": return `${fieldName} not equal to`;
662
+ case "in": return `${fieldName} in comma-separated list`;
663
+ case "nin": return `${fieldName} not in comma-separated list`;
664
+ case "exists": return `${fieldName} exists (true/false)`;
665
+ default: return `${fieldName} ${op}`;
666
+ }
667
+ }
668
+ /** Build list/query shape with filterable fields, operators, and pagination */
669
+ function buildListShape(fieldRules, options) {
670
+ const { filterableFields = [], hiddenFields = [], extraHideFields = [], allowedOperators } = options;
671
+ const allHidden = new Set([...hiddenFields, ...extraHideFields]);
672
+ const shape = { ...PAGINATION_SHAPE };
673
+ if (!fieldRules) return shape;
674
+ for (const name of filterableFields) {
675
+ if (allHidden.has(name)) continue;
676
+ const rule = fieldRules[name];
677
+ if (!rule) continue;
678
+ if (rule.hidden || rule.systemManaged) continue;
679
+ const filterField = buildFieldSchema(rule);
680
+ shape[name] = filterField.optional();
681
+ if (allowedOperators?.length) {
682
+ const isNumericOrDate = rule.type === "number" || rule.type === "date";
683
+ for (const op of allowedOperators) {
684
+ if (COMPARISON_OPS.has(op) && !isNumericOrDate) continue;
685
+ if (op === "eq") continue;
686
+ if (op === "exists") {
687
+ shape[`${name}_${op}`] = z.boolean().optional().describe(opDescription(op, name));
688
+ continue;
689
+ }
690
+ if (op === "in" || op === "nin") {
691
+ shape[`${name}_${op}`] = z.string().optional().describe(opDescription(op, name));
692
+ continue;
693
+ }
694
+ shape[`${name}_${op}`] = filterField.optional().describe(opDescription(op, name));
695
+ }
696
+ }
697
+ }
698
+ return shape;
699
+ }
700
+ //#endregion
701
+ //#region src/integrations/mcp/authBridge.ts
702
+ /**
703
+ * @classytic/arc — MCP Auth Bridge
440
704
  *
441
- * Emits the value as JSON with no envelope — same no-envelope contract
442
- * the HTTP wire follows. The `isError: true` flag on `CallToolResult`
443
- * is the success/error discriminant for MCP, mirroring HTTP status.
705
+ * Resolves MCP session identity from request headers.
706
+ * Supports three modes the user chooses:
707
+ *
708
+ * 1. `false` — no auth, anonymous access
709
+ * 2. `BetterAuthHandler` — OAuth 2.1 via Better Auth
710
+ * 3. `McpAuthResolver` — custom function (API key, JWT, gateway headers, etc.)
444
711
  */
445
- function toCallToolSuccess(value) {
446
- return { content: [{
447
- type: "text",
448
- text: JSON.stringify(value)
449
- }] };
712
+ /** Distinguish BetterAuthHandler from McpAuthResolver */
713
+ function isBetterAuth(auth) {
714
+ return typeof auth === "object" && auth !== null && "api" in auth && "handler" in auth;
450
715
  }
451
716
  /**
452
- * Wrap an error as an MCP `CallToolResult` with the canonical
453
- * `ErrorContract` shape inside the text payload. Single source of truth
454
- * for MCP error serialization — every tool surface (CRUD, action, route,
455
- * aggregation) routes through here so the JSON shape an agent sees is
456
- * identical to what an HTTP client sees.
717
+ * Resolve MCP session identity from request headers.
457
718
  *
458
- * Accepts:
459
- * - An `ArcError` (or any `HttpError`-shaped throw) routes through
460
- * `toErrorContract()` for the canonical conversion.
461
- * - A partial contract `{code, message, status, details?}` → used as-is.
462
- * - Any other `Error` → falls back to `arc.internal_error` 500.
719
+ * @param headers - HTTP request headers
720
+ * @param auth - false | BetterAuthHandler | McpAuthResolver
721
+ * @param authCache - Optional short-lived cache to avoid redundant auth lookups
463
722
  */
464
- function toCallToolError(input) {
465
- let contract;
466
- if (input instanceof Error) if (isArcError(input) || isHttpError(input)) contract = toErrorContract(input);
467
- else contract = {
468
- code: "arc.internal_error",
469
- message: input.message || "Internal Server Error",
470
- status: 500
471
- };
472
- else contract = {
473
- code: input.code,
474
- message: input.message,
475
- ...input.status !== void 0 ? { status: input.status } : {},
476
- ...input.details ? { details: input.details } : {}
477
- };
478
- return {
479
- content: [{
480
- type: "text",
481
- text: JSON.stringify(contract)
482
- }],
483
- isError: true
484
- };
723
+ async function resolveMcpAuth(headers, auth, authCache) {
724
+ if (auth === false) return null;
725
+ const cacheKey = authCache ? extractAuthCacheKey(headers) : null;
726
+ if (cacheKey && authCache) {
727
+ const cached = authCache.get(cacheKey);
728
+ if (cached !== void 0) return cached;
729
+ }
730
+ let result = null;
731
+ if (typeof auth === "function") try {
732
+ result = await auth(headers);
733
+ } catch {
734
+ result = null;
735
+ }
736
+ else if (isBetterAuth(auth)) try {
737
+ const session = await auth.api.getMcpSession({ headers });
738
+ if (!session?.userId) result = null;
739
+ else result = {
740
+ userId: session.userId,
741
+ organizationId: session.activeOrganizationId,
742
+ ...session.clientId ? { clientId: session.clientId } : {},
743
+ ...session.scopes ? { scopes: session.scopes.split(" ") } : {}
744
+ };
745
+ } catch {
746
+ result = null;
747
+ }
748
+ if (cacheKey && authCache) authCache.set(cacheKey, result);
749
+ return result;
485
750
  }
751
+ const DEFAULT_AUTH_CACHE_TTL_MS = 5e3;
752
+ const DEFAULT_AUTH_CACHE_MAX = 500;
753
+ /** Short-lived auth cache to avoid redundant auth resolver calls in stateless mode */
754
+ var McpAuthCache = class {
755
+ cache = /* @__PURE__ */ new Map();
756
+ ttlMs;
757
+ maxEntries;
758
+ constructor(opts) {
759
+ this.ttlMs = opts?.ttlMs ?? DEFAULT_AUTH_CACHE_TTL_MS;
760
+ this.maxEntries = opts?.maxEntries ?? DEFAULT_AUTH_CACHE_MAX;
761
+ }
762
+ get(key) {
763
+ const entry = this.cache.get(key);
764
+ if (!entry) return void 0;
765
+ if (Date.now() > entry.expires) {
766
+ this.cache.delete(key);
767
+ return;
768
+ }
769
+ return entry.result;
770
+ }
771
+ set(key, result) {
772
+ if (this.cache.size >= this.maxEntries) {
773
+ const now = Date.now();
774
+ for (const [k, v] of this.cache) if (now > v.expires) this.cache.delete(k);
775
+ if (this.cache.size >= this.maxEntries) {
776
+ const firstKey = this.cache.keys().next().value;
777
+ if (firstKey) this.cache.delete(firstKey);
778
+ }
779
+ }
780
+ this.cache.set(key, {
781
+ result,
782
+ expires: Date.now() + this.ttlMs
783
+ });
784
+ }
785
+ };
486
786
  /**
487
- * Build the canonical permission-denied `CallToolResult` for an MCP
488
- * tool. Discriminates 401 (no session "Authentication required") from
489
- * 403 (session present, denied "Permission denied"). Mirrors the
490
- * status split the HTTP `errorHandler` plugin uses.
787
+ * Extract a cache key from auth-related headers.
788
+ * Uses SHA-256 hash of header values to prevent cache key collisions
789
+ * and avoid storing raw credentials in memory.
491
790
  */
492
- function permissionDeniedResult(args) {
493
- const authenticated = args.session != null;
494
- return toCallToolError({
495
- code: authenticated ? "arc.forbidden" : "arc.unauthorized",
496
- message: args.reason ?? (authenticated ? `Permission denied for '${args.operation}' on '${args.resource}'` : "Authentication required"),
497
- status: authenticated ? 403 : 401
498
- });
791
+ function extractAuthCacheKey(headers) {
792
+ if (headers.authorization) return `authz:${hashForCache(headers.authorization)}`;
793
+ if (headers["x-api-key"]) return `apikey:${hashForCache(headers["x-api-key"])}`;
794
+ return null;
795
+ }
796
+ function hashForCache(value) {
797
+ return createHash("sha256").update(value).digest("hex").slice(0, 32);
499
798
  }
500
799
  /**
501
- * Auto-create a BaseController from the resource's adapter for MCP use.
502
- * Called when the resource has an adapter but no controller
503
- * (e.g. `disableDefaultRoutes: true` skips controller creation in
504
- * `defineResource`).
800
+ * Register OAuth 2.1 discovery endpoints for MCP clients.
801
+ * Only relevant when using Better Auth custom auth doesn't need these.
505
802
  */
506
- function createMcpController(resource) {
507
- const repository = resource.adapter?.repository;
508
- if (!repository) return void 0;
509
- return new BaseController(repository, {
510
- resourceName: resource.name,
511
- schemaOptions: resource.schemaOptions,
512
- tenantField: resource.tenantField,
513
- idField: resource.idField,
514
- matchesFilter: resource.adapter?.matchesFilter
803
+ async function registerOAuthDiscovery(fastify, auth) {
804
+ fastify.get("/.well-known/oauth-authorization-server", async (req, reply) => {
805
+ await forwardResponse(reply, await auth.handler(toWebRequest(req)));
806
+ });
807
+ fastify.get("/.well-known/oauth-protected-resource", async (req, reply) => {
808
+ await forwardResponse(reply, await auth.handler(toWebRequest(req)));
809
+ });
810
+ }
811
+ function toWebRequest(req) {
812
+ const protocol = req.protocol ?? "http";
813
+ const host = req.hostname ?? "localhost";
814
+ return new Request(`${protocol}://${host}${req.url}`, {
815
+ method: req.method,
816
+ headers: req.headers
817
+ });
818
+ }
819
+ async function forwardResponse(reply, response) {
820
+ reply.status(response.status);
821
+ response.headers.forEach((value, key) => {
822
+ if (key.toLowerCase() !== "transfer-encoding") reply.header(key, value);
515
823
  });
824
+ reply.send(await response.text());
516
825
  }
517
826
  //#endregion
518
827
  //#region src/integrations/mcp/action-tools.ts
@@ -538,9 +847,9 @@ function convertActionSchemaToZod(raw) {
538
847
  * in the 2.10.8 review: REST and MCP action tools share identical
539
848
  * context-building machinery.
540
849
  */
541
- function createActionToolHandler(actionName, handler, permissions, resourceName, _resourcePermissions, rawSchema) {
850
+ function createActionToolHandler(actionName, handler, permissions, resourceName, _resourcePermissions, rawSchema, idLess = false) {
542
851
  const ir = rawSchema ? normalizeSchemaIR(rawSchema) : void 0;
543
- const allowedKeys = (ir ? shouldRejectAdditionalProperties(ir) : false) && ir ? new Set(["id", ...Object.keys(ir.properties)]) : void 0;
852
+ const allowedKeys = (ir ? shouldRejectAdditionalProperties(ir) : false) && ir ? new Set([...idLess ? [] : ["id"], ...Object.keys(ir.properties)]) : void 0;
544
853
  return async (input, ctx) => {
545
854
  const session = ctx.session;
546
855
  if (allowedKeys) {
@@ -567,7 +876,7 @@ function createActionToolHandler(actionName, handler, permissions, resourceName,
567
876
  ...input,
568
877
  action: actionName
569
878
  }, session, "action", permResult?.filters, permResult?.scope);
570
- const id = typeof input.id === "string" ? input.id : "";
879
+ const id = idLess ? "" : typeof input.id === "string" ? input.id : "";
571
880
  const { id: _discardId, ...data } = input;
572
881
  try {
573
882
  return toCallToolSuccess(await handler(id, data, reqCtx));
@@ -675,88 +984,6 @@ function createAggregationToolHandler(args) {
675
984
  };
676
985
  }
677
986
  //#endregion
678
- //#region src/integrations/mcp/crud-tools.ts
679
- const ALL_CRUD_OPS = [
680
- "list",
681
- "get",
682
- "create",
683
- "update",
684
- "delete"
685
- ];
686
- const CRUD_ANNOTATIONS = {
687
- list: { readOnlyHint: true },
688
- get: { readOnlyHint: true },
689
- create: { destructiveHint: false },
690
- update: {
691
- destructiveHint: true,
692
- idempotentHint: true
693
- },
694
- delete: {
695
- destructiveHint: true,
696
- idempotentHint: true
697
- }
698
- };
699
- /**
700
- * Build a handler that dispatches to the controller method for `op`,
701
- * passing through arc's MCP → IRequestContext adapter. Permission check
702
- * runs first and short-circuits with a structured tool error on denial.
703
- */
704
- function createCrudHandler(op, controller, resourceName, permissions) {
705
- const ctrl = controller;
706
- return async (input, ctx) => {
707
- try {
708
- const method = ctrl[op];
709
- if (typeof method !== "function") return {
710
- content: [{
711
- type: "text",
712
- text: `Operation "${op}" not available on ${resourceName}`
713
- }],
714
- isError: true
715
- };
716
- const permResult = await evaluatePermission(permissions?.[op], ctx.session, resourceName, op, input);
717
- if (permResult && !permResult.granted) return {
718
- content: [{
719
- type: "text",
720
- text: `Permission denied: ${op} on ${resourceName}${permResult.reason ? ` — ${permResult.reason}` : ""}`
721
- }],
722
- isError: true
723
- };
724
- return toCallToolResult(await method(buildRequestContext(input, ctx.session, op, permResult?.filters, permResult?.scope)));
725
- } catch (err) {
726
- const msg = err instanceof Error ? err.message : String(err);
727
- ctx.log("error", `${resourceName}.${op}: ${msg}`).catch(() => {});
728
- return {
729
- content: [{
730
- type: "text",
731
- text: `Error: ${msg}`
732
- }],
733
- isError: true
734
- };
735
- }
736
- };
737
- }
738
- /**
739
- * Default description for a CRUD tool. Enriches list descriptions with the
740
- * configured filter/sort metadata so MCP clients can see what's queryable
741
- * without reading the resource source.
742
- */
743
- function defaultCrudDescription(op, displayName, softDelete, queryMeta) {
744
- const name = displayName.toLowerCase();
745
- switch (op) {
746
- case "list": {
747
- const parts = [`List ${pluralize(name)} with optional filters and pagination.`];
748
- if (queryMeta?.filterableFields?.length) parts.push(`Filterable fields: ${queryMeta.filterableFields.join(", ")}.`);
749
- if (queryMeta?.allowedOperators?.length) parts.push(`Filter operators: ${queryMeta.allowedOperators.join(", ")} (use field[op]=value syntax).`);
750
- if (queryMeta?.sortableFields?.length) parts.push(`Sortable fields: ${queryMeta.sortableFields.join(", ")}.`);
751
- return parts.join(" ");
752
- }
753
- case "get": return `Get a single ${name} by ID`;
754
- case "create": return `Create a new ${name}`;
755
- case "update": return `Update an existing ${name} by ID`;
756
- case "delete": return softDelete ? `Delete a ${name} by ID (soft delete — marks as deleted, not permanently removed)` : `Delete a ${name} by ID`;
757
- }
758
- }
759
- //#endregion
760
987
  //#region src/integrations/mcp/jsonSchemaToZod.ts
761
988
  /**
762
989
  * @classytic/arc — JSON Schema → Zod shape converter
@@ -1046,6 +1273,32 @@ function mapJsonSchemaTypeToArcType(jsonType) {
1046
1273
  * 3. String handler — looks up a method on the controller by name.
1047
1274
  */
1048
1275
  /**
1276
+ * Pick the IRequestContext shape MCP should produce when invoking a
1277
+ * custom route's handler. Mirrors the HTTP semantics of the route:
1278
+ *
1279
+ * | HTTP | MCP kind | params | body | query |
1280
+ * |--------------------------------|-----------|---------|-------|-------|
1281
+ * | `GET /thing` (no :id) | `list` | - | - | input |
1282
+ * | `GET /thing/:id` | `get` | { id } | - | input |
1283
+ * | `POST /thing` (no :id) | `create` | - | input | - |
1284
+ * | `PUT|PATCH /thing/:id` | `update` | { id } | rest | - |
1285
+ * | `DELETE /thing/:id` | `delete` | { id } | - | - |
1286
+ * | `POST /thing/:id` (any other) | `update` | { id } | rest | - |
1287
+ *
1288
+ * Pre-2.15.5 every custom route used `update`/`create` regardless of method.
1289
+ * That broke GET-route bridging the moment a handler read `ctx.query` — MCP
1290
+ * stuffed the input into `ctx.body` instead and the handler returned empty.
1291
+ * The `kind` mapping below is the single source of truth that keeps HTTP
1292
+ * and MCP invocations producing the same `IRequestContext` shape.
1293
+ */
1294
+ function operationKindForRoute(method, hasId) {
1295
+ const upper = method.toUpperCase();
1296
+ if (upper === "GET") return hasId ? "get" : "list";
1297
+ if (upper === "DELETE") return "delete";
1298
+ if (hasId) return "update";
1299
+ return "create";
1300
+ }
1301
+ /**
1049
1302
  * Build an MCP tool handler for a custom route.
1050
1303
  *
1051
1304
  * Enforces the same contract as the REST route:
@@ -1075,7 +1328,7 @@ function createCustomRouteHandler(route, controller, hasId, options) {
1075
1328
  session
1076
1329
  });
1077
1330
  try {
1078
- const reqCtx = buildRequestContext(input, session, hasId ? "update" : "create", permResult?.filters, permResult?.scope);
1331
+ const reqCtx = buildRequestContext(input, session, operationKindForRoute(route.method, hasId), permResult?.filters, permResult?.scope);
1079
1332
  if (typeof route.handler === "function") {
1080
1333
  const fn = route.handler;
1081
1334
  if (pipelineSteps.length > 0) return toCallToolResult(await executePipeline(pipelineSteps, {
@@ -1195,9 +1448,18 @@ function resourceToTools(resource, config = {}) {
1195
1448
  if (config.operations) ops = ops.filter((op) => config.operations?.includes(op));
1196
1449
  for (const op of ops) {
1197
1450
  const name = config.names?.[op] ?? (op === "list" ? `${prefix ? `${prefix}_` : ""}list_${pluralize(resource.name)}` : `${prefix ? `${prefix}_` : ""}${op}_${resource.name}`);
1451
+ const defaultDescription = defaultCrudDescription(op, resource.displayName, hasSoftDelete, {
1452
+ filterableFields,
1453
+ allowedOperators,
1454
+ sortableFields
1455
+ });
1198
1456
  tools.push({
1199
1457
  name,
1200
- description: config.descriptions?.[op] ?? defaultCrudDescription(op, resource.displayName, hasSoftDelete, {
1458
+ description: resolveCrudDescription(config.descriptions?.[op], {
1459
+ operation: op,
1460
+ displayName: resource.displayName,
1461
+ softDelete: hasSoftDelete,
1462
+ defaultDescription,
1201
1463
  filterableFields,
1202
1464
  allowedOperators,
1203
1465
  sortableFields
@@ -1219,13 +1481,16 @@ function resourceToTools(resource, config = {}) {
1219
1481
  if (route.mcp === false) continue;
1220
1482
  const mcpHandler = route.mcpHandler;
1221
1483
  if (!!route.raw && !mcpHandler) continue;
1222
- if (!mcpHandler && ![
1223
- "POST",
1224
- "PUT",
1225
- "PATCH",
1226
- "DELETE"
1227
- ].includes(route.method)) continue;
1228
1484
  if (!mcpHandler && typeof route.handler === "string" && !controller) continue;
1485
+ const routeWithRef = route;
1486
+ let resolvedRouteHandler = route.handler;
1487
+ if (typeof routeWithRef.controllerMethod === "function" && !resolvedRouteHandler) {
1488
+ if (!controller) continue;
1489
+ const referenced = routeWithRef.controllerMethod(controller);
1490
+ if (typeof referenced !== "function") continue;
1491
+ resolvedRouteHandler = referenced.bind(controller);
1492
+ }
1493
+ if (!resolvedRouteHandler) continue;
1229
1494
  const opName = route.operation ?? slugifyRoute(route.method, route.path);
1230
1495
  const hasId = route.path.includes(":id");
1231
1496
  const mcpConfig = typeof route.mcp === "object" && route.mcp !== null ? route.mcp : void 0;
@@ -1248,7 +1513,12 @@ function resourceToTools(resource, config = {}) {
1248
1513
  description: toolDescription,
1249
1514
  annotations: toolAnnotations,
1250
1515
  inputSchema: inputShape,
1251
- handler: mcpHandler ? createMcpHandlerPassthrough(mcpHandler) : createCustomRouteHandler(route, controller, hasId, {
1516
+ handler: mcpHandler ? createMcpHandlerPassthrough(mcpHandler) : createCustomRouteHandler({
1517
+ handler: resolvedRouteHandler,
1518
+ operation: route.operation,
1519
+ method: route.method,
1520
+ path: route.path
1521
+ }, controller, hasId, {
1252
1522
  resourceName: resource.name,
1253
1523
  operationName: opName,
1254
1524
  permissions: route.permissions,
@@ -1262,7 +1532,8 @@ function resourceToTools(resource, config = {}) {
1262
1532
  const mcpCfg = typeof def !== "function" && typeof def.mcp === "object" ? def.mcp : void 0;
1263
1533
  const description = mcpCfg?.description ?? (typeof def !== "function" ? def.description : void 0) ?? `${actionName} action on ${resource.displayName}`;
1264
1534
  const annotations = mcpCfg?.annotations ? { ...mcpCfg.annotations } : { destructiveHint: true };
1265
- const inputShape = { id: z.string().describe("Resource ID") };
1535
+ const idLess = typeof def !== "function" && def.id === false;
1536
+ const inputShape = idLess ? {} : { id: z.string().describe("Resource ID") };
1266
1537
  const rawSchema = typeof def !== "function" ? def.schema : void 0;
1267
1538
  if (rawSchema && typeof rawSchema === "object") {
1268
1539
  const converted = convertActionSchemaToZod(rawSchema);
@@ -1281,7 +1552,7 @@ function resourceToTools(resource, config = {}) {
1281
1552
  description: String(description),
1282
1553
  annotations,
1283
1554
  inputSchema: inputShape,
1284
- handler: createActionToolHandler(actionName, handler, actionPerms, resource.name, resource.permissions, rawSchema)
1555
+ handler: createActionToolHandler(actionName, handler, actionPerms, resource.name, resource.permissions, rawSchema, idLess)
1285
1556
  });
1286
1557
  }
1287
1558
  if (resource.aggregations && Object.keys(resource.aggregations).length > 0) {
@@ -1310,4 +1581,436 @@ function resourceToTools(resource, config = {}) {
1310
1581
  return tools;
1311
1582
  }
1312
1583
  //#endregion
1313
- export { fieldRulesToZod as n, createMcpServer as r, resourceToTools as t };
1584
+ //#region src/integrations/mcp/schemaResources.ts
1585
+ /**
1586
+ * Register MCP Resources for schema discovery.
1587
+ */
1588
+ function registerSchemaResources(server, resources, overrides) {
1589
+ const srv = server;
1590
+ srv.resource("schemas", "arc://schemas", {
1591
+ title: "Arc Resource Schemas",
1592
+ description: "All available resources",
1593
+ mimeType: "application/json"
1594
+ }, async () => ({ contents: [{
1595
+ uri: "arc://schemas",
1596
+ mimeType: "application/json",
1597
+ text: JSON.stringify(resources.map((r) => ({
1598
+ name: r.name,
1599
+ displayName: r.displayName,
1600
+ fieldCount: r.schemaOptions?.fieldRules ? Object.keys(r.schemaOptions.fieldRules).length : 0,
1601
+ operations: getOps(r, overrides?.[r.name]?.operations),
1602
+ presets: r._appliedPresets ?? []
1603
+ })), null, 2)
1604
+ }] }));
1605
+ for (const r of resources) {
1606
+ const uri = `arc://schemas/${r.name}`;
1607
+ const schemaOpts = r.schemaOptions;
1608
+ srv.resource(`schema-${r.name}`, uri, {
1609
+ title: `${r.displayName} Schema`,
1610
+ description: `Schema for ${r.displayName}`,
1611
+ mimeType: "application/json"
1612
+ }, async () => ({ contents: [{
1613
+ uri,
1614
+ mimeType: "application/json",
1615
+ text: JSON.stringify({
1616
+ name: r.name,
1617
+ displayName: r.displayName,
1618
+ operations: getOps(r, overrides?.[r.name]?.operations),
1619
+ fields: r.schemaOptions?.fieldRules ?? {},
1620
+ filterableFields: schemaOpts?.filterableFields ?? [],
1621
+ presets: r._appliedPresets ?? []
1622
+ }, null, 2)
1623
+ }] }));
1624
+ }
1625
+ }
1626
+ function getOps(r, override) {
1627
+ let ops = [
1628
+ "list",
1629
+ "get",
1630
+ "create",
1631
+ "update",
1632
+ "delete"
1633
+ ].filter((op) => !r.disabledRoutes?.includes(op));
1634
+ if (override) ops = ops.filter((op) => override.includes(op));
1635
+ return ops;
1636
+ }
1637
+ //#endregion
1638
+ //#region src/integrations/mcp/sessionCache.ts
1639
+ const DEFAULT_TTL_MS = 1800 * 1e3;
1640
+ const DEFAULT_MAX_SESSIONS = 1e3;
1641
+ var McpSessionCache = class {
1642
+ sessions = /* @__PURE__ */ new Map();
1643
+ ttlMs;
1644
+ maxSessions;
1645
+ cleanupTimer = null;
1646
+ constructor(opts = {}) {
1647
+ this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
1648
+ this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
1649
+ if (this.ttlMs > 0) {
1650
+ this.cleanupTimer = setInterval(() => this.cleanup(), Math.max(this.ttlMs / 2, 5e3));
1651
+ if (this.cleanupTimer.unref) this.cleanupTimer.unref();
1652
+ }
1653
+ }
1654
+ /** Get an existing session by ID */
1655
+ get(sessionId) {
1656
+ const entry = this.sessions.get(sessionId);
1657
+ if (!entry) return void 0;
1658
+ if (Date.now() - entry.lastAccessed > this.ttlMs) {
1659
+ this.remove(sessionId);
1660
+ return;
1661
+ }
1662
+ return entry;
1663
+ }
1664
+ /** Store a new session */
1665
+ set(sessionId, entry) {
1666
+ if (this.sessions.size >= this.maxSessions && !this.sessions.has(sessionId)) this.evictOldest();
1667
+ entry.lastAccessed = Date.now();
1668
+ this.sessions.set(sessionId, entry);
1669
+ }
1670
+ /** Refresh the TTL on a session */
1671
+ touch(sessionId) {
1672
+ const entry = this.sessions.get(sessionId);
1673
+ if (entry) entry.lastAccessed = Date.now();
1674
+ }
1675
+ /** Remove and close a session */
1676
+ remove(sessionId) {
1677
+ const entry = this.sessions.get(sessionId);
1678
+ if (entry) {
1679
+ this.closeTransport(entry);
1680
+ this.sessions.delete(sessionId);
1681
+ }
1682
+ }
1683
+ /** Remove all expired sessions */
1684
+ cleanup() {
1685
+ const now = Date.now();
1686
+ for (const [id, entry] of this.sessions) if (now - entry.lastAccessed > this.ttlMs) {
1687
+ this.closeTransport(entry);
1688
+ this.sessions.delete(id);
1689
+ }
1690
+ }
1691
+ /** Close all sessions and stop cleanup timer */
1692
+ close() {
1693
+ if (this.cleanupTimer) {
1694
+ clearInterval(this.cleanupTimer);
1695
+ this.cleanupTimer = null;
1696
+ }
1697
+ for (const [id, entry] of this.sessions) {
1698
+ this.closeTransport(entry);
1699
+ this.sessions.delete(id);
1700
+ }
1701
+ }
1702
+ /** Current session count */
1703
+ get size() {
1704
+ return this.sessions.size;
1705
+ }
1706
+ /** Evict the oldest (least recently accessed) session */
1707
+ evictOldest() {
1708
+ let oldestId = null;
1709
+ let oldestTime = Infinity;
1710
+ for (const [id, entry] of this.sessions) if (entry.lastAccessed < oldestTime) {
1711
+ oldestTime = entry.lastAccessed;
1712
+ oldestId = id;
1713
+ }
1714
+ if (oldestId) this.remove(oldestId);
1715
+ }
1716
+ /** Safely close a transport */
1717
+ closeTransport(entry) {
1718
+ try {
1719
+ const transport = entry.transport;
1720
+ if (transport && typeof transport.close === "function") transport.close();
1721
+ } catch {}
1722
+ }
1723
+ };
1724
+ //#endregion
1725
+ //#region src/integrations/mcp/mcpPlugin.ts
1726
+ /**
1727
+ * @classytic/arc — MCP Plugin (Level 1)
1728
+ *
1729
+ * Fastify plugin that auto-generates MCP tools from Arc resources.
1730
+ *
1731
+ * Two transport modes:
1732
+ * - **Stateless** (default) — fresh server per request, no session tracking.
1733
+ * Best for production, horizontal scaling, serverless, edge.
1734
+ * - **Stateful** — sessions cached with TTL, reused across requests.
1735
+ * Use when you need server-initiated notifications or long-lived connections.
1736
+ *
1737
+ * Auth is NOT enforced — the plugin respects whatever auth mode you choose:
1738
+ * - `auth: false` — no auth, anonymous access (dev/testing/stdio)
1739
+ * - `auth: betterAuthInstance` — OAuth 2.1 via Better Auth's mcp() plugin
1740
+ * - `auth: async (headers) => {...}` — custom function (API key, JWT, gateway, etc.)
1741
+ *
1742
+ * @example
1743
+ * ```typescript
1744
+ * // Stateless (default) — production, scalable
1745
+ * await app.register(mcpPlugin, { resources, auth: false });
1746
+ *
1747
+ * // Stateful — when you need session persistence
1748
+ * await app.register(mcpPlugin, { resources, stateful: true, sessionTtlMs: 600000 });
1749
+ *
1750
+ * // Multiple MCP endpoints scoped to different resource groups
1751
+ * await app.register(mcpPlugin, { resources: catalogResources, prefix: '/mcp/catalog' });
1752
+ * await app.register(mcpPlugin, { resources: orderResources, prefix: '/mcp/orders' });
1753
+ * ```
1754
+ */
1755
+ const mcpPluginImpl = async (fastify, options) => {
1756
+ let StreamableHTTPServerTransport;
1757
+ try {
1758
+ StreamableHTTPServerTransport = (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport;
1759
+ } catch {
1760
+ throw new Error("@modelcontextprotocol/sdk is required for MCP support. Install it: npm install @modelcontextprotocol/sdk");
1761
+ }
1762
+ try {
1763
+ await import("zod");
1764
+ } catch {
1765
+ throw new Error("zod is required for MCP tool schemas. Install it: npm install zod");
1766
+ }
1767
+ const enabledResources = filterResourcesForMcp(options.resources, {
1768
+ expose: options.expose,
1769
+ include: options.include,
1770
+ exclude: options.exclude
1771
+ });
1772
+ const overrides = options.overrides ?? {};
1773
+ const allTools = enabledResources.flatMap((r) => {
1774
+ const resOverrides = overrides[r.name] ?? {};
1775
+ return resourceToTools(r, {
1776
+ ...resOverrides,
1777
+ toolNamePrefix: resOverrides.toolNamePrefix ?? options.toolNamePrefix
1778
+ });
1779
+ });
1780
+ if (options.extraTools) allTools.push(...options.extraTools);
1781
+ fastify.log.info(`mcpPlugin: ${allTools.length} tools from ${enabledResources.length} resources`);
1782
+ const overrideOpsMap = {};
1783
+ for (const [name, cfg] of Object.entries(overrides)) overrideOpsMap[name] = { operations: cfg.operations };
1784
+ const stateful = options.stateful === true;
1785
+ const cache = stateful ? new McpSessionCache({
1786
+ ttlMs: options.sessionTtlMs,
1787
+ maxSessions: options.maxSessions
1788
+ }) : null;
1789
+ async function createServerInstance(authRef) {
1790
+ const server = await createMcpServer({
1791
+ name: options.serverName ?? "arc-mcp",
1792
+ version: options.serverVersion ?? "1.0.0",
1793
+ instructions: options.instructions,
1794
+ tools: allTools,
1795
+ prompts: options.extraPrompts
1796
+ }, authRef);
1797
+ registerSchemaResources(server, enabledResources, overrideOpsMap);
1798
+ return server;
1799
+ }
1800
+ if (options.auth && isBetterAuth(options.auth)) await registerOAuthDiscovery(fastify, options.auth);
1801
+ const prefix = options.prefix ?? "/mcp";
1802
+ fastify.get(`${prefix}/health`, async (_request, reply) => {
1803
+ reply.send({
1804
+ status: "ok",
1805
+ mode: stateful ? "stateful" : "stateless",
1806
+ tools: allTools.length,
1807
+ resources: enabledResources.length,
1808
+ toolNames: allTools.map((t) => t.name),
1809
+ sessions: cache?.size ?? null
1810
+ });
1811
+ });
1812
+ if (stateful) {
1813
+ if (!cache) throw new Error("[Arc/MCP] Stateful session cache missing in stateful registration");
1814
+ registerStatefulRoutes(fastify, prefix, options, cache, createServerInstance, StreamableHTTPServerTransport);
1815
+ } else {
1816
+ const authCache = options.auth && options.authCacheTtlMs !== 0 ? new McpAuthCache({ ttlMs: options.authCacheTtlMs }) : void 0;
1817
+ registerStatelessRoutes(fastify, prefix, options, createServerInstance, StreamableHTTPServerTransport, authCache);
1818
+ }
1819
+ if (cache) fastify.addHook("onClose", async () => cache.close());
1820
+ const registration = {
1821
+ sessions: cache,
1822
+ toolNames: allTools.map((t) => t.name),
1823
+ resourceNames: enabledResources.map((r) => r.name),
1824
+ stateful
1825
+ };
1826
+ if (!fastify.hasDecorator("mcp")) {
1827
+ const registrations = /* @__PURE__ */ new Map();
1828
+ registrations.set(prefix, registration);
1829
+ const first = () => registrations.values().next().value;
1830
+ const decorator = {
1831
+ registrations,
1832
+ get(p) {
1833
+ return registrations.get(p);
1834
+ },
1835
+ get sessions() {
1836
+ return first()?.sessions ?? null;
1837
+ },
1838
+ get toolNames() {
1839
+ return first()?.toolNames ?? [];
1840
+ },
1841
+ get resourceNames() {
1842
+ return first()?.resourceNames ?? [];
1843
+ },
1844
+ get stateful() {
1845
+ return first()?.stateful ?? false;
1846
+ }
1847
+ };
1848
+ fastify.decorate("mcp", decorator);
1849
+ } else {
1850
+ const existing = fastify.mcp;
1851
+ if (existing) {
1852
+ if (existing.registrations.has(prefix)) throw new Error(`mcpPlugin: prefix "${prefix}" is already registered`);
1853
+ existing.registrations.set(prefix, registration);
1854
+ }
1855
+ }
1856
+ };
1857
+ function registerStatelessRoutes(fastify, prefix, options, createServer, Transport, authCache) {
1858
+ fastify.post(prefix, async (request, reply) => {
1859
+ const authResult = await resolveMcpAuth(request.headers, options.auth ?? false, authCache);
1860
+ if (!authResult && options.auth) {
1861
+ fastify.log.warn({
1862
+ msg: "mcpPlugin: auth failed",
1863
+ status: 401
1864
+ });
1865
+ return reply.code(401).send({
1866
+ jsonrpc: "2.0",
1867
+ error: {
1868
+ code: -32e3,
1869
+ message: "Unauthorized"
1870
+ }
1871
+ });
1872
+ }
1873
+ const authRef = { current: authResult };
1874
+ const transport = new Transport({ sessionIdGenerator: void 0 });
1875
+ await (await createServer(authRef)).connect(transport);
1876
+ await transport.handleRequest(request.raw, reply.raw, request.body);
1877
+ });
1878
+ fastify.get(prefix, async (_request, reply) => {
1879
+ reply.code(405).send({
1880
+ jsonrpc: "2.0",
1881
+ error: {
1882
+ code: -32e3,
1883
+ message: "SSE not available in stateless mode. Use stateful: true for server-initiated messages."
1884
+ }
1885
+ });
1886
+ });
1887
+ fastify.delete(prefix, async (_request, reply) => {
1888
+ reply.code(200).send();
1889
+ });
1890
+ }
1891
+ function registerStatefulRoutes(fastify, prefix, options, cache, createServer, Transport) {
1892
+ /** Check if the requesting principal owns the session */
1893
+ function isSessionOwner(entry, authResult) {
1894
+ if (!options.auth || !entry.auth || !authResult) return true;
1895
+ const prev = entry.auth;
1896
+ return prev.userId === authResult.userId && prev.organizationId === authResult.organizationId && prev.clientId === authResult.clientId;
1897
+ }
1898
+ fastify.post(prefix, async (request, reply) => {
1899
+ const authResult = await resolveMcpAuth(request.headers, options.auth ?? false);
1900
+ if (!authResult && options.auth) {
1901
+ fastify.log.warn({
1902
+ msg: "mcpPlugin: auth failed",
1903
+ status: 401
1904
+ });
1905
+ return reply.code(401).send({
1906
+ jsonrpc: "2.0",
1907
+ error: {
1908
+ code: -32e3,
1909
+ message: "Unauthorized"
1910
+ }
1911
+ });
1912
+ }
1913
+ const sessionId = request.headers["mcp-session-id"];
1914
+ if (sessionId) {
1915
+ const entry = cache.get(sessionId);
1916
+ if (entry) {
1917
+ if (!isSessionOwner(entry, authResult)) return reply.code(403).send({
1918
+ jsonrpc: "2.0",
1919
+ error: {
1920
+ code: -32e3,
1921
+ message: "Session ownership mismatch"
1922
+ }
1923
+ });
1924
+ cache.touch(sessionId);
1925
+ entry.auth = authResult;
1926
+ entry.authRef.current = authResult;
1927
+ await entry.transport.handleRequest(request.raw, reply.raw, request.body);
1928
+ return;
1929
+ }
1930
+ }
1931
+ const authRef = { current: authResult };
1932
+ const transport = new Transport({
1933
+ sessionIdGenerator: () => randomUUID(),
1934
+ onsessioninitialized: (newSessionId) => {
1935
+ cache.set(newSessionId, {
1936
+ transport,
1937
+ lastAccessed: Date.now(),
1938
+ organizationId: authResult?.organizationId ?? "",
1939
+ auth: authResult,
1940
+ authRef
1941
+ });
1942
+ }
1943
+ });
1944
+ await (await createServer(authRef)).connect(transport);
1945
+ await transport.handleRequest(request.raw, reply.raw, request.body);
1946
+ });
1947
+ fastify.get(prefix, async (request, reply) => {
1948
+ const sessionId = request.headers["mcp-session-id"];
1949
+ if (!sessionId) return reply.code(400).send({ error: "Missing Mcp-Session-Id header" });
1950
+ const entry = cache.get(sessionId);
1951
+ if (!entry) return reply.code(403).send({ error: "Unauthorized" });
1952
+ if (options.auth) {
1953
+ const authResult = await resolveMcpAuth(request.headers, options.auth);
1954
+ if (!isSessionOwner(entry, authResult)) return reply.code(403).send({ error: "Unauthorized" });
1955
+ entry.auth = authResult;
1956
+ entry.authRef.current = authResult;
1957
+ }
1958
+ cache.touch(sessionId);
1959
+ await entry.transport.handleRequest(request.raw, reply.raw);
1960
+ });
1961
+ fastify.delete(prefix, async (request, reply) => {
1962
+ const sessionId = request.headers["mcp-session-id"];
1963
+ if (!sessionId) return reply.code(400).send({ error: "Missing Mcp-Session-Id header" });
1964
+ const entry = cache.get(sessionId);
1965
+ if (!entry) return reply.code(204).send();
1966
+ if (options.auth) {
1967
+ const authResult = await resolveMcpAuth(request.headers, options.auth);
1968
+ if (!isSessionOwner(entry, authResult)) return reply.code(403).send({ error: "Unauthorized" });
1969
+ entry.auth = authResult;
1970
+ entry.authRef.current = authResult;
1971
+ }
1972
+ cache.remove(sessionId);
1973
+ reply.code(204).send();
1974
+ });
1975
+ }
1976
+ /**
1977
+ * Resolve `expose` / `include` / `exclude` into the filtered resource list
1978
+ * `mcpPlugin` should surface. Three mutually exclusive intents:
1979
+ *
1980
+ * - `expose`: default-deny allowlist (preferred — new resources auto-deny).
1981
+ * - `include`: legacy alias for `expose`, kept for back-compat.
1982
+ * - `exclude`: default-allow opt-out (drift-prone — new resources auto-leak).
1983
+ *
1984
+ * Conflicting combinations throw with an actionable message so a host that
1985
+ * accidentally mixed paradigms fails at boot instead of silently leaking or
1986
+ * starving the LLM surface.
1987
+ *
1988
+ * **Local opt-out wins.** Any resource declared with
1989
+ * `defineResource({ mcp: false })` is dropped FIRST, before any of the
1990
+ * plugin-level allowlists/blocklists run. The opt-out is colocated with
1991
+ * the resource definition, so adding a new "never-expose" resource is
1992
+ * a single-file change instead of a host-wide blocklist update that
1993
+ * drifts as the codebase grows.
1994
+ *
1995
+ * Exported so `mcp/testing.ts` and external test harnesses can apply the
1996
+ * same precedence rules without duplicating them.
1997
+ */
1998
+ function filterResourcesForMcp(resources, inputs) {
1999
+ const { expose, include, exclude } = inputs;
2000
+ if (expose && include) throw new Error("mcpPlugin: pass either `expose` (preferred) or `include` (legacy alias), not both.");
2001
+ if (expose && exclude) throw new Error("mcpPlugin: `expose` is default-deny — `exclude` is redundant. Drop `exclude` or switch to `include`/`exclude` for default-allow semantics.");
2002
+ const eligible = resources.filter((r) => r.mcp !== false);
2003
+ const allow = expose ?? include;
2004
+ if (allow) {
2005
+ const allowSet = new Set(allow);
2006
+ return eligible.filter((r) => allowSet.has(r.name));
2007
+ }
2008
+ const excludeSet = new Set(exclude ?? []);
2009
+ return eligible.filter((r) => !excludeSet.has(r.name));
2010
+ }
2011
+ const mcpPlugin = fp(mcpPluginImpl, {
2012
+ name: "arc-mcp",
2013
+ fastify: "5.x"
2014
+ });
2015
+ //#endregion
2016
+ export { defaultCrudDescription as a, mcpHandlerAdapter as c, toCallToolResult as d, toCallToolSuccess as f, fieldRulesToZod as i, permissionDeniedResult as l, createMcpServer as m, mcpPlugin as n, resolveCrudDescription as o, buildRequestContext as p, resourceToTools as r, invokeController as s, filterResourcesForMcp as t, toCallToolError as u };