@classytic/arc 2.3.0 → 2.4.2

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 (175) hide show
  1. package/README.md +187 -18
  2. package/bin/arc.js +11 -3
  3. package/dist/BaseController-CkM5dUh_.mjs +1031 -0
  4. package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
  5. package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
  6. package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
  7. package/dist/adapters/index.d.mts +3 -5
  8. package/dist/adapters/index.mjs +2 -3
  9. package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
  10. package/dist/audit/index.d.mts +4 -7
  11. package/dist/audit/index.mjs +2 -29
  12. package/dist/audit/mongodb.d.mts +1 -4
  13. package/dist/audit/mongodb.mjs +2 -3
  14. package/dist/auth/index.d.mts +7 -9
  15. package/dist/auth/index.mjs +65 -63
  16. package/dist/auth/redis-session.d.mts +1 -1
  17. package/dist/auth/redis-session.mjs +1 -2
  18. package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
  19. package/dist/cache/index.d.mts +23 -23
  20. package/dist/cache/index.mjs +4 -6
  21. package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
  22. package/dist/chunk-BpYLSNr0.mjs +14 -0
  23. package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
  24. package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
  25. package/dist/cli/commands/describe.mjs +24 -7
  26. package/dist/cli/commands/docs.mjs +6 -7
  27. package/dist/cli/commands/doctor.d.mts +10 -0
  28. package/dist/cli/commands/doctor.mjs +156 -0
  29. package/dist/cli/commands/generate.mjs +66 -17
  30. package/dist/cli/commands/init.mjs +315 -45
  31. package/dist/cli/commands/introspect.mjs +2 -4
  32. package/dist/cli/index.d.mts +1 -10
  33. package/dist/cli/index.mjs +4 -153
  34. package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
  35. package/dist/core/index.d.mts +3 -5
  36. package/dist/core/index.mjs +5 -4
  37. package/dist/core-C1XCMtqM.mjs +185 -0
  38. package/dist/{createApp-CgKOPhA4.mjs → createApp-ByWNRsZj.mjs} +64 -35
  39. package/dist/{defineResource-DWbpJYtm.mjs → defineResource-D9aY5Cy6.mjs} +108 -1157
  40. package/dist/discovery/index.mjs +37 -5
  41. package/dist/docs/index.d.mts +6 -9
  42. package/dist/docs/index.mjs +3 -21
  43. package/dist/dynamic/index.d.mts +93 -0
  44. package/dist/dynamic/index.mjs +122 -0
  45. package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
  46. package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
  47. package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
  48. package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
  49. package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
  50. package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
  51. package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
  52. package/dist/events/index.d.mts +72 -7
  53. package/dist/events/index.mjs +216 -4
  54. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  55. package/dist/events/transports/redis-stream-entry.mjs +19 -7
  56. package/dist/events/transports/redis.d.mts +1 -1
  57. package/dist/events/transports/redis.mjs +3 -4
  58. package/dist/factory/index.d.mts +23 -9
  59. package/dist/factory/index.mjs +48 -3
  60. package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
  61. package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
  62. package/dist/hooks/index.d.mts +1 -3
  63. package/dist/hooks/index.mjs +2 -3
  64. package/dist/idempotency/index.d.mts +5 -5
  65. package/dist/idempotency/index.mjs +3 -7
  66. package/dist/idempotency/mongodb.d.mts +1 -1
  67. package/dist/idempotency/mongodb.mjs +4 -5
  68. package/dist/idempotency/redis.d.mts +1 -1
  69. package/dist/idempotency/redis.mjs +2 -5
  70. package/dist/{fastifyAdapter-6b_eRDBw.d.mts → index-BL8CaQih.d.mts} +56 -57
  71. package/dist/index-Diqcm14c.d.mts +369 -0
  72. package/dist/{prisma-Dy5S5F5i.d.mts → index-yhxyjqNb.d.mts} +4 -5
  73. package/dist/index.d.mts +100 -105
  74. package/dist/index.mjs +85 -58
  75. package/dist/integrations/event-gateway.d.mts +1 -1
  76. package/dist/integrations/event-gateway.mjs +8 -4
  77. package/dist/integrations/index.d.mts +4 -2
  78. package/dist/integrations/index.mjs +1 -1
  79. package/dist/integrations/jobs.d.mts +2 -2
  80. package/dist/integrations/jobs.mjs +63 -14
  81. package/dist/integrations/mcp/index.d.mts +219 -0
  82. package/dist/integrations/mcp/index.mjs +572 -0
  83. package/dist/integrations/mcp/testing.d.mts +53 -0
  84. package/dist/integrations/mcp/testing.mjs +104 -0
  85. package/dist/integrations/streamline.mjs +39 -19
  86. package/dist/integrations/webhooks.d.mts +56 -0
  87. package/dist/integrations/webhooks.mjs +139 -0
  88. package/dist/integrations/websocket-redis.d.mts +46 -0
  89. package/dist/integrations/websocket-redis.mjs +50 -0
  90. package/dist/integrations/websocket.d.mts +68 -2
  91. package/dist/integrations/websocket.mjs +96 -13
  92. package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
  93. package/dist/interface-DGmPxakH.d.mts +2213 -0
  94. package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
  95. package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
  96. package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
  97. package/dist/metrics-Csh4nsvv.mjs +224 -0
  98. package/dist/migrations/index.d.mts +113 -44
  99. package/dist/migrations/index.mjs +84 -102
  100. package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
  101. package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
  102. package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
  103. package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
  104. package/dist/org/index.d.mts +12 -14
  105. package/dist/org/index.mjs +92 -119
  106. package/dist/org/types.d.mts +2 -2
  107. package/dist/org/types.mjs +1 -1
  108. package/dist/permissions/index.d.mts +4 -278
  109. package/dist/permissions/index.mjs +4 -579
  110. package/dist/permissions-CA5zg0yK.mjs +751 -0
  111. package/dist/plugins/index.d.mts +104 -107
  112. package/dist/plugins/index.mjs +203 -313
  113. package/dist/plugins/response-cache.mjs +4 -69
  114. package/dist/plugins/tracing-entry.d.mts +1 -1
  115. package/dist/plugins/tracing-entry.mjs +24 -11
  116. package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
  117. package/dist/policies/index.d.mts +2 -2
  118. package/dist/policies/index.mjs +80 -83
  119. package/dist/presets/index.d.mts +26 -19
  120. package/dist/presets/index.mjs +2 -142
  121. package/dist/presets/multiTenant.d.mts +1 -4
  122. package/dist/presets/multiTenant.mjs +4 -6
  123. package/dist/presets-C9QXJV1u.mjs +422 -0
  124. package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
  125. package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
  126. package/dist/queryParser-CgCtsjti.mjs +352 -0
  127. package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
  128. package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
  129. package/dist/registry/index.d.mts +1 -4
  130. package/dist/registry/index.mjs +3 -4
  131. package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
  132. package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
  133. package/dist/resourceToTools-PMFE8HIv.mjs +533 -0
  134. package/dist/rpc/index.d.mts +90 -0
  135. package/dist/rpc/index.mjs +248 -0
  136. package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
  137. package/dist/schemas/index.d.mts +30 -30
  138. package/dist/schemas/index.mjs +2 -4
  139. package/dist/scope/index.d.mts +13 -2
  140. package/dist/scope/index.mjs +18 -5
  141. package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
  142. package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
  143. package/dist/testing/index.d.mts +551 -567
  144. package/dist/testing/index.mjs +1744 -1799
  145. package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
  146. package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
  147. package/dist/types/index.d.mts +4 -946
  148. package/dist/types/index.mjs +2 -4
  149. package/dist/types-BJmgxNbF.d.mts +275 -0
  150. package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
  151. package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
  152. package/dist/{types-tKwaViYB.d.mts → types-Dt0-AI6E.d.mts} +68 -27
  153. package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
  154. package/dist/utils/index.d.mts +254 -351
  155. package/dist/utils/index.mjs +7 -6
  156. package/dist/utils-Dc0WhlIl.mjs +594 -0
  157. package/dist/versioning-BzfeHmhj.mjs +37 -0
  158. package/package.json +44 -10
  159. package/skills/arc/SKILL.md +518 -0
  160. package/skills/arc/references/auth.md +250 -0
  161. package/skills/arc/references/events.md +272 -0
  162. package/skills/arc/references/integrations.md +385 -0
  163. package/skills/arc/references/mcp.md +431 -0
  164. package/skills/arc/references/production.md +610 -0
  165. package/skills/arc/references/testing.md +183 -0
  166. package/dist/audited-CGdLiSlE.mjs +0 -140
  167. package/dist/chunk-C7Uep-_p.mjs +0 -20
  168. package/dist/circuitBreaker-CSS2VvL6.mjs +0 -1109
  169. package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
  170. package/dist/interface-BtdYtQUA.d.mts +0 -1114
  171. package/dist/presets-BTeYbw7h.d.mts +0 -57
  172. package/dist/presets-CeFtfDR8.mjs +0 -119
  173. /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
  174. /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
  175. /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
@@ -0,0 +1,431 @@
1
+ # Arc MCP Integration
2
+
3
+ Expose Arc resources as MCP tools for AI agents. Two levels: zero-config auto-generation or fully custom tool definitions.
4
+
5
+ **Requires:** `@modelcontextprotocol/sdk` (peer dep), `zod` (peer dep)
6
+
7
+ ```bash
8
+ npm install @modelcontextprotocol/sdk zod
9
+ ```
10
+
11
+ ## Level 1 — Auto-Generate from Resources
12
+
13
+ ```typescript
14
+ import { mcpPlugin } from '@classytic/arc/mcp';
15
+
16
+ await app.register(mcpPlugin, {
17
+ resources: [productResource, taskResource],
18
+ auth: false,
19
+ exclude: ['credential'],
20
+ overrides: { product: { operations: ['list', 'get'] } },
21
+ });
22
+ ```
23
+
24
+ Per resource, generates up to 5 tools: `list_{plural}`, `get_{name}`, `create_{name}`, `update_{name}`, `delete_{name}`.
25
+
26
+ Tool handlers call `BaseController` — same pipeline as REST (auth, org-scoping, hooks, field permissions, cache).
27
+
28
+ ### McpPluginOptions
29
+
30
+ | Option | Type | Default | Description |
31
+ |--------|------|---------|-------------|
32
+ | `resources` | `ResourceDefinition[]` | required | Resources to expose |
33
+ | `auth` | `BetterAuthHandler \| McpAuthResolver \| false` | `false` | Auth mode (see Auth section) |
34
+ | `prefix` | `string` | `'/mcp'` | MCP endpoint path |
35
+ | `serverName` | `string` | `'arc-mcp'` | Server identity |
36
+ | `serverVersion` | `string` | `'1.0.0'` | Server version |
37
+ | `instructions` | `string` | — | LLM guidance on tool usage |
38
+ | `include` | `string[]` | — | Only these resources get tools (overrides `exclude`) |
39
+ | `exclude` | `string[]` | — | Resource names to exclude |
40
+ | `toolNamePrefix` | `string` | — | Global prefix: `'crm'` → `crm_list_products` |
41
+ | `overrides` | `Record<string, McpResourceConfig>` | — | Per-resource overrides (see below) |
42
+ | `authCacheTtlMs` | `number` | — | Cache auth results for N ms in stateless mode |
43
+ | `extraTools` | `ToolDefinition[]` | — | Hand-written tools alongside auto-generated |
44
+ | `extraPrompts` | `PromptDefinition[]` | — | Custom prompts |
45
+ | `stateful` | `boolean` | `false` | `false` = stateless (default, scalable). `true` = session-cached. |
46
+ | `sessionTtlMs` | `number` | `1800000` | Session TTL (stateful only) |
47
+ | `maxSessions` | `number` | `1000` | Max concurrent sessions (stateful only) |
48
+
49
+ ### Tool Annotations (auto-set)
50
+
51
+ | Operation | Annotations |
52
+ |-----------|-------------|
53
+ | `list`, `get` | `readOnlyHint: true` |
54
+ | `create` | `destructiveHint: false` |
55
+ | `update`, `delete` | `destructiveHint: true, idempotentHint: true` |
56
+
57
+ ### Per-Resource Overrides
58
+
59
+ ```typescript
60
+ await app.register(mcpPlugin, {
61
+ resources,
62
+ include: ['job', 'project'], // only expose these
63
+ overrides: {
64
+ job: {
65
+ operations: ['list', 'get'], // restrict ops
66
+ toolNamePrefix: 'db', // db_list_jobs, db_get_job
67
+ names: { get: 'get_job_by_id' }, // custom name for specific op
68
+ hideFields: ['internalScore'], // strip from schema
69
+ descriptions: { list: 'Browse jobs' }, // custom descriptions
70
+ },
71
+ },
72
+ });
73
+ ```
74
+
75
+ ### Permission Filters (v2.4.2)
76
+
77
+ Resource permissions with `filters` are automatically enforced in MCP tools — same as REST:
78
+
79
+ ```typescript
80
+ defineResource({
81
+ name: 'task',
82
+ permissions: {
83
+ list: (ctx) => ({
84
+ granted: !!ctx.user,
85
+ filters: { orgId: ctx.user?.orgId, branchId: ctx.user?.branchId },
86
+ }),
87
+ create: (ctx) => !!ctx.user, // boolean works too
88
+ delete: (ctx) => ({ granted: false, reason: 'Read-only' }), // deny
89
+ },
90
+ });
91
+
92
+ // MCP tools automatically:
93
+ // - list_tasks scopes by orgId + branchId from permission filters
94
+ // - create_task allowed if user is authenticated
95
+ // - delete_task returns "Permission denied: delete on task"
96
+ ```
97
+
98
+ No extra config. `PermissionResult.filters` flow into `_policyFilters` → `BaseController.AccessControl`.
99
+
100
+ ### Multiple MCP Endpoints
101
+
102
+ Mount separate servers scoped to different resource groups:
103
+
104
+ ```typescript
105
+ await app.register(mcpPlugin, { resources: catalogResources, prefix: '/mcp/catalog' });
106
+ await app.register(mcpPlugin, { resources: orderResources, prefix: '/mcp/orders' });
107
+ ```
108
+
109
+ ## Auth — Three Modes
110
+
111
+ Arc doesn't enforce an auth strategy. You choose what fits.
112
+
113
+ ### 1. No Auth (dev/testing/stdio)
114
+
115
+ ```typescript
116
+ await app.register(mcpPlugin, { resources, auth: false });
117
+ ```
118
+
119
+ All tools open. Every request gets `{ userId: 'anonymous' }`.
120
+
121
+ ### 2. Better Auth OAuth 2.1 (production SaaS)
122
+
123
+ Full MCP spec-compliant OAuth 2.1: authorization code + PKCE, token exchange, dynamic client registration.
124
+
125
+ ```typescript
126
+ // auth.config.ts — add mcp() plugin to Better Auth
127
+ import { mcp } from 'better-auth/plugins';
128
+ betterAuth({ plugins: [mcp({ loginPage: '/login' })] });
129
+
130
+ // app.ts
131
+ await app.register(mcpPlugin, { resources, auth: getAuth() });
132
+ ```
133
+
134
+ Auto-registers discovery endpoints:
135
+ - `GET /.well-known/oauth-authorization-server` (RFC 8414)
136
+ - `GET /.well-known/oauth-protected-resource` (RFC 9728)
137
+
138
+ The auth flow:
139
+ ```
140
+ MCP Client → discovers OAuth endpoints → registers → user authorizes → gets token
141
+ MCP Client → POST /mcp (Authorization: Bearer <token>)
142
+ Arc → auth.api.getMcpSession({ headers }) → { userId, organizationId }
143
+ Arc → BaseController scopes by org automatically
144
+ ```
145
+
146
+ ### 3. Custom Auth Function (API key, gateway, static org)
147
+
148
+ Pass an `McpAuthResolver` — a function that receives headers and returns identity:
149
+
150
+ ```typescript
151
+ type McpAuthResolver = (headers: Record<string, string | undefined>) =>
152
+ Promise<McpAuthResult | null> | McpAuthResult | null;
153
+ ```
154
+
155
+ Return `{ userId, organizationId? }` to allow. Return `null` to reject (401).
156
+
157
+ ```typescript
158
+ // API key
159
+ auth: async (headers) => {
160
+ if (headers['x-api-key'] !== process.env.MCP_API_KEY) return null;
161
+ return { userId: 'service', organizationId: 'org-123' };
162
+ },
163
+
164
+ // Gateway-validated JWT (token already verified upstream)
165
+ auth: async (headers) => {
166
+ const userId = headers['x-user-id'];
167
+ const orgId = headers['x-org-id'];
168
+ return userId ? { userId, organizationId: orgId } : null;
169
+ },
170
+
171
+ // Static org (trusted internal network)
172
+ auth: async () => ({ userId: 'internal', organizationId: 'org-main' }),
173
+
174
+ // Bearer token with custom validation
175
+ auth: async (headers) => {
176
+ const token = headers['authorization']?.replace('Bearer ', '');
177
+ if (!token) return null;
178
+ const payload = await verifyJwt(token);
179
+ return payload ? { userId: payload.sub, organizationId: payload.org } : null;
180
+ },
181
+ ```
182
+
183
+ ### Multi-Tenancy
184
+
185
+ The `organizationId` from auth flows into BaseController's org-scoping automatically:
186
+
187
+ ```
188
+ auth resolver returns { userId: 'alice', organizationId: 'org-a' }
189
+ → buildRequestContext sets _scope: { kind: 'member', organizationId: 'org-a' }
190
+ → QueryResolver adds { organizationId: 'org-a' } to every query
191
+ → Agent only sees org-a's data — no cross-tenant leaks
192
+ ```
193
+
194
+ Works with any `tenantField` (`organizationId`, `workspaceId`, `teamId`). Resources with `tenantField: false` are visible to all authenticated users.
195
+
196
+ ## Level 2 — Custom Tools
197
+
198
+ ```typescript
199
+ import { createMcpServer, defineTool, definePrompt } from '@classytic/arc/mcp';
200
+ import { z } from 'zod';
201
+
202
+ const server = await createMcpServer({
203
+ name: 'social-automation',
204
+ version: '1.0.0',
205
+ instructions: 'Use list_providers first.',
206
+ tools: [
207
+ defineTool('send_notification', {
208
+ description: 'Send a notification to a user or channel',
209
+ input: {
210
+ channel: z.enum(['email', 'telegram', 'whatsapp']),
211
+ recipient: z.string(),
212
+ message: z.string(),
213
+ },
214
+ annotations: { openWorldHint: true },
215
+ handler: async ({ channel, recipient, message }) => {
216
+ const result = await notificationService.send(channel, recipient, message);
217
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
218
+ },
219
+ }),
220
+ ],
221
+ prompts: [
222
+ definePrompt('content_calendar', {
223
+ description: 'Plan a content calendar',
224
+ args: { platforms: z.string(), theme: z.string().optional() },
225
+ handler: ({ platforms, theme }) => ({
226
+ messages: [{ role: 'user', content: { type: 'text', text: `Plan content for ${platforms}` } }],
227
+ }),
228
+ }),
229
+ ],
230
+ });
231
+ ```
232
+
233
+ ### defineTool() — Input is a flat Zod shape
234
+
235
+ Pass `{ field: z.string() }`, NOT `z.object(...)`. The SDK wraps it internally.
236
+
237
+ ```typescript
238
+ defineTool('get_weather', {
239
+ description: 'Get weather for a city',
240
+ input: { city: z.string(), units: z.enum(['celsius', 'fahrenheit']).optional() },
241
+ annotations: { readOnlyHint: true, openWorldHint: true },
242
+ handler: async ({ city, units }) => ({
243
+ content: [{ type: 'text', text: JSON.stringify(await weatherApi.get(city, units)) }],
244
+ }),
245
+ });
246
+ ```
247
+
248
+ ## Project Structure
249
+
250
+ Resources stay in `src/resources/`. Custom MCP tools co-locate with their resource:
251
+
252
+ ```
253
+ src/resources/
254
+ product/
255
+ product.model.ts
256
+ product.resource.ts
257
+ product.mcp.ts ← custom MCP tools (optional)
258
+ order/
259
+ order.model.ts
260
+ order.resource.ts
261
+ order.mcp.ts ← domain-specific tools (fulfill, cancel, track)
262
+ ```
263
+
264
+ Generate with CLI: `arc generate resource order --mcp` or `arc generate mcp analytics`
265
+
266
+ Wire in app.ts:
267
+
268
+ ```typescript
269
+ import { fulfillOrderTool } from './resources/order/order.mcp.js';
270
+
271
+ await app.register(mcpPlugin, {
272
+ resources,
273
+ extraTools: [fulfillOrderTool],
274
+ });
275
+ ```
276
+
277
+ ## Guards — Permissions for Custom Tools
278
+
279
+ Auto-generated tools go through BaseController (permissions enforced automatically). Custom tools need explicit guards:
280
+
281
+ ```typescript
282
+ import { defineTool, guard, requireAuth, requireOrg, requireRole, customGuard } from '@classytic/arc/mcp';
283
+ ```
284
+
285
+ ### guard() Wrapper — Compose Guards
286
+
287
+ ```typescript
288
+ // Admin-only tool
289
+ defineTool('delete_all', {
290
+ description: 'Delete all records',
291
+ handler: guard(requireAuth, requireOrg, requireRole('admin'), async (input, ctx) => {
292
+ // Only runs if: authenticated + has org + has admin role
293
+ return { content: [{ type: 'text', text: 'Deleted' }] };
294
+ }),
295
+ });
296
+ ```
297
+
298
+ ### Built-in Guards
299
+
300
+ | Guard | Rejects when |
301
+ |-------|-------------|
302
+ | `requireAuth` | No session or anonymous |
303
+ | `requireOrg` | No `organizationId` in session |
304
+ | `requireRole('admin')` | User lacks the role (checks `session.roles`) |
305
+ | `requireRole('admin', 'editor')` | User has neither role (OR logic) |
306
+ | `requireOrgId('org-x')` | Session org doesn't match |
307
+ | `customGuard(fn, msg)` | Predicate returns false |
308
+
309
+ ### Inline Checks
310
+
311
+ For conditional logic inside handlers instead of wrapping:
312
+
313
+ ```typescript
314
+ import { isAuthenticated, hasOrg, getUserId, getOrgId, denied } from '@classytic/arc/mcp';
315
+
316
+ defineTool('flexible_action', {
317
+ description: 'Does different things based on auth',
318
+ handler: async (input, ctx) => {
319
+ if (!isAuthenticated(ctx)) return denied('Login required');
320
+
321
+ const userId = getUserId(ctx);
322
+ const orgId = getOrgId(ctx);
323
+
324
+ // Different behavior for different orgs
325
+ if (orgId === 'org-premium') {
326
+ // premium features
327
+ }
328
+
329
+ return { content: [{ type: 'text', text: `Done by ${userId}` }] };
330
+ },
331
+ });
332
+ ```
333
+
334
+ ### Custom Guard
335
+
336
+ ```typescript
337
+ const businessHours = customGuard(
338
+ () => { const h = new Date().getHours(); return h >= 9 && h < 17; },
339
+ 'Only available during business hours (9-5)',
340
+ );
341
+
342
+ const maxRequestsGuard = customGuard(
343
+ async (ctx) => await rateLimiter.check(ctx.session?.userId),
344
+ 'Rate limit exceeded',
345
+ );
346
+
347
+ defineTool('sensitive_op', {
348
+ description: 'Time-restricted operation',
349
+ handler: guard(requireAuth, businessHours, async (input, ctx) => { ... }),
350
+ });
351
+ ```
352
+
353
+ ### Auth Resolver with Roles
354
+
355
+ For guards to check roles, your auth resolver must return them:
356
+
357
+ ```typescript
358
+ auth: async (headers) => {
359
+ const token = await verifyJwt(headers['authorization']);
360
+ return {
361
+ userId: token.sub,
362
+ organizationId: token.org,
363
+ roles: token.roles, // ← requireRole() checks this
364
+ orgRoles: token.orgRoles, // ← for org-level permissions
365
+ };
366
+ },
367
+ ```
368
+
369
+ ## fieldRulesToZod — Schema Conversion
370
+
371
+ Convert Arc's `schemaOptions.fieldRules` to flat Zod shapes for custom tools:
372
+
373
+ ```typescript
374
+ import { fieldRulesToZod } from '@classytic/arc/mcp';
375
+
376
+ const createShape = fieldRulesToZod(resource.schemaOptions.fieldRules, {
377
+ mode: 'create', // 'create' | 'update' | 'list'
378
+ hiddenFields: ['internalScore'],
379
+ readonlyFields: ['slug'],
380
+ });
381
+ // → { name: z.string(), price: z.number(), category: z.enum([...]) }
382
+ ```
383
+
384
+ ## Schema Discovery — MCP Resources
385
+
386
+ Auto-registered for agent discovery:
387
+
388
+ - `arc://schemas` — list all resources with field counts, operations, presets
389
+ - `arc://schemas/{name}` — full schema for a specific resource
390
+
391
+ ## Testing with Claude CLI
392
+
393
+ ```bash
394
+ claude mcp add --transport http my-api http://localhost:3000/mcp
395
+ echo "List all products" | claude -p --allowedTools "mcp__my-api__*"
396
+ claude mcp remove my-api
397
+ ```
398
+
399
+ ## Transport Modes
400
+
401
+ ### Stateless (default) — Production
402
+
403
+ Fresh server per request. No session tracking, no memory overhead. Best for horizontal scaling, serverless, edge.
404
+
405
+ ```typescript
406
+ await app.register(mcpPlugin, { resources, auth: false });
407
+ // stateful defaults to false — stateless mode
408
+ ```
409
+
410
+ - `POST /mcp` — each request gets a fresh server + transport
411
+ - `GET /mcp` — returns 405 (no SSE in stateless mode)
412
+ - `DELETE /mcp` — no-op
413
+
414
+ ### Stateful — When Needed
415
+
416
+ Sessions cached with TTL. Use when you need server-initiated notifications or long-lived connections.
417
+
418
+ ```typescript
419
+ await app.register(mcpPlugin, {
420
+ resources,
421
+ stateful: true, // enable session persistence
422
+ sessionTtlMs: 600000, // 10 min TTL
423
+ maxSessions: 500, // max concurrent sessions
424
+ });
425
+ ```
426
+
427
+ - `POST /mcp` — reuses session via `Mcp-Session-Id` header, or creates new
428
+ - `GET /mcp` — SSE stream for server-initiated messages
429
+ - `DELETE /mcp` — terminates session
430
+
431
+ Sessions: lazily created, TTL-cached, LRU-evicted at max capacity, auto-cleaned on shutdown.