@classytic/arc 2.4.1 → 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.
package/dist/index.mjs CHANGED
@@ -126,6 +126,6 @@ function transform(name, handlerOrOptions) {
126
126
  }
127
127
  //#endregion
128
128
  //#region src/index.ts
129
- const version = "2.4.1";
129
+ const version = "2.4.2";
130
130
  //#endregion
131
131
  export { ArcError, BaseController, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, ForbiddenError, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, MongooseAdapter, NotFoundError, PrismaAdapter, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, UnauthorizedError, ValidationError, adminOnly, allOf, allowPublic, anyOf, applyFieldReadPermissions, applyFieldWritePermissions, arcLog, assertValidConfig, authenticated, configureArcLogger, createDynamicPermissionMatrix, createMongooseAdapter, createOrgPermissions, createPrismaAdapter, defineResource, denyAll, fields, formatValidationErrors, fullPublic, getControllerScope, guard, intercept, middleware, ownerWithAdminBypass, presets_exports as permissions, pipe, publicRead, publicReadAdminWrite, readOnly, requestContext, requireAuth, requireOrgMembership, requireOrgRole, requireOwnership, requireRoles, requireTeamMembership, sortMiddlewares, transform, validateResourceConfig, version, when };
@@ -1,4 +1,4 @@
1
- import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-B6ZN9Ing.mjs";
1
+ import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-PMFE8HIv.mjs";
2
2
  import { createHash } from "node:crypto";
3
3
  import fp from "fastify-plugin";
4
4
  //#region src/integrations/mcp/definePrompt.ts
@@ -1,4 +1,4 @@
1
- import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-B6ZN9Ing.mjs";
1
+ import { r as createMcpServer, t as resourceToTools } from "../../resourceToTools-PMFE8HIv.mjs";
2
2
  //#region src/integrations/mcp/testing.ts
3
3
  /**
4
4
  * @classytic/arc/mcp/testing — MCP Test Utilities
@@ -1,6 +1,41 @@
1
- import mongoose from "mongoose";
2
-
3
1
  //#region src/migrations/index.d.ts
2
+ /**
3
+ * Schema Versioning and Migrations System
4
+ *
5
+ * Manages database schema changes over time with version tracking.
6
+ * Supports forward migrations, rollbacks, and schema compatibility layers.
7
+ *
8
+ * DB-agnostic: the `db` parameter is typed as `unknown` — the user passes
9
+ * whatever connection object their adapter uses (Mongoose db, Prisma client,
10
+ * Knex instance, etc.) and their `up`/`down` functions cast it internally.
11
+ *
12
+ * @example
13
+ * import { defineMigration, MigrationRunner } from '@classytic/arc/migrations';
14
+ *
15
+ * const productV2 = defineMigration({
16
+ * version: 2,
17
+ * resource: 'product',
18
+ * up: async (db) => {
19
+ * const mongo = db as import('mongoose').mongo.Db;
20
+ * await mongo.collection('products').updateMany(
21
+ * {},
22
+ * { $rename: { 'oldField': 'newField' } }
23
+ * );
24
+ * },
25
+ * down: async (db) => {
26
+ * const mongo = db as import('mongoose').mongo.Db;
27
+ * await mongo.collection('products').updateMany(
28
+ * {},
29
+ * { $rename: { 'newField': 'oldField' } }
30
+ * );
31
+ * },
32
+ * });
33
+ *
34
+ * const runner = new MigrationRunner(mongoose.connection.db, {
35
+ * store: new MongoMigrationStore(mongoose.connection.db),
36
+ * });
37
+ * await runner.up(migrations);
38
+ */
4
39
  interface Migration {
5
40
  /** Migration version (sequential number) */
6
41
  version: number;
@@ -9,17 +44,18 @@ interface Migration {
9
44
  /** Description of the migration */
10
45
  description?: string;
11
46
  /**
12
- * Forward migration (apply schema change)
47
+ * Forward migration (apply schema change).
48
+ * The `db` parameter is whatever connection object you pass to the runner.
13
49
  */
14
- up: (db: mongoose.mongo.Db) => Promise<void>;
50
+ up: (db: unknown) => Promise<void>;
15
51
  /**
16
- * Backward migration (revert schema change)
52
+ * Backward migration (revert schema change).
17
53
  */
18
- down: (db: mongoose.mongo.Db) => Promise<void>;
54
+ down: (db: unknown) => Promise<void>;
19
55
  /**
20
56
  * Optional validation that data is compatible after migration
21
57
  */
22
- validate?: (db: mongoose.mongo.Db) => Promise<boolean>;
58
+ validate?: (db: unknown) => Promise<boolean>;
23
59
  }
24
60
  interface MigrationRecord {
25
61
  version: number;
@@ -28,6 +64,54 @@ interface MigrationRecord {
28
64
  appliedAt: Date;
29
65
  executionTime: number;
30
66
  }
67
+ /**
68
+ * DB-agnostic migration store interface.
69
+ *
70
+ * Users implement this for their database:
71
+ * - MongoMigrationStore (uses a `_migrations` collection)
72
+ * - PrismaMigrationStore (uses a `_migrations` table)
73
+ * - or any custom store
74
+ */
75
+ interface MigrationStore {
76
+ /** Get all applied migration records, sorted by appliedAt ascending */
77
+ getApplied(): Promise<MigrationRecord[]>;
78
+ /** Record a completed migration */
79
+ record(migration: Migration, executionTime: number): Promise<void>;
80
+ /** Remove a migration record (for rollback) */
81
+ remove(migration: Migration): Promise<void>;
82
+ }
83
+ /**
84
+ * Minimal logger interface — matches Fastify's logger, pino, console, etc.
85
+ */
86
+ interface MigrationLogger {
87
+ info(msg: string): void;
88
+ error(msg: string): void;
89
+ }
90
+ /**
91
+ * MongoDB-backed migration store.
92
+ *
93
+ * Uses a `_migrations` collection in the same database.
94
+ * The `db` parameter accepts any object with a `.collection()` method
95
+ * (Mongoose db, native MongoDB Db, etc.)
96
+ */
97
+ declare class MongoMigrationStore implements MigrationStore {
98
+ private readonly collectionName;
99
+ private readonly db;
100
+ constructor(db: {
101
+ collection(name: string): any;
102
+ }, opts?: {
103
+ collectionName?: string;
104
+ });
105
+ getApplied(): Promise<MigrationRecord[]>;
106
+ record(migration: Migration, executionTime: number): Promise<void>;
107
+ remove(migration: Migration): Promise<void>;
108
+ }
109
+ interface MigrationRunnerOptions {
110
+ /** Migration store (required — use MongoMigrationStore or implement your own) */
111
+ store: MigrationStore;
112
+ /** Logger (defaults to process.stdout/stderr) */
113
+ logger?: MigrationLogger;
114
+ }
31
115
  /**
32
116
  * Define a migration
33
117
  */
@@ -35,12 +119,30 @@ declare function defineMigration(migration: Migration): Migration;
35
119
  /**
36
120
  * Migration Runner
37
121
  *
38
- * Manages execution of migrations with tracking and rollback support.
122
+ * DB-agnostic. Manages execution of migrations with tracking and rollback.
123
+ * The `db` parameter is passed through to migration `up`/`down` functions
124
+ * as-is — the runner never touches it directly.
125
+ *
126
+ * @example
127
+ * ```typescript
128
+ * // MongoDB
129
+ * const runner = new MigrationRunner(mongoose.connection.db, {
130
+ * store: new MongoMigrationStore(mongoose.connection.db),
131
+ * });
132
+ *
133
+ * // Prisma
134
+ * const runner = new MigrationRunner(prisma, {
135
+ * store: new PrismaMigrationStore(prisma), // user-implemented
136
+ * });
137
+ *
138
+ * await runner.up(migrations);
139
+ * ```
39
140
  */
40
141
  declare class MigrationRunner {
41
- private readonly collectionName;
42
142
  private readonly db;
43
- constructor(db: mongoose.mongo.Db);
143
+ private readonly store;
144
+ private readonly log;
145
+ constructor(db: unknown, opts: MigrationRunnerOptions);
44
146
  /**
45
147
  * Run all pending migrations
46
148
  */
@@ -69,14 +171,6 @@ declare class MigrationRunner {
69
171
  * Run a single migration
70
172
  */
71
173
  private runMigration;
72
- /**
73
- * Record a completed migration
74
- */
75
- private recordMigration;
76
- /**
77
- * Remove a migration record
78
- */
79
- private removeMigration;
80
174
  }
81
175
  /**
82
176
  * Schema version definition for resources
@@ -127,30 +221,5 @@ declare class MigrationRegistry {
127
221
  */
128
222
  clear(): void;
129
223
  }
130
- /**
131
- * Global migration registry instance
132
- */
133
- declare const migrationRegistry: MigrationRegistry;
134
- /**
135
- * Common migration helpers
136
- */
137
- declare const migrationHelpers: {
138
- /**
139
- * Rename a field across all documents
140
- */
141
- renameField: (collection: string, oldName: string, newName: string) => Migration;
142
- /**
143
- * Add a new field with default value
144
- */
145
- addField: (collection: string, fieldName: string, defaultValue: unknown) => Migration;
146
- /**
147
- * Remove a field
148
- */
149
- removeField: (collection: string, fieldName: string) => Migration;
150
- /**
151
- * Create an index
152
- */
153
- createIndex: (collection: string, fields: Record<string, 1 | -1>, options?: Record<string, unknown>) => Migration;
154
- };
155
224
  //#endregion
156
- export { Migration, MigrationRecord, MigrationRegistry, MigrationRunner, SchemaVersion, defineMigration, migrationHelpers, migrationRegistry, withSchemaVersion };
225
+ export { Migration, MigrationLogger, MigrationRecord, MigrationRegistry, MigrationRunner, MigrationRunnerOptions, MigrationStore, MongoMigrationStore, SchemaVersion, defineMigration, withSchemaVersion };
@@ -1,4 +1,42 @@
1
1
  //#region src/migrations/index.ts
2
+ /** Default logger that writes to stdout/stderr */
3
+ const defaultLogger = {
4
+ info: (msg) => process.stdout.write(`${msg}\n`),
5
+ error: (msg) => process.stderr.write(`${msg}\n`)
6
+ };
7
+ /**
8
+ * MongoDB-backed migration store.
9
+ *
10
+ * Uses a `_migrations` collection in the same database.
11
+ * The `db` parameter accepts any object with a `.collection()` method
12
+ * (Mongoose db, native MongoDB Db, etc.)
13
+ */
14
+ var MongoMigrationStore = class {
15
+ collectionName;
16
+ db;
17
+ constructor(db, opts) {
18
+ this.db = db;
19
+ this.collectionName = opts?.collectionName ?? "_migrations";
20
+ }
21
+ async getApplied() {
22
+ return await this.db.collection(this.collectionName).find({}).sort({ appliedAt: 1 }).toArray();
23
+ }
24
+ async record(migration, executionTime) {
25
+ await this.db.collection(this.collectionName).insertOne({
26
+ version: migration.version,
27
+ resource: migration.resource,
28
+ description: migration.description,
29
+ appliedAt: /* @__PURE__ */ new Date(),
30
+ executionTime
31
+ });
32
+ }
33
+ async remove(migration) {
34
+ await this.db.collection(this.collectionName).deleteOne({
35
+ version: migration.version,
36
+ resource: migration.resource
37
+ });
38
+ }
39
+ };
2
40
  /**
3
41
  * Define a migration
4
42
  */
@@ -8,77 +46,97 @@ function defineMigration(migration) {
8
46
  /**
9
47
  * Migration Runner
10
48
  *
11
- * Manages execution of migrations with tracking and rollback support.
49
+ * DB-agnostic. Manages execution of migrations with tracking and rollback.
50
+ * The `db` parameter is passed through to migration `up`/`down` functions
51
+ * as-is — the runner never touches it directly.
52
+ *
53
+ * @example
54
+ * ```typescript
55
+ * // MongoDB
56
+ * const runner = new MigrationRunner(mongoose.connection.db, {
57
+ * store: new MongoMigrationStore(mongoose.connection.db),
58
+ * });
59
+ *
60
+ * // Prisma
61
+ * const runner = new MigrationRunner(prisma, {
62
+ * store: new PrismaMigrationStore(prisma), // user-implemented
63
+ * });
64
+ *
65
+ * await runner.up(migrations);
66
+ * ```
12
67
  */
13
68
  var MigrationRunner = class {
14
- collectionName = "_migrations";
15
69
  db;
16
- constructor(db) {
70
+ store;
71
+ log;
72
+ constructor(db, opts) {
17
73
  this.db = db;
74
+ this.store = opts.store;
75
+ this.log = opts.logger ?? defaultLogger;
18
76
  }
19
77
  /**
20
78
  * Run all pending migrations
21
79
  */
22
80
  async up(migrations) {
23
- const applied = await this.getAppliedMigrations();
81
+ const applied = await this.store.getApplied();
24
82
  const appliedVersions = new Set(applied.map((m) => `${m.resource}:${m.version}`));
25
83
  const pending = migrations.filter((m) => !appliedVersions.has(`${m.resource}:${m.version}`)).sort((a, b) => a.version - b.version);
26
84
  if (pending.length === 0) {
27
- console.log("No pending migrations");
85
+ this.log.info("No pending migrations");
28
86
  return;
29
87
  }
30
- console.log(`Running ${pending.length} migration(s)...\n`);
88
+ this.log.info(`Running ${pending.length} migration(s)...`);
31
89
  for (const migration of pending) await this.runMigration(migration, "up");
32
- console.log("\nAll migrations completed successfully");
90
+ this.log.info("All migrations completed successfully");
33
91
  }
34
92
  /**
35
93
  * Rollback last migration
36
94
  */
37
95
  async down(migrations) {
38
- const applied = await this.getAppliedMigrations();
96
+ const applied = await this.store.getApplied();
39
97
  if (applied.length === 0) {
40
- console.log("No migrations to rollback");
98
+ this.log.info("No migrations to rollback");
41
99
  return;
42
100
  }
43
101
  const last = applied[applied.length - 1];
44
102
  if (!last) {
45
- console.log("No migrations to rollback");
103
+ this.log.info("No migrations to rollback");
46
104
  return;
47
105
  }
48
106
  const migration = migrations.find((m) => m.resource === last.resource && m.version === last.version);
49
107
  if (!migration) throw new Error(`Migration ${last.resource}:${last.version} not found in migration files`);
50
- console.log(`Rolling back ${migration.resource} v${migration.version}...`);
108
+ this.log.info(`Rolling back ${migration.resource} v${migration.version}...`);
51
109
  await this.runMigration(migration, "down", true);
52
- console.log("Rollback completed");
110
+ this.log.info("Rollback completed");
53
111
  }
54
112
  /**
55
113
  * Rollback to specific version
56
114
  */
57
115
  async downTo(migrations, targetVersion) {
58
- const toRollback = (await this.getAppliedMigrations()).filter((m) => m.version > targetVersion).reverse();
116
+ const toRollback = (await this.store.getApplied()).filter((m) => m.version > targetVersion).reverse();
59
117
  if (toRollback.length === 0) {
60
- console.log(`Already at or below version ${targetVersion}`);
118
+ this.log.info(`Already at or below version ${targetVersion}`);
61
119
  return;
62
120
  }
63
- console.log(`Rolling back ${toRollback.length} migration(s)...\n`);
121
+ this.log.info(`Rolling back ${toRollback.length} migration(s)...`);
64
122
  for (const record of toRollback) {
65
123
  const migration = migrations.find((m) => m.resource === record.resource && m.version === record.version);
66
124
  if (!migration) throw new Error(`Migration ${record.resource}:${record.version} not found`);
67
125
  await this.runMigration(migration, "down", true);
68
126
  }
69
- console.log("\nRollback completed");
127
+ this.log.info("Rollback completed");
70
128
  }
71
129
  /**
72
130
  * Get all applied migrations
73
131
  */
74
132
  async getAppliedMigrations() {
75
- return await this.db.collection(this.collectionName).find({}).sort({ appliedAt: 1 }).toArray();
133
+ return this.store.getApplied();
76
134
  }
77
135
  /**
78
136
  * Get pending migrations
79
137
  */
80
138
  async getPendingMigrations(migrations) {
81
- const applied = await this.getAppliedMigrations();
139
+ const applied = await this.store.getApplied();
82
140
  const appliedVersions = new Set(applied.map((m) => `${m.resource}:${m.version}`));
83
141
  return migrations.filter((m) => !appliedVersions.has(`${m.resource}:${m.version}`));
84
142
  }
@@ -93,46 +151,28 @@ var MigrationRunner = class {
93
151
  */
94
152
  async runMigration(migration, direction, isRollback = false) {
95
153
  const start = Date.now();
96
- console.log(`${direction === "up" ? "Applying" : "Rolling back"} ${migration.resource} v${migration.version}${migration.description ? `: ${migration.description}` : ""}...`);
154
+ const action = direction === "up" ? "Applying" : "Rolling back";
155
+ const label = `${migration.resource} v${migration.version}`;
156
+ const desc = migration.description ? `: ${migration.description}` : "";
157
+ this.log.info(`${action} ${label}${desc}...`);
97
158
  try {
98
159
  if (direction === "up") {
99
160
  await migration.up(this.db);
100
161
  if (migration.validate) {
101
162
  if (!await migration.validate(this.db)) throw new Error("Migration validation failed");
102
163
  }
103
- await this.recordMigration(migration, Date.now() - start);
164
+ await this.store.record(migration, Date.now() - start);
104
165
  } else {
105
166
  await migration.down(this.db);
106
- if (isRollback) await this.removeMigration(migration);
167
+ if (isRollback) await this.store.remove(migration);
107
168
  }
108
169
  const duration = Date.now() - start;
109
- console.log(`✅ ${migration.resource} v${migration.version} (${duration}ms)`);
170
+ this.log.info(`${label} completed (${duration}ms)`);
110
171
  } catch (error) {
111
- console.error(`❌ ${migration.resource} v${migration.version} failed:`, error.message);
172
+ this.log.error(`${label} failed: ${error.message}`);
112
173
  throw error;
113
174
  }
114
175
  }
115
- /**
116
- * Record a completed migration
117
- */
118
- async recordMigration(migration, executionTime) {
119
- await this.db.collection(this.collectionName).insertOne({
120
- version: migration.version,
121
- resource: migration.resource,
122
- description: migration.description,
123
- appliedAt: /* @__PURE__ */ new Date(),
124
- executionTime
125
- });
126
- }
127
- /**
128
- * Remove a migration record
129
- */
130
- async removeMigration(migration) {
131
- await this.db.collection(this.collectionName).deleteOne({
132
- version: migration.version,
133
- resource: migration.resource
134
- });
135
- }
136
176
  };
137
177
  /**
138
178
  * Add versioning to resource definition
@@ -198,59 +238,5 @@ var MigrationRegistry = class {
198
238
  this.migrations.clear();
199
239
  }
200
240
  };
201
- /**
202
- * Global migration registry instance
203
- */
204
- const migrationRegistry = new MigrationRegistry();
205
- /**
206
- * Common migration helpers
207
- */
208
- const migrationHelpers = {
209
- renameField: (collection, oldName, newName) => defineMigration({
210
- version: 0,
211
- resource: collection,
212
- description: `Rename ${oldName} to ${newName}`,
213
- up: async (db) => {
214
- await db.collection(collection).updateMany({}, { $rename: { [oldName]: newName } });
215
- },
216
- down: async (db) => {
217
- await db.collection(collection).updateMany({}, { $rename: { [newName]: oldName } });
218
- }
219
- }),
220
- addField: (collection, fieldName, defaultValue) => defineMigration({
221
- version: 0,
222
- resource: collection,
223
- description: `Add ${fieldName} field`,
224
- up: async (db) => {
225
- await db.collection(collection).updateMany({ [fieldName]: { $exists: false } }, { $set: { [fieldName]: defaultValue } });
226
- },
227
- down: async (db) => {
228
- await db.collection(collection).updateMany({}, { $unset: { [fieldName]: "" } });
229
- }
230
- }),
231
- removeField: (collection, fieldName) => defineMigration({
232
- version: 0,
233
- resource: collection,
234
- description: `Remove ${fieldName} field`,
235
- up: async (db) => {
236
- await db.collection(collection).updateMany({}, { $unset: { [fieldName]: "" } });
237
- },
238
- down: async (_db) => {
239
- console.warn(`Cannot restore ${fieldName} field - data was deleted`);
240
- }
241
- }),
242
- createIndex: (collection, fields, options) => defineMigration({
243
- version: 0,
244
- resource: collection,
245
- description: `Create index on ${Object.keys(fields).join(", ")}`,
246
- up: async (db) => {
247
- await db.collection(collection).createIndex(fields, options);
248
- },
249
- down: async (db) => {
250
- const indexName = typeof options?.name === "string" ? options.name : Object.keys(fields).join("_");
251
- await db.collection(collection).dropIndex(indexName);
252
- }
253
- })
254
- };
255
241
  //#endregion
256
- export { MigrationRegistry, MigrationRunner, defineMigration, migrationHelpers, migrationRegistry, withSchemaVersion };
242
+ export { MigrationRegistry, MigrationRunner, MongoMigrationStore, defineMigration, withSchemaVersion };
@@ -44,7 +44,7 @@ try {
44
44
  function createTracerProvider(options) {
45
45
  if (!isAvailable) return null;
46
46
  const { serviceName = "@classytic/arc", serviceVersion, exporterUrl = "http://localhost:4318/v1/traces" } = options;
47
- const resolvedVersion = serviceVersion ?? "2.4.1";
47
+ const resolvedVersion = serviceVersion ?? "2.4.2";
48
48
  const exporter = new OTLPTraceExporter({ url: exporterUrl });
49
49
  const provider = new NodeTracerProvider({ resource: { attributes: {
50
50
  "service.name": serviceName,
@@ -174,18 +174,19 @@ function buildListShape(fieldRules, options) {
174
174
  * | update | { id } | {} | input minus id |
175
175
  * | delete | { id } | {} | undefined |
176
176
  */
177
- function buildRequestContext(input, auth, operation) {
177
+ function buildRequestContext(input, auth, operation, policyFilters) {
178
178
  const scope = buildScope(auth);
179
179
  const base = {
180
180
  user: auth ? {
181
181
  id: auth.userId,
182
- _id: auth.userId
182
+ _id: auth.userId,
183
+ ...auth
183
184
  } : null,
184
185
  headers: {},
185
186
  context: {},
186
187
  metadata: {
187
188
  _scope: scope,
188
- _policyFilters: {}
189
+ _policyFilters: policyFilters ?? {}
189
190
  }
190
191
  };
191
192
  switch (operation) {
@@ -315,7 +316,7 @@ function resourceToTools(resource, config = {}) {
315
316
  extraHideFields: config.hideFields,
316
317
  filterableFields
317
318
  }),
318
- handler: createHandler(op, controller, resource.name)
319
+ handler: createHandler(op, controller, resource.name, resource.permissions)
319
320
  });
320
321
  }
321
322
  for (const route of resource.additionalRoutes ?? []) {
@@ -381,7 +382,7 @@ function buildInputSchema(op, fieldRules, opts) {
381
382
  case "delete": return { id: z.string().describe("Resource ID") };
382
383
  }
383
384
  }
384
- function createHandler(op, controller, resourceName) {
385
+ function createHandler(op, controller, resourceName, permissions) {
385
386
  const ctrl = controller;
386
387
  return async (input, ctx) => {
387
388
  try {
@@ -393,7 +394,15 @@ function createHandler(op, controller, resourceName) {
393
394
  }],
394
395
  isError: true
395
396
  };
396
- return toCallToolResult(await method(buildRequestContext(input, ctx.session, op)));
397
+ const policyFilters = await evaluatePermission(permissions?.[op], ctx.session, resourceName, op, input);
398
+ if (policyFilters === false) return {
399
+ content: [{
400
+ type: "text",
401
+ text: `Permission denied: ${op} on ${resourceName}`
402
+ }],
403
+ isError: true
404
+ };
405
+ return toCallToolResult(await method(buildRequestContext(input, ctx.session, op, policyFilters || void 0)));
397
406
  } catch (err) {
398
407
  const msg = err instanceof Error ? err.message : String(err);
399
408
  ctx.log("error", `${resourceName}.${op}: ${msg}`).catch(() => {});
@@ -432,6 +441,41 @@ function createAdditionalRouteHandler(route, controller, hasId) {
432
441
  }
433
442
  };
434
443
  }
444
+ /**
445
+ * Evaluate a resource's permission check in MCP context.
446
+ *
447
+ * Returns:
448
+ * - `false` if permission denied
449
+ * - `Record<string, unknown>` if granted with filters (ownership patterns)
450
+ * - `null` if granted without filters (or no permission check defined)
451
+ */
452
+ async function evaluatePermission(check, session, resource, action, input) {
453
+ if (!check) return null;
454
+ const user = session ? {
455
+ id: session.userId,
456
+ _id: session.userId,
457
+ ...session
458
+ } : null;
459
+ const result = await check({
460
+ user,
461
+ request: {
462
+ user,
463
+ headers: {},
464
+ params: {},
465
+ query: {},
466
+ body: input
467
+ },
468
+ resource,
469
+ action,
470
+ resourceId: typeof input.id === "string" ? input.id : void 0,
471
+ params: {},
472
+ data: input
473
+ });
474
+ if (typeof result === "boolean") return result ? null : false;
475
+ const permResult = result;
476
+ if (!permResult.granted) return false;
477
+ return permResult.filters ?? null;
478
+ }
435
479
  function toCallToolResult(result) {
436
480
  if (!result.success) return {
437
481
  content: [{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@classytic/arc",
3
- "version": "2.4.1",
3
+ "version": "2.4.2",
4
4
  "description": "Resource-oriented backend framework for Fastify — clean, minimal, powerful, tree-shakable",
5
5
  "type": "module",
6
6
  "exports": {
@@ -8,11 +8,11 @@ description: |
8
8
  Triggers: arc, fastify resource, defineResource, createApp, BaseController, arc preset,
9
9
  arc auth, arc events, arc jobs, arc websocket, arc mcp, arc plugin, arc testing, arc cli,
10
10
  arc permissions, arc hooks, arc pipeline, arc factory, arc cache, arc QueryCache.
11
- version: 2.4.0
11
+ version: 2.4.2
12
12
  license: MIT
13
13
  metadata:
14
14
  author: Classytic
15
- version: "2.4.0"
15
+ version: "2.4.1"
16
16
  tags:
17
17
  - fastify
18
18
  - rest-api
@@ -452,6 +452,18 @@ auth: async (headers) => {
452
452
 
453
453
  **Multi-tenancy**: `organizationId` from auth flows into BaseController org-scoping automatically.
454
454
 
455
+ **Permission filters**: `PermissionResult.filters` from resource permissions flow into MCP tools — same as REST. Define once, works everywhere:
456
+
457
+ ```typescript
458
+ permissions: {
459
+ list: (ctx) => ({
460
+ granted: !!ctx.user,
461
+ filters: { orgId: ctx.user?.orgId, branchId: ctx.user?.branchId },
462
+ }),
463
+ }
464
+ // MCP tools automatically scope queries by orgId + branchId
465
+ ```
466
+
455
467
  **Project structure** — custom MCP tools co-located with resources:
456
468
 
457
469
  ```
@@ -35,9 +35,11 @@ Tool handlers call `BaseController` — same pipeline as REST (auth, org-scoping
35
35
  | `serverName` | `string` | `'arc-mcp'` | Server identity |
36
36
  | `serverVersion` | `string` | `'1.0.0'` | Server version |
37
37
  | `instructions` | `string` | — | LLM guidance on tool usage |
38
+ | `include` | `string[]` | — | Only these resources get tools (overrides `exclude`) |
38
39
  | `exclude` | `string[]` | — | Resource names to exclude |
39
- | `toolNamePrefix` | `string` | — | Prefix: `'crm'` → `crm_list_products` |
40
- | `overrides` | `Record<string, McpResourceConfig>` | — | Per-resource operation/field overrides |
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 |
41
43
  | `extraTools` | `ToolDefinition[]` | — | Hand-written tools alongside auto-generated |
42
44
  | `extraPrompts` | `PromptDefinition[]` | — | Custom prompts |
43
45
  | `stateful` | `boolean` | `false` | `false` = stateless (default, scalable). `true` = session-cached. |
@@ -52,6 +54,49 @@ Tool handlers call `BaseController` — same pipeline as REST (auth, org-scoping
52
54
  | `create` | `destructiveHint: false` |
53
55
  | `update`, `delete` | `destructiveHint: true, idempotentHint: true` |
54
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
+
55
100
  ### Multiple MCP Endpoints
56
101
 
57
102
  Mount separate servers scoped to different resource groups: