@classytic/arc 2.10.3 → 2.11.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.
- package/README.md +1 -1
- package/dist/{BaseController-CbKKIflT.mjs → BaseController-JNV08qOT.mjs} +595 -537
- package/dist/{queryCachePlugin-BKbWjgDG.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- package/dist/actionPermissions-C8YYU92K.mjs +22 -0
- package/dist/adapters/index.d.mts +2 -2
- package/dist/adapters/index.mjs +1 -1
- package/dist/{adapters-BXY4i-hw.mjs → adapters-D0tT2Tyo.mjs} +54 -0
- package/dist/audit/index.d.mts +2 -2
- package/dist/audit/index.mjs +15 -17
- package/dist/auth/index.d.mts +4 -4
- package/dist/auth/index.mjs +3 -3
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/{betterAuthOpenApi-BBRVhjQN.mjs → betterAuthOpenApi-DwxtK3uG.mjs} +1 -1
- package/dist/cache/index.d.mts +3 -2
- package/dist/cache/index.mjs +3 -3
- package/dist/cli/commands/docs.mjs +2 -2
- package/dist/cli/commands/generate.mjs +37 -27
- package/dist/cli/commands/init.mjs +47 -34
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.d.mts +58 -0
- package/dist/context/index.mjs +2 -0
- package/dist/core/index.d.mts +3 -3
- package/dist/core/index.mjs +4 -3
- package/dist/core-DXdSSFW-.mjs +1037 -0
- package/dist/createActionRouter-BwaSM0No.mjs +166 -0
- package/dist/{createApp-BuvPma24.mjs → createApp-DvNYEhpb.mjs} +118 -36
- package/dist/docs/index.d.mts +2 -2
- package/dist/docs/index.mjs +1 -1
- package/dist/{elevation-C7hgL_aI.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/errorHandler-Co3lnVmJ.d.mts +114 -0
- package/dist/{eventPlugin-DCUjuiQT.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/{eventPlugin-CxWgpd6K.d.mts → eventPlugin-CUNjYYRY.d.mts} +1 -1
- package/dist/events/index.d.mts +4 -4
- package/dist/events/index.mjs +69 -51
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/factory/index.d.mts +1 -1
- package/dist/factory/index.mjs +2 -2
- package/dist/{fields-Lo1VUDpt.d.mts → fields-C8Y0XLAu.d.mts} +1 -1
- package/dist/hooks/index.d.mts +1 -1
- package/dist/hooks/index.mjs +1 -1
- package/dist/idempotency/index.d.mts +3 -3
- package/dist/idempotency/index.mjs +38 -27
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-ChIw3776.d.mts → index-BYCqHCVu.d.mts} +4 -4
- package/dist/{index-Cl0uoKd5.d.mts → index-Cm0vUrr_.d.mts} +2100 -1688
- package/dist/{index-DStwgFUK.d.mts → index-DAushRTt.d.mts} +29 -10
- package/dist/index-DsJ1MNfC.d.mts +1179 -0
- package/dist/{index-8qw4y6ff.d.mts → index-t8pLpPFW.d.mts} +13 -10
- package/dist/index.d.mts +7 -251
- package/dist/index.mjs +8 -128
- package/dist/integrations/event-gateway.d.mts +2 -2
- package/dist/integrations/event-gateway.mjs +1 -1
- package/dist/integrations/index.d.mts +2 -2
- package/dist/integrations/mcp/index.d.mts +2 -2
- package/dist/integrations/mcp/index.mjs +1 -1
- package/dist/integrations/mcp/testing.d.mts +1 -1
- package/dist/integrations/mcp/testing.mjs +1 -1
- package/dist/integrations/streamline.d.mts +46 -5
- package/dist/integrations/streamline.mjs +50 -21
- package/dist/integrations/websocket-redis.d.mts +1 -1
- package/dist/integrations/websocket.d.mts +2 -154
- package/dist/integrations/websocket.mjs +292 -224
- package/dist/{keys-qcD-TVJl.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-BAzJItAJ.mjs → loadResources-YNwKHvRA.mjs} +3 -1
- package/dist/logger/index.d.mts +81 -0
- package/dist/{logger-DLg8-Ueg.mjs → logger/index.mjs} +1 -6
- package/dist/middleware/index.d.mts +109 -0
- package/dist/middleware/index.mjs +70 -0
- package/dist/multipartBody-CvTR1Un6.mjs +123 -0
- package/dist/{openapi-B5F8AddX.mjs → openapi-C0L9ar7m.mjs} +9 -7
- package/dist/org/index.d.mts +2 -2
- package/dist/permissions/index.d.mts +2 -2
- package/dist/permissions/index.mjs +1 -3
- package/dist/{permissions-Dk6mshja.mjs → permissions-B4vU9L0Q.mjs} +220 -2
- package/dist/pipe-DVoIheVC.mjs +62 -0
- package/dist/pipeline/index.d.mts +62 -0
- package/dist/pipeline/index.mjs +53 -0
- package/dist/plugins/index.d.mts +25 -5
- package/dist/plugins/index.mjs +10 -10
- package/dist/plugins/response-cache.mjs +1 -1
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +42 -24
- package/dist/presets/filesUpload.d.mts +4 -4
- package/dist/presets/filesUpload.mjs +255 -1
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +2 -2
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +48 -8
- package/dist/presets/search.d.mts +2 -2
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-fLJVXdVn.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-DQCEfJis.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-DqyeggCa.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/{redis-stream-CakIQmwR.d.mts → redis-stream-CM8TXTix.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{requestContext-xHIKedG6.mjs → requestContext-CfRkaxwf.mjs} +1 -1
- package/dist/{resourceToTools-BElv3xPT.mjs → resourceToTools--okX6QBr.mjs} +534 -415
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.d.mts +2 -2
- package/dist/scope/index.mjs +1 -1
- package/dist/{sse-yBCgOLGu.mjs → sse-V7aXc3bW.mjs} +1 -1
- package/dist/{store-helpers-ZCSMJJAX.mjs → store-helpers-BhrzxvyQ.mjs} +4 -0
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +646 -1434
- package/dist/testing/storageContract.d.mts +1 -1
- package/dist/{tracing-65B51Dw3.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +5 -5
- package/dist/types/index.mjs +1 -3
- package/dist/types/storage.d.mts +1 -1
- package/dist/{types-Co8k3NyS.d.mts → types-CgikqKAj.d.mts} +133 -21
- package/dist/{types-Btdda02s.d.mts → types-D9NqiYIw.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -898
- package/dist/utils/index.mjs +4 -5
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/versioning-M9lNLhO8.d.mts +117 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +26 -8
- package/skills/arc/SKILL.md +124 -39
- package/skills/arc/references/testing.md +212 -183
- package/dist/applyPermissionResult-QhV1Pa-g.mjs +0 -37
- package/dist/core-CcR01lup.mjs +0 -1411
- package/dist/createActionRouter-Bp_5c_2b.mjs +0 -249
- package/dist/errorHandler-DRQ3EqfL.d.mts +0 -218
- package/dist/errors-CCSsMpXE.d.mts +0 -140
- package/dist/fields-bxkeltzz.mjs +0 -126
- package/dist/filesUpload-t21LS-py.mjs +0 -377
- package/dist/queryParser-DBqBB6AC.mjs +0 -352
- package/dist/types-Csi3FLfq.mjs +0 -27
- package/dist/utils-B2fNOD_i.mjs +0 -929
- /package/dist/{EventTransport-CUw5NNWe.d.mts → EventTransport-CfVEGaEl.d.mts} +0 -0
- /package/dist/{HookSystem-BNYKnrXF.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-BPd6NQDm.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{caching-CBpK_SCM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{elevation-C5SwtkAn.d.mts → elevation-s5ykdNHr.d.mts} +0 -0
- /package/dist/{errorHandler-Bb49BvPD.mjs → errorHandler-BQm8ZxTK.mjs} +0 -0
- /package/dist/{externalPaths-BQ8QijNH.d.mts → externalPaths-Bapitwvd.d.mts} +0 -0
- /package/dist/{interface-CSbZdv_3.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-D218ikEo.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-B5Amv9A1.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-DuhiSEZI.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{pluralize-A0tWEl1K.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{registry-B3lRFBWo.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-CXtJDAZ0.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
- /package/dist/{sessionManager-BkzVU8h2.d.mts → sessionManager-D-oNWHz3.d.mts} +0 -0
- /package/dist/{storage-CVk_SEn2.d.mts → storage-BwGQXUpd.d.mts} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-BD85MlEK.d.mts → types-tgR4Pt8F.d.mts} +0 -0
- /package/dist/{versioning-C2U_bLY0.mjs → versioning-CGPjkqAg.mjs} +0 -0
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
import { t as BaseController } from "./BaseController-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { t as BaseController } from "./BaseController-JNV08qOT.mjs";
|
|
2
|
+
import { A as normalizePermissionResult } from "./permissions-B4vU9L0Q.mjs";
|
|
3
|
+
import { u as resolvePipelineSteps } from "./routerShared-DeESFp4a.mjs";
|
|
4
|
+
import { t as executePipeline } from "./pipe-DVoIheVC.mjs";
|
|
5
|
+
import { t as resolveActionPermission } from "./actionPermissions-C8YYU92K.mjs";
|
|
6
|
+
import { i as shouldRejectAdditionalProperties, r as schemaIRToZodShape, t as normalizeSchemaIR } from "./schemaIR-BlG9bY7v.mjs";
|
|
7
|
+
import { t as pluralize } from "./pluralize-BneOJkpi.mjs";
|
|
4
8
|
import { z } from "zod";
|
|
5
9
|
//#region src/integrations/mcp/createMcpServer.ts
|
|
6
10
|
/**
|
|
@@ -373,6 +377,246 @@ function buildScope(auth) {
|
|
|
373
377
|
};
|
|
374
378
|
}
|
|
375
379
|
//#endregion
|
|
380
|
+
//#region src/integrations/mcp/tool-helpers.ts
|
|
381
|
+
/**
|
|
382
|
+
* Evaluate a resource's permission check in MCP context.
|
|
383
|
+
*
|
|
384
|
+
* Returns the full normalized `PermissionResult` so the caller can honor
|
|
385
|
+
* ALL side-effects (filters + scope) consistently with CRUD/action routes.
|
|
386
|
+
* Returns `null` when no permission is defined (= allow, no side effects).
|
|
387
|
+
*
|
|
388
|
+
* Promoting booleans to `PermissionResult` via the shared
|
|
389
|
+
* `normalizePermissionResult` helper keeps the contract aligned with the
|
|
390
|
+
* rest of arc — one normalization path for every call site.
|
|
391
|
+
*/
|
|
392
|
+
async function evaluatePermission(check, session, resource, action, input) {
|
|
393
|
+
if (!check) return null;
|
|
394
|
+
const user = session ? {
|
|
395
|
+
id: session.userId,
|
|
396
|
+
_id: session.userId,
|
|
397
|
+
...session
|
|
398
|
+
} : null;
|
|
399
|
+
return normalizePermissionResult(await check({
|
|
400
|
+
user,
|
|
401
|
+
request: {
|
|
402
|
+
user,
|
|
403
|
+
headers: {},
|
|
404
|
+
params: {},
|
|
405
|
+
query: {},
|
|
406
|
+
body: input
|
|
407
|
+
},
|
|
408
|
+
resource,
|
|
409
|
+
action,
|
|
410
|
+
resourceId: typeof input.id === "string" ? input.id : void 0,
|
|
411
|
+
params: {},
|
|
412
|
+
data: input
|
|
413
|
+
}));
|
|
414
|
+
}
|
|
415
|
+
/**
|
|
416
|
+
* Convert a controller response envelope into an MCP `CallToolResult`.
|
|
417
|
+
* Carries `meta` into the serialized payload so consumers see pagination
|
|
418
|
+
* totals, stripped-field arrays, etc.
|
|
419
|
+
*/
|
|
420
|
+
function toCallToolResult(result) {
|
|
421
|
+
if (!result.success) return {
|
|
422
|
+
content: [{
|
|
423
|
+
type: "text",
|
|
424
|
+
text: result.error ?? "Operation failed"
|
|
425
|
+
}],
|
|
426
|
+
isError: true
|
|
427
|
+
};
|
|
428
|
+
const output = result.meta ? {
|
|
429
|
+
data: result.data,
|
|
430
|
+
...result.meta
|
|
431
|
+
} : result.data;
|
|
432
|
+
return { content: [{
|
|
433
|
+
type: "text",
|
|
434
|
+
text: JSON.stringify(output, null, 2)
|
|
435
|
+
}] };
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Auto-create a BaseController from the resource's adapter for MCP use.
|
|
439
|
+
* Called when the resource has an adapter but no controller
|
|
440
|
+
* (e.g. `disableDefaultRoutes: true` skips controller creation in
|
|
441
|
+
* `defineResource`).
|
|
442
|
+
*/
|
|
443
|
+
function createMcpController(resource) {
|
|
444
|
+
const repository = resource.adapter?.repository;
|
|
445
|
+
if (!repository) return void 0;
|
|
446
|
+
return new BaseController(repository, {
|
|
447
|
+
resourceName: resource.name,
|
|
448
|
+
schemaOptions: resource.schemaOptions,
|
|
449
|
+
tenantField: resource.tenantField,
|
|
450
|
+
idField: resource.idField,
|
|
451
|
+
matchesFilter: resource.adapter?.matchesFilter
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
//#endregion
|
|
455
|
+
//#region src/integrations/mcp/action-tools.ts
|
|
456
|
+
/**
|
|
457
|
+
* Convert an action's `schema` field into a Zod shape for MCP input.
|
|
458
|
+
*
|
|
459
|
+
* Delegates to the shared schema IR ([../../core/schemaIR.ts]). Same
|
|
460
|
+
* normalization path AJV sees on the HTTP side via `buildActionBodySchema`,
|
|
461
|
+
* so authors get one schema declaration for both surfaces. If the author
|
|
462
|
+
* declares `additionalProperties: false`, the flag is preserved on the IR;
|
|
463
|
+
* the MCP tool handler enforces it at request time (MCP's flat-shape input
|
|
464
|
+
* format can't express strict mode natively — see [./types.ts]).
|
|
465
|
+
*/
|
|
466
|
+
function convertActionSchemaToZod(raw) {
|
|
467
|
+
return schemaIRToZodShape(normalizeSchemaIR(raw));
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Build an MCP tool handler for a declarative action.
|
|
471
|
+
*
|
|
472
|
+
* Uses the SAME `evaluatePermission()` + `buildRequestContext()` as CRUD
|
|
473
|
+
* tools — single code path for permission side effects, scope construction,
|
|
474
|
+
* and request-context assembly. This eliminates the DRY/drift risk flagged
|
|
475
|
+
* in the 2.10.8 review: REST and MCP action tools share identical
|
|
476
|
+
* context-building machinery.
|
|
477
|
+
*/
|
|
478
|
+
function createActionToolHandler(actionName, handler, permissions, resourceName, _resourcePermissions, rawSchema) {
|
|
479
|
+
const ir = rawSchema ? normalizeSchemaIR(rawSchema) : void 0;
|
|
480
|
+
const allowedKeys = (ir ? shouldRejectAdditionalProperties(ir) : false) && ir ? new Set(["id", ...Object.keys(ir.properties)]) : void 0;
|
|
481
|
+
return async (input, ctx) => {
|
|
482
|
+
const session = ctx.session;
|
|
483
|
+
if (allowedKeys) {
|
|
484
|
+
const extras = Object.keys(input).filter((k) => !allowedKeys.has(k));
|
|
485
|
+
if (extras.length > 0) return {
|
|
486
|
+
content: [{
|
|
487
|
+
type: "text",
|
|
488
|
+
text: JSON.stringify({
|
|
489
|
+
success: false,
|
|
490
|
+
error: `Unknown properties not allowed: ${extras.join(", ")}`,
|
|
491
|
+
details: {
|
|
492
|
+
action: actionName,
|
|
493
|
+
unexpected: extras
|
|
494
|
+
}
|
|
495
|
+
})
|
|
496
|
+
}],
|
|
497
|
+
isError: true
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const permResult = await evaluatePermission(permissions, session, resourceName, actionName, input);
|
|
501
|
+
if (permResult && !permResult.granted) return {
|
|
502
|
+
content: [{
|
|
503
|
+
type: "text",
|
|
504
|
+
text: JSON.stringify({
|
|
505
|
+
success: false,
|
|
506
|
+
error: permResult.reason ?? `Permission denied for action '${actionName}'`
|
|
507
|
+
})
|
|
508
|
+
}],
|
|
509
|
+
isError: true
|
|
510
|
+
};
|
|
511
|
+
const reqCtx = buildRequestContext({
|
|
512
|
+
...input,
|
|
513
|
+
action: actionName
|
|
514
|
+
}, session, "action", permResult?.filters, permResult?.scope);
|
|
515
|
+
const id = typeof input.id === "string" ? input.id : "";
|
|
516
|
+
const { id: _discardId, ...data } = input;
|
|
517
|
+
try {
|
|
518
|
+
const result = await handler(id, data, reqCtx);
|
|
519
|
+
return { content: [{
|
|
520
|
+
type: "text",
|
|
521
|
+
text: JSON.stringify({
|
|
522
|
+
success: true,
|
|
523
|
+
data: result
|
|
524
|
+
})
|
|
525
|
+
}] };
|
|
526
|
+
} catch (err) {
|
|
527
|
+
return {
|
|
528
|
+
content: [{
|
|
529
|
+
type: "text",
|
|
530
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
531
|
+
}],
|
|
532
|
+
isError: true
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/integrations/mcp/crud-tools.ts
|
|
539
|
+
const ALL_CRUD_OPS = [
|
|
540
|
+
"list",
|
|
541
|
+
"get",
|
|
542
|
+
"create",
|
|
543
|
+
"update",
|
|
544
|
+
"delete"
|
|
545
|
+
];
|
|
546
|
+
const CRUD_ANNOTATIONS = {
|
|
547
|
+
list: { readOnlyHint: true },
|
|
548
|
+
get: { readOnlyHint: true },
|
|
549
|
+
create: { destructiveHint: false },
|
|
550
|
+
update: {
|
|
551
|
+
destructiveHint: true,
|
|
552
|
+
idempotentHint: true
|
|
553
|
+
},
|
|
554
|
+
delete: {
|
|
555
|
+
destructiveHint: true,
|
|
556
|
+
idempotentHint: true
|
|
557
|
+
}
|
|
558
|
+
};
|
|
559
|
+
/**
|
|
560
|
+
* Build a handler that dispatches to the controller method for `op`,
|
|
561
|
+
* passing through arc's MCP → IRequestContext adapter. Permission check
|
|
562
|
+
* runs first and short-circuits with a structured tool error on denial.
|
|
563
|
+
*/
|
|
564
|
+
function createCrudHandler(op, controller, resourceName, permissions) {
|
|
565
|
+
const ctrl = controller;
|
|
566
|
+
return async (input, ctx) => {
|
|
567
|
+
try {
|
|
568
|
+
const method = ctrl[op];
|
|
569
|
+
if (typeof method !== "function") return {
|
|
570
|
+
content: [{
|
|
571
|
+
type: "text",
|
|
572
|
+
text: `Operation "${op}" not available on ${resourceName}`
|
|
573
|
+
}],
|
|
574
|
+
isError: true
|
|
575
|
+
};
|
|
576
|
+
const permResult = await evaluatePermission(permissions?.[op], ctx.session, resourceName, op, input);
|
|
577
|
+
if (permResult && !permResult.granted) return {
|
|
578
|
+
content: [{
|
|
579
|
+
type: "text",
|
|
580
|
+
text: `Permission denied: ${op} on ${resourceName}${permResult.reason ? ` — ${permResult.reason}` : ""}`
|
|
581
|
+
}],
|
|
582
|
+
isError: true
|
|
583
|
+
};
|
|
584
|
+
return toCallToolResult(await method(buildRequestContext(input, ctx.session, op, permResult?.filters, permResult?.scope)));
|
|
585
|
+
} catch (err) {
|
|
586
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
587
|
+
ctx.log("error", `${resourceName}.${op}: ${msg}`).catch(() => {});
|
|
588
|
+
return {
|
|
589
|
+
content: [{
|
|
590
|
+
type: "text",
|
|
591
|
+
text: `Error: ${msg}`
|
|
592
|
+
}],
|
|
593
|
+
isError: true
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Default description for a CRUD tool. Enriches list descriptions with the
|
|
600
|
+
* configured filter/sort metadata so MCP clients can see what's queryable
|
|
601
|
+
* without reading the resource source.
|
|
602
|
+
*/
|
|
603
|
+
function defaultCrudDescription(op, displayName, softDelete, queryMeta) {
|
|
604
|
+
const name = displayName.toLowerCase();
|
|
605
|
+
switch (op) {
|
|
606
|
+
case "list": {
|
|
607
|
+
const parts = [`List ${pluralize(name)} with optional filters and pagination.`];
|
|
608
|
+
if (queryMeta?.filterableFields?.length) parts.push(`Filterable fields: ${queryMeta.filterableFields.join(", ")}.`);
|
|
609
|
+
if (queryMeta?.allowedOperators?.length) parts.push(`Filter operators: ${queryMeta.allowedOperators.join(", ")} (use field[op]=value syntax).`);
|
|
610
|
+
if (queryMeta?.sortableFields?.length) parts.push(`Sortable fields: ${queryMeta.sortableFields.join(", ")}.`);
|
|
611
|
+
return parts.join(" ");
|
|
612
|
+
}
|
|
613
|
+
case "get": return `Get a single ${name} by ID`;
|
|
614
|
+
case "create": return `Create a new ${name}`;
|
|
615
|
+
case "update": return `Update an existing ${name} by ID`;
|
|
616
|
+
case "delete": return softDelete ? `Delete a ${name} by ID (soft delete — marks as deleted, not permanently removed)` : `Delete a ${name} by ID`;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
376
620
|
//#region src/integrations/mcp/jsonSchemaToZod.ts
|
|
377
621
|
/**
|
|
378
622
|
* @classytic/arc — JSON Schema → Zod shape converter
|
|
@@ -513,195 +757,61 @@ function applyDescription(zodType, prop) {
|
|
|
513
757
|
return zodType;
|
|
514
758
|
}
|
|
515
759
|
//#endregion
|
|
516
|
-
//#region src/integrations/mcp/
|
|
760
|
+
//#region src/integrations/mcp/input-schema.ts
|
|
517
761
|
/**
|
|
518
|
-
*
|
|
762
|
+
* MCP tool input schema generation.
|
|
519
763
|
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
764
|
+
* One of four internal units extracted from `resourceToTools.ts` in
|
|
765
|
+
* v2.11.0. Owns the translation from arc's fieldRules / adapter-generated
|
|
766
|
+
* body schemas into the Zod shapes MCP tools expect.
|
|
522
767
|
*
|
|
523
|
-
*
|
|
768
|
+
* Exports:
|
|
769
|
+
* - `buildInputSchema` — the switch on CRUD op that picks between the
|
|
770
|
+
* fieldRules path and the high-fidelity adapter-body path.
|
|
771
|
+
* - `getAdapterBodies` — pulls `createBody` / `updateBody` from the
|
|
772
|
+
* adapter once so callers can reuse them for both paths.
|
|
773
|
+
* - `deriveFieldRulesFromAdapter` — fallback FieldRules derivation for
|
|
774
|
+
* the list/filter path when the host didn't supply explicit rules.
|
|
524
775
|
*/
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
776
|
+
function buildInputSchema(op, fieldRules, opts) {
|
|
777
|
+
switch (op) {
|
|
778
|
+
case "list": return fieldRulesToZod(fieldRules, {
|
|
779
|
+
mode: "list",
|
|
780
|
+
...opts
|
|
781
|
+
});
|
|
782
|
+
case "get": return getIdShape();
|
|
783
|
+
case "create":
|
|
784
|
+
if (!fieldRules && opts.adapterBodies?.createBody) {
|
|
785
|
+
const shape = jsonSchemaToZodShape(opts.adapterBodies.createBody, "create");
|
|
786
|
+
if (shape) return shape;
|
|
787
|
+
}
|
|
788
|
+
return fieldRulesToZod(fieldRules, {
|
|
789
|
+
mode: "create",
|
|
790
|
+
...opts
|
|
791
|
+
});
|
|
792
|
+
case "update": {
|
|
793
|
+
const idShape = getIdShape();
|
|
794
|
+
if (!fieldRules && opts.adapterBodies?.updateBody) {
|
|
795
|
+
const shape = jsonSchemaToZodShape(opts.adapterBodies.updateBody, "update");
|
|
796
|
+
if (shape) return {
|
|
797
|
+
...idShape,
|
|
798
|
+
...shape
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
return {
|
|
802
|
+
...idShape,
|
|
803
|
+
...fieldRulesToZod(fieldRules, {
|
|
804
|
+
mode: "update",
|
|
805
|
+
...opts
|
|
806
|
+
})
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
case "delete": return getIdShape();
|
|
543
810
|
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
* MCP tools call BaseController directly — they bypass HTTP routes entirely.
|
|
549
|
-
* Therefore `disableDefaultRoutes` does NOT affect MCP tool generation;
|
|
550
|
-
* only `disabledRoutes` (the per-operation array) controls which ops are skipped.
|
|
551
|
-
*
|
|
552
|
-
* If the resource has an adapter but no controller (e.g. `disableDefaultRoutes: true`),
|
|
553
|
-
* a lightweight BaseController is auto-created from the adapter for MCP use.
|
|
554
|
-
*
|
|
555
|
-
* @param resource - Arc resource definition
|
|
556
|
-
* @param config - Optional overrides (operations, descriptions, hideFields, prefix, names)
|
|
557
|
-
*/
|
|
558
|
-
function resourceToTools(resource, config = {}) {
|
|
559
|
-
const controller = resource.controller ?? (resource.adapter ? createMcpController(resource) : void 0);
|
|
560
|
-
const explicitFieldRules = resource.schemaOptions?.fieldRules;
|
|
561
|
-
const hiddenFields = resource.schemaOptions?.hiddenFields;
|
|
562
|
-
const readonlyFields = resource.schemaOptions?.readonlyFields;
|
|
563
|
-
const adapterBodies = explicitFieldRules ? void 0 : getAdapterBodies(resource);
|
|
564
|
-
const fieldRules = explicitFieldRules ?? deriveFieldRulesFromAdapter(resource);
|
|
565
|
-
const filterableFields = resource.schemaOptions?.filterableFields ?? resource.queryParser?.allowedFilterFields;
|
|
566
|
-
const sortableFields = resource.queryParser?.allowedSortFields;
|
|
567
|
-
const allowedOperators = resource.queryParser?.allowedOperators;
|
|
568
|
-
const hasSoftDelete = resource._appliedPresets?.includes("softDelete") ?? false;
|
|
569
|
-
const tools = [];
|
|
570
|
-
const prefix = config.toolNamePrefix;
|
|
571
|
-
if (controller) {
|
|
572
|
-
let ops = ALL_CRUD_OPS.filter((op) => {
|
|
573
|
-
if (resource.disabledRoutes?.includes(op)) return false;
|
|
574
|
-
return true;
|
|
575
|
-
});
|
|
576
|
-
if (config.operations) ops = ops.filter((op) => config.operations?.includes(op));
|
|
577
|
-
for (const op of ops) {
|
|
578
|
-
const name = config.names?.[op] ?? (op === "list" ? `${prefix ? `${prefix}_` : ""}list_${pluralize(resource.name)}` : `${prefix ? `${prefix}_` : ""}${op}_${resource.name}`);
|
|
579
|
-
tools.push({
|
|
580
|
-
name,
|
|
581
|
-
description: config.descriptions?.[op] ?? defaultDescription(op, resource.displayName, hasSoftDelete, {
|
|
582
|
-
filterableFields,
|
|
583
|
-
allowedOperators,
|
|
584
|
-
sortableFields
|
|
585
|
-
}),
|
|
586
|
-
annotations: ANNOTATIONS[op],
|
|
587
|
-
inputSchema: buildInputSchema(op, fieldRules, {
|
|
588
|
-
hiddenFields,
|
|
589
|
-
readonlyFields,
|
|
590
|
-
extraHideFields: config.hideFields,
|
|
591
|
-
filterableFields,
|
|
592
|
-
allowedOperators,
|
|
593
|
-
adapterBodies
|
|
594
|
-
}),
|
|
595
|
-
handler: createHandler(op, controller, resource.name, resource.permissions)
|
|
596
|
-
});
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
for (const route of resource.routes ?? []) {
|
|
600
|
-
if (route.mcp === false) continue;
|
|
601
|
-
const mcpHandler = route.mcpHandler;
|
|
602
|
-
if (!!route.raw && !mcpHandler) continue;
|
|
603
|
-
if (!mcpHandler && ![
|
|
604
|
-
"POST",
|
|
605
|
-
"PUT",
|
|
606
|
-
"PATCH",
|
|
607
|
-
"DELETE"
|
|
608
|
-
].includes(route.method)) continue;
|
|
609
|
-
if (!mcpHandler && typeof route.handler === "string" && !controller) continue;
|
|
610
|
-
const opName = route.operation ?? slugifyRoute(route.method, route.path);
|
|
611
|
-
const hasId = route.path.includes(":id");
|
|
612
|
-
const mcpConfig = typeof route.mcp === "object" && route.mcp !== null ? route.mcp : void 0;
|
|
613
|
-
const toolDescription = mcpConfig?.description ?? route.summary ?? route.description ?? `${opName} on ${resource.displayName}`;
|
|
614
|
-
const toolAnnotations = mcpConfig?.annotations ? { ...mcpConfig.annotations } : { openWorldHint: true };
|
|
615
|
-
const inputShape = {};
|
|
616
|
-
if (hasId) inputShape.id = z.string().describe("Resource ID");
|
|
617
|
-
if (mcpHandler) tools.push({
|
|
618
|
-
name: prefix ? `${prefix}_${opName}_${resource.name}` : `${opName}_${resource.name}`,
|
|
619
|
-
description: toolDescription,
|
|
620
|
-
annotations: toolAnnotations,
|
|
621
|
-
inputSchema: inputShape,
|
|
622
|
-
handler: async (input, _ctx) => {
|
|
623
|
-
try {
|
|
624
|
-
return await mcpHandler(input);
|
|
625
|
-
} catch (err) {
|
|
626
|
-
return {
|
|
627
|
-
content: [{
|
|
628
|
-
type: "text",
|
|
629
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
630
|
-
}],
|
|
631
|
-
isError: true
|
|
632
|
-
};
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
});
|
|
636
|
-
else tools.push({
|
|
637
|
-
name: prefix ? `${prefix}_${opName}_${resource.name}` : `${opName}_${resource.name}`,
|
|
638
|
-
description: toolDescription,
|
|
639
|
-
annotations: toolAnnotations,
|
|
640
|
-
inputSchema: inputShape,
|
|
641
|
-
handler: createCustomRouteHandler(route, controller, hasId)
|
|
642
|
-
});
|
|
643
|
-
}
|
|
644
|
-
if (resource.actions) for (const [actionName, entry] of Object.entries(resource.actions)) {
|
|
645
|
-
const def = typeof entry === "function" ? { handler: entry } : entry;
|
|
646
|
-
if (typeof def !== "function" && "mcp" in def && def.mcp === false) continue;
|
|
647
|
-
const mcpCfg = typeof def !== "function" && typeof def.mcp === "object" ? def.mcp : void 0;
|
|
648
|
-
const description = mcpCfg?.description ?? (typeof def !== "function" ? def.description : void 0) ?? `${actionName} action on ${resource.displayName}`;
|
|
649
|
-
const annotations = mcpCfg?.annotations ? { ...mcpCfg.annotations } : { destructiveHint: true };
|
|
650
|
-
const inputShape = { id: z.string().describe("Resource ID") };
|
|
651
|
-
const rawSchema = typeof def !== "function" ? def.schema : void 0;
|
|
652
|
-
if (rawSchema && typeof rawSchema === "object") {
|
|
653
|
-
const converted = convertActionSchemaToZod(rawSchema);
|
|
654
|
-
for (const [key, val] of Object.entries(converted)) inputShape[key] = val;
|
|
655
|
-
}
|
|
656
|
-
const toolName = prefix ? `${prefix}_${actionName}_${resource.name}` : `${actionName}_${resource.name}`;
|
|
657
|
-
const handler = typeof entry === "function" ? entry : def.handler;
|
|
658
|
-
const actionPerms = (typeof def !== "function" ? def.permissions : void 0) ?? resource.actionPermissions;
|
|
659
|
-
tools.push({
|
|
660
|
-
name: toolName,
|
|
661
|
-
description: String(description),
|
|
662
|
-
annotations,
|
|
663
|
-
inputSchema: inputShape,
|
|
664
|
-
handler: createActionToolHandler(actionName, handler, actionPerms, resource.name, resource.permissions)
|
|
665
|
-
});
|
|
666
|
-
}
|
|
667
|
-
return tools;
|
|
668
|
-
}
|
|
669
|
-
function buildInputSchema(op, fieldRules, opts) {
|
|
670
|
-
switch (op) {
|
|
671
|
-
case "list": return fieldRulesToZod(fieldRules, {
|
|
672
|
-
mode: "list",
|
|
673
|
-
...opts
|
|
674
|
-
});
|
|
675
|
-
case "get": return { id: z.string().describe("Resource ID") };
|
|
676
|
-
case "create":
|
|
677
|
-
if (!fieldRules && opts.adapterBodies?.createBody) {
|
|
678
|
-
const shape = jsonSchemaToZodShape(opts.adapterBodies.createBody, "create");
|
|
679
|
-
if (shape) return shape;
|
|
680
|
-
}
|
|
681
|
-
return fieldRulesToZod(fieldRules, {
|
|
682
|
-
mode: "create",
|
|
683
|
-
...opts
|
|
684
|
-
});
|
|
685
|
-
case "update": {
|
|
686
|
-
const idShape = { id: z.string().describe("Resource ID") };
|
|
687
|
-
if (!fieldRules && opts.adapterBodies?.updateBody) {
|
|
688
|
-
const shape = jsonSchemaToZodShape(opts.adapterBodies.updateBody, "update");
|
|
689
|
-
if (shape) return {
|
|
690
|
-
...idShape,
|
|
691
|
-
...shape
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
return {
|
|
695
|
-
...idShape,
|
|
696
|
-
...fieldRulesToZod(fieldRules, {
|
|
697
|
-
mode: "update",
|
|
698
|
-
...opts
|
|
699
|
-
})
|
|
700
|
-
};
|
|
701
|
-
}
|
|
702
|
-
case "delete": return { id: z.string().describe("Resource ID") };
|
|
703
|
-
}
|
|
704
|
-
}
|
|
811
|
+
}
|
|
812
|
+
function getIdShape() {
|
|
813
|
+
return { id: z.string().describe("Resource ID") };
|
|
814
|
+
}
|
|
705
815
|
/**
|
|
706
816
|
* Pull the adapter's `createBody` / `updateBody` schemas, if any.
|
|
707
817
|
* Returns `undefined` when the adapter doesn't generate schemas or throws.
|
|
@@ -724,115 +834,6 @@ function getAdapterBodies(resource) {
|
|
|
724
834
|
return;
|
|
725
835
|
}
|
|
726
836
|
}
|
|
727
|
-
function createHandler(op, controller, resourceName, permissions) {
|
|
728
|
-
const ctrl = controller;
|
|
729
|
-
return async (input, ctx) => {
|
|
730
|
-
try {
|
|
731
|
-
const method = ctrl[op];
|
|
732
|
-
if (typeof method !== "function") return {
|
|
733
|
-
content: [{
|
|
734
|
-
type: "text",
|
|
735
|
-
text: `Operation "${op}" not available on ${resourceName}`
|
|
736
|
-
}],
|
|
737
|
-
isError: true
|
|
738
|
-
};
|
|
739
|
-
const permResult = await evaluatePermission(permissions?.[op], ctx.session, resourceName, op, input);
|
|
740
|
-
if (permResult && !permResult.granted) return {
|
|
741
|
-
content: [{
|
|
742
|
-
type: "text",
|
|
743
|
-
text: `Permission denied: ${op} on ${resourceName}${permResult.reason ? ` — ${permResult.reason}` : ""}`
|
|
744
|
-
}],
|
|
745
|
-
isError: true
|
|
746
|
-
};
|
|
747
|
-
return toCallToolResult(await method(buildRequestContext(input, ctx.session, op, permResult?.filters, permResult?.scope)));
|
|
748
|
-
} catch (err) {
|
|
749
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
750
|
-
ctx.log("error", `${resourceName}.${op}: ${msg}`).catch(() => {});
|
|
751
|
-
return {
|
|
752
|
-
content: [{
|
|
753
|
-
type: "text",
|
|
754
|
-
text: `Error: ${msg}`
|
|
755
|
-
}],
|
|
756
|
-
isError: true
|
|
757
|
-
};
|
|
758
|
-
}
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
function createCustomRouteHandler(route, controller, hasId) {
|
|
762
|
-
const ctrl = controller;
|
|
763
|
-
const handlerName = typeof route.handler === "string" ? route.handler : route.operation ?? slugifyRoute(route.method, route.path);
|
|
764
|
-
return async (input, ctx) => {
|
|
765
|
-
try {
|
|
766
|
-
if (typeof route.handler === "function") {
|
|
767
|
-
const reqCtx = buildRequestContext(input, ctx.session, hasId ? "update" : "create");
|
|
768
|
-
const fn = route.handler;
|
|
769
|
-
const out = await fn(reqCtx);
|
|
770
|
-
return toCallToolResult(out !== null && typeof out === "object" && "success" in out ? out : {
|
|
771
|
-
success: true,
|
|
772
|
-
data: out
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
if (!ctrl) return {
|
|
776
|
-
content: [{
|
|
777
|
-
type: "text",
|
|
778
|
-
text: `Handler "${handlerName}" has no controller available`
|
|
779
|
-
}],
|
|
780
|
-
isError: true
|
|
781
|
-
};
|
|
782
|
-
const method = ctrl[handlerName];
|
|
783
|
-
if (typeof method !== "function") return {
|
|
784
|
-
content: [{
|
|
785
|
-
type: "text",
|
|
786
|
-
text: `Handler "${handlerName}" not found on controller`
|
|
787
|
-
}],
|
|
788
|
-
isError: true
|
|
789
|
-
};
|
|
790
|
-
return toCallToolResult(await method(buildRequestContext(input, ctx.session, hasId ? "update" : "create")));
|
|
791
|
-
} catch (err) {
|
|
792
|
-
return {
|
|
793
|
-
content: [{
|
|
794
|
-
type: "text",
|
|
795
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
796
|
-
}],
|
|
797
|
-
isError: true
|
|
798
|
-
};
|
|
799
|
-
}
|
|
800
|
-
};
|
|
801
|
-
}
|
|
802
|
-
/**
|
|
803
|
-
* Evaluate a resource's permission check in MCP context.
|
|
804
|
-
*
|
|
805
|
-
* Returns the full normalized `PermissionResult` so the caller can honor
|
|
806
|
-
* ALL side-effects (filters + scope) consistently with CRUD/action routes.
|
|
807
|
-
* Returns `null` when no permission is defined (= allow, no side effects).
|
|
808
|
-
*
|
|
809
|
-
* Promoting booleans to `PermissionResult` via the shared `normalizePermissionResult`
|
|
810
|
-
* helper keeps the contract aligned with the rest of Arc — there is a single
|
|
811
|
-
* normalization path for every call site.
|
|
812
|
-
*/
|
|
813
|
-
async function evaluatePermission(check, session, resource, action, input) {
|
|
814
|
-
if (!check) return null;
|
|
815
|
-
const user = session ? {
|
|
816
|
-
id: session.userId,
|
|
817
|
-
_id: session.userId,
|
|
818
|
-
...session
|
|
819
|
-
} : null;
|
|
820
|
-
return normalizePermissionResult(await check({
|
|
821
|
-
user,
|
|
822
|
-
request: {
|
|
823
|
-
user,
|
|
824
|
-
headers: {},
|
|
825
|
-
params: {},
|
|
826
|
-
query: {},
|
|
827
|
-
body: input
|
|
828
|
-
},
|
|
829
|
-
resource,
|
|
830
|
-
action,
|
|
831
|
-
resourceId: typeof input.id === "string" ? input.id : void 0,
|
|
832
|
-
params: {},
|
|
833
|
-
data: input
|
|
834
|
-
}));
|
|
835
|
-
}
|
|
836
837
|
/**
|
|
837
838
|
* Derive a fieldRules-shaped object from the adapter's auto-generated body
|
|
838
839
|
* schemas. Used as a fallback when the resource doesn't supply explicit
|
|
@@ -890,141 +891,90 @@ function mapJsonSchemaTypeToArcType(jsonType) {
|
|
|
890
891
|
default: return "string";
|
|
891
892
|
}
|
|
892
893
|
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
content: [{
|
|
896
|
-
type: "text",
|
|
897
|
-
text: result.error ?? "Operation failed"
|
|
898
|
-
}],
|
|
899
|
-
isError: true
|
|
900
|
-
};
|
|
901
|
-
const output = result.meta ? {
|
|
902
|
-
data: result.data,
|
|
903
|
-
...result.meta
|
|
904
|
-
} : result.data;
|
|
905
|
-
return { content: [{
|
|
906
|
-
type: "text",
|
|
907
|
-
text: JSON.stringify(output, null, 2)
|
|
908
|
-
}] };
|
|
909
|
-
}
|
|
910
|
-
function defaultDescription(op, displayName, softDelete, queryMeta) {
|
|
911
|
-
const name = displayName.toLowerCase();
|
|
912
|
-
switch (op) {
|
|
913
|
-
case "list": {
|
|
914
|
-
const parts = [`List ${pluralize(name)} with optional filters and pagination.`];
|
|
915
|
-
if (queryMeta?.filterableFields?.length) parts.push(`Filterable fields: ${queryMeta.filterableFields.join(", ")}.`);
|
|
916
|
-
if (queryMeta?.allowedOperators?.length) parts.push(`Filter operators: ${queryMeta.allowedOperators.join(", ")} (use field[op]=value syntax).`);
|
|
917
|
-
if (queryMeta?.sortableFields?.length) parts.push(`Sortable fields: ${queryMeta.sortableFields.join(", ")}.`);
|
|
918
|
-
return parts.join(" ");
|
|
919
|
-
}
|
|
920
|
-
case "get": return `Get a single ${name} by ID`;
|
|
921
|
-
case "create": return `Create a new ${name}`;
|
|
922
|
-
case "update": return `Update an existing ${name} by ID`;
|
|
923
|
-
case "delete": return softDelete ? `Delete a ${name} by ID (soft delete — marks as deleted, not permanently removed)` : `Delete a ${name} by ID`;
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
function slugifyRoute(method, path) {
|
|
927
|
-
const clean = path.replace(/:[^/]+/g, "").replace(/^\/+|\/+$/g, "").replace(/\//g, "_");
|
|
928
|
-
return clean ? `${method.toLowerCase()}_${clean}` : method.toLowerCase();
|
|
929
|
-
}
|
|
930
|
-
/**
|
|
931
|
-
* Auto-create a BaseController from the resource's adapter for MCP use.
|
|
932
|
-
* Called when the resource has an adapter but no controller
|
|
933
|
-
* (e.g. `disableDefaultRoutes: true` skips controller creation in defineResource).
|
|
934
|
-
*/
|
|
935
|
-
function createMcpController(resource) {
|
|
936
|
-
const repository = resource.adapter?.repository;
|
|
937
|
-
if (!repository) return void 0;
|
|
938
|
-
return new BaseController(repository, {
|
|
939
|
-
resourceName: resource.name,
|
|
940
|
-
schemaOptions: resource.schemaOptions,
|
|
941
|
-
tenantField: resource.tenantField,
|
|
942
|
-
idField: resource.idField,
|
|
943
|
-
matchesFilter: resource.adapter?.matchesFilter
|
|
944
|
-
});
|
|
945
|
-
}
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/integrations/mcp/route-tools.ts
|
|
946
896
|
/**
|
|
947
|
-
*
|
|
948
|
-
*
|
|
949
|
-
* `
|
|
897
|
+
* Custom-route → MCP tool generation.
|
|
898
|
+
*
|
|
899
|
+
* Converts arc's `routes[]` entries (declared via `defineResource({
|
|
900
|
+
* routes: [...] })`) into MCP tools. Three handler shapes are supported:
|
|
901
|
+
*
|
|
902
|
+
* 1. `mcpHandler` (full bypass) — caller-supplied function owns the whole
|
|
903
|
+
* tool result; pipeline is not invoked.
|
|
904
|
+
* 2. Function handler with `raw: false/undefined` — arc's pipeline wrapper
|
|
905
|
+
* runs normally, and the envelope is serialized into the tool result.
|
|
906
|
+
* 3. String handler — looks up a method on the controller by name.
|
|
950
907
|
*/
|
|
951
|
-
function convertActionSchemaToZod(raw) {
|
|
952
|
-
if ("_zod" in raw && typeof raw.shape === "object") return { ...raw.shape };
|
|
953
|
-
if ((raw.type === "object" || "properties" in raw) && typeof raw.properties === "object" && raw.properties !== null) {
|
|
954
|
-
const props = raw.properties;
|
|
955
|
-
return jsonSchemaPropsToZod(props, new Set(Array.isArray(raw.required) ? raw.required : []));
|
|
956
|
-
}
|
|
957
|
-
const result = {};
|
|
958
|
-
for (const [fieldName, fieldSchema] of Object.entries(raw)) {
|
|
959
|
-
if (fieldName === "type" || fieldName === "properties" || fieldName === "required") continue;
|
|
960
|
-
if (!fieldSchema || typeof fieldSchema !== "object") continue;
|
|
961
|
-
const fs = fieldSchema;
|
|
962
|
-
const desc = typeof fs.description === "string" ? fs.description : `${fieldName} field`;
|
|
963
|
-
const isOptional = fs.required === false;
|
|
964
|
-
const base = jsonSchemaTypeToZod(fs);
|
|
965
|
-
result[fieldName] = isOptional ? base.optional().describe(desc) : base.describe(desc);
|
|
966
|
-
}
|
|
967
|
-
return result;
|
|
968
|
-
}
|
|
969
|
-
function jsonSchemaPropsToZod(props, requiredSet) {
|
|
970
|
-
const result = {};
|
|
971
|
-
for (const [name, schema] of Object.entries(props)) {
|
|
972
|
-
const desc = typeof schema.description === "string" ? schema.description : name;
|
|
973
|
-
const base = jsonSchemaTypeToZod(schema);
|
|
974
|
-
result[name] = requiredSet.has(name) ? base.describe(desc) : base.optional().describe(desc);
|
|
975
|
-
}
|
|
976
|
-
return result;
|
|
977
|
-
}
|
|
978
|
-
function jsonSchemaTypeToZod(schema) {
|
|
979
|
-
const type = typeof schema.type === "string" ? schema.type : "string";
|
|
980
|
-
if (Array.isArray(schema.enum) && schema.enum.length > 0) return z.enum(schema.enum);
|
|
981
|
-
switch (type) {
|
|
982
|
-
case "number":
|
|
983
|
-
case "integer": return z.number();
|
|
984
|
-
case "boolean": return z.boolean();
|
|
985
|
-
case "array": return z.array(z.unknown());
|
|
986
|
-
case "object": return z.record(z.string(), z.unknown());
|
|
987
|
-
default: return z.string();
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
908
|
/**
|
|
991
|
-
*
|
|
909
|
+
* Build an MCP tool handler for a custom route.
|
|
910
|
+
*
|
|
911
|
+
* Enforces the same contract as the REST route:
|
|
912
|
+
* 1. **Permission evaluation** via the shared `evaluatePermission` — the
|
|
913
|
+
* exact path CRUD and action MCP tools use. Filters and scope from a
|
|
914
|
+
* `PermissionResult` thread through `buildRequestContext`.
|
|
915
|
+
* 2. **Pipeline integration** — function handlers run inside
|
|
916
|
+
* `executePipeline` with the same steps the HTTP path resolves.
|
|
917
|
+
* 3. **Controller dispatch** for string handlers.
|
|
992
918
|
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
* DRY/drift risk flagged in the review: REST and MCP action tools now
|
|
997
|
-
* share identical context-building machinery.
|
|
919
|
+
* `hasId` signals whether the route path uses `:id`, which determines
|
|
920
|
+
* whether we treat the call as an update-shaped or create-shaped request
|
|
921
|
+
* when hydrating the request context.
|
|
998
922
|
*/
|
|
999
|
-
function
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
923
|
+
function createCustomRouteHandler(route, controller, hasId, options) {
|
|
924
|
+
const ctrl = controller;
|
|
925
|
+
const handlerName = typeof route.handler === "string" ? route.handler : route.operation ?? slugifyRoute(route.method, route.path);
|
|
926
|
+
const { resourceName, operationName, permissions, pipeline } = options;
|
|
927
|
+
const pipelineSteps = resolvePipelineSteps(pipeline, operationName);
|
|
928
|
+
return async (input, _ctx) => {
|
|
929
|
+
const session = _ctx.session;
|
|
930
|
+
const permResult = await evaluatePermission(permissions, session, resourceName, operationName, input);
|
|
1003
931
|
if (permResult && !permResult.granted) return {
|
|
1004
932
|
content: [{
|
|
1005
933
|
type: "text",
|
|
1006
934
|
text: JSON.stringify({
|
|
1007
935
|
success: false,
|
|
1008
|
-
error: permResult.reason ?? `Permission denied for
|
|
936
|
+
error: permResult.reason ?? (session ? `Permission denied for '${operationName}'` : "Authentication required")
|
|
1009
937
|
})
|
|
1010
938
|
}],
|
|
1011
939
|
isError: true
|
|
1012
940
|
};
|
|
1013
|
-
const reqCtx = buildRequestContext({
|
|
1014
|
-
...input,
|
|
1015
|
-
action: actionName
|
|
1016
|
-
}, session, "action", permResult?.filters, permResult?.scope);
|
|
1017
|
-
const id = typeof input.id === "string" ? input.id : "";
|
|
1018
|
-
const { id: _discardId, ...data } = input;
|
|
1019
941
|
try {
|
|
1020
|
-
const
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
942
|
+
const reqCtx = buildRequestContext(input, session, hasId ? "update" : "create", permResult?.filters, permResult?.scope);
|
|
943
|
+
if (typeof route.handler === "function") {
|
|
944
|
+
const fn = route.handler;
|
|
945
|
+
if (pipelineSteps.length > 0) return toCallToolResult(await executePipeline(pipelineSteps, {
|
|
946
|
+
...reqCtx,
|
|
947
|
+
resource: resourceName,
|
|
948
|
+
operation: operationName
|
|
949
|
+
}, async (ctx) => {
|
|
950
|
+
const raw = await fn(ctx);
|
|
951
|
+
return raw !== null && typeof raw === "object" && "success" in raw ? raw : {
|
|
952
|
+
success: true,
|
|
953
|
+
data: raw
|
|
954
|
+
};
|
|
955
|
+
}, operationName));
|
|
956
|
+
const out = await fn(reqCtx);
|
|
957
|
+
return toCallToolResult(out !== null && typeof out === "object" && "success" in out ? out : {
|
|
1024
958
|
success: true,
|
|
1025
|
-
data:
|
|
1026
|
-
})
|
|
1027
|
-
}
|
|
959
|
+
data: out
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
if (!ctrl) return {
|
|
963
|
+
content: [{
|
|
964
|
+
type: "text",
|
|
965
|
+
text: `Handler "${handlerName}" has no controller available`
|
|
966
|
+
}],
|
|
967
|
+
isError: true
|
|
968
|
+
};
|
|
969
|
+
const method = ctrl[handlerName];
|
|
970
|
+
if (typeof method !== "function") return {
|
|
971
|
+
content: [{
|
|
972
|
+
type: "text",
|
|
973
|
+
text: `Handler "${handlerName}" not found on controller`
|
|
974
|
+
}],
|
|
975
|
+
isError: true
|
|
976
|
+
};
|
|
977
|
+
return toCallToolResult(await method(reqCtx));
|
|
1028
978
|
} catch (err) {
|
|
1029
979
|
return {
|
|
1030
980
|
content: [{
|
|
@@ -1036,5 +986,174 @@ function createActionToolHandler(actionName, handler, permissions, resourceName,
|
|
|
1036
986
|
}
|
|
1037
987
|
};
|
|
1038
988
|
}
|
|
989
|
+
/**
|
|
990
|
+
* Build an MCP tool handler around a caller-supplied `mcpHandler` — no
|
|
991
|
+
* pipeline, no envelope translation, the function owns the whole
|
|
992
|
+
* `CallToolResult`. Only surfaces errors as tool-error results.
|
|
993
|
+
*/
|
|
994
|
+
function createMcpHandlerPassthrough(mcpHandler) {
|
|
995
|
+
return async (input) => {
|
|
996
|
+
try {
|
|
997
|
+
return await mcpHandler(input);
|
|
998
|
+
} catch (err) {
|
|
999
|
+
return {
|
|
1000
|
+
content: [{
|
|
1001
|
+
type: "text",
|
|
1002
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
1003
|
+
}],
|
|
1004
|
+
isError: true
|
|
1005
|
+
};
|
|
1006
|
+
}
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Slugify `{method, path}` into a readable tool-operation name when the
|
|
1011
|
+
* route definition doesn't supply an explicit `operation`.
|
|
1012
|
+
*/
|
|
1013
|
+
function slugifyRoute(method, path) {
|
|
1014
|
+
const clean = path.replace(/:[^/]+/g, "").replace(/^\/+|\/+$/g, "").replace(/\//g, "_");
|
|
1015
|
+
return clean ? `${method.toLowerCase()}_${clean}` : method.toLowerCase();
|
|
1016
|
+
}
|
|
1017
|
+
//#endregion
|
|
1018
|
+
//#region src/integrations/mcp/resourceToTools.ts
|
|
1019
|
+
/**
|
|
1020
|
+
* @classytic/arc — Resource → MCP Tools orchestrator.
|
|
1021
|
+
*
|
|
1022
|
+
* Top-level entry point for generating `ToolDefinition[]` from a
|
|
1023
|
+
* `ResourceDefinition`. Delegates the heavy lifting to four focused
|
|
1024
|
+
* internal units (v2.11.0 split):
|
|
1025
|
+
*
|
|
1026
|
+
* - [input-schema.ts](./input-schema.ts) — CRUD input-shape generation
|
|
1027
|
+
* - [crud-tools.ts](./crud-tools.ts) — CRUD handler + annotations + descriptions
|
|
1028
|
+
* - [route-tools.ts](./route-tools.ts) — custom-route → tool translation
|
|
1029
|
+
* - [action-tools.ts](./action-tools.ts) — declarative-action → tool translation
|
|
1030
|
+
*
|
|
1031
|
+
* This file's job is purely orchestration: pick the controller, gather
|
|
1032
|
+
* field rules once, and loop over CRUD / routes / actions delegating
|
|
1033
|
+
* each tool's construction to the matching unit.
|
|
1034
|
+
*
|
|
1035
|
+
* All tool handlers call BaseController methods — same pipeline as REST.
|
|
1036
|
+
*/
|
|
1037
|
+
/**
|
|
1038
|
+
* Convert a ResourceDefinition into MCP ToolDefinitions.
|
|
1039
|
+
*
|
|
1040
|
+
* MCP tools call BaseController directly — they bypass HTTP routes entirely.
|
|
1041
|
+
* Therefore `disableDefaultRoutes` does NOT affect MCP tool generation;
|
|
1042
|
+
* only `disabledRoutes` (the per-operation array) controls which ops are skipped.
|
|
1043
|
+
*
|
|
1044
|
+
* If the resource has an adapter but no controller (e.g. `disableDefaultRoutes: true`),
|
|
1045
|
+
* a lightweight BaseController is auto-created from the adapter for MCP use.
|
|
1046
|
+
*
|
|
1047
|
+
* @param resource - Arc resource definition
|
|
1048
|
+
* @param config - Optional overrides (operations, descriptions, hideFields, prefix, names)
|
|
1049
|
+
*/
|
|
1050
|
+
function resourceToTools(resource, config = {}) {
|
|
1051
|
+
const controller = resource.controller ?? (resource.adapter ? createMcpController(resource) : void 0);
|
|
1052
|
+
const explicitFieldRules = resource.schemaOptions?.fieldRules;
|
|
1053
|
+
const hiddenFields = resource.schemaOptions?.hiddenFields;
|
|
1054
|
+
const readonlyFields = resource.schemaOptions?.readonlyFields;
|
|
1055
|
+
const adapterBodies = explicitFieldRules ? void 0 : getAdapterBodies(resource);
|
|
1056
|
+
const fieldRules = explicitFieldRules ?? deriveFieldRulesFromAdapter(resource);
|
|
1057
|
+
const filterableFields = resource.schemaOptions?.filterableFields ?? resource.queryParser?.allowedFilterFields;
|
|
1058
|
+
const sortableFields = resource.queryParser?.allowedSortFields;
|
|
1059
|
+
const allowedOperators = resource.queryParser?.allowedOperators;
|
|
1060
|
+
const hasSoftDelete = resource._appliedPresets?.includes("softDelete") ?? false;
|
|
1061
|
+
const tools = [];
|
|
1062
|
+
const prefix = config.toolNamePrefix;
|
|
1063
|
+
if (controller) {
|
|
1064
|
+
let ops = ALL_CRUD_OPS.filter((op) => !resource.disabledRoutes?.includes(op));
|
|
1065
|
+
if (config.operations) ops = ops.filter((op) => config.operations?.includes(op));
|
|
1066
|
+
for (const op of ops) {
|
|
1067
|
+
const name = config.names?.[op] ?? (op === "list" ? `${prefix ? `${prefix}_` : ""}list_${pluralize(resource.name)}` : `${prefix ? `${prefix}_` : ""}${op}_${resource.name}`);
|
|
1068
|
+
tools.push({
|
|
1069
|
+
name,
|
|
1070
|
+
description: config.descriptions?.[op] ?? defaultCrudDescription(op, resource.displayName, hasSoftDelete, {
|
|
1071
|
+
filterableFields,
|
|
1072
|
+
allowedOperators,
|
|
1073
|
+
sortableFields
|
|
1074
|
+
}),
|
|
1075
|
+
annotations: CRUD_ANNOTATIONS[op],
|
|
1076
|
+
inputSchema: buildInputSchema(op, fieldRules, {
|
|
1077
|
+
hiddenFields,
|
|
1078
|
+
readonlyFields,
|
|
1079
|
+
extraHideFields: config.hideFields,
|
|
1080
|
+
filterableFields,
|
|
1081
|
+
allowedOperators,
|
|
1082
|
+
adapterBodies
|
|
1083
|
+
}),
|
|
1084
|
+
handler: createCrudHandler(op, controller, resource.name, resource.permissions)
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
for (const route of resource.routes ?? []) {
|
|
1089
|
+
if (route.mcp === false) continue;
|
|
1090
|
+
const mcpHandler = route.mcpHandler;
|
|
1091
|
+
if (!!route.raw && !mcpHandler) continue;
|
|
1092
|
+
if (!mcpHandler && ![
|
|
1093
|
+
"POST",
|
|
1094
|
+
"PUT",
|
|
1095
|
+
"PATCH",
|
|
1096
|
+
"DELETE"
|
|
1097
|
+
].includes(route.method)) continue;
|
|
1098
|
+
if (!mcpHandler && typeof route.handler === "string" && !controller) continue;
|
|
1099
|
+
const opName = route.operation ?? slugifyRoute(route.method, route.path);
|
|
1100
|
+
const hasId = route.path.includes(":id");
|
|
1101
|
+
const mcpConfig = typeof route.mcp === "object" && route.mcp !== null ? route.mcp : void 0;
|
|
1102
|
+
const toolDescription = mcpConfig?.description ?? route.summary ?? route.description ?? `${opName} on ${resource.displayName}`;
|
|
1103
|
+
const toolAnnotations = mcpConfig?.annotations ? { ...mcpConfig.annotations } : { openWorldHint: true };
|
|
1104
|
+
const inputShape = {};
|
|
1105
|
+
if (hasId) inputShape.id = z.string().describe("Resource ID");
|
|
1106
|
+
const routeSchema = route.schema;
|
|
1107
|
+
if (routeSchema?.body) {
|
|
1108
|
+
const ir = normalizeSchemaIR(routeSchema.body);
|
|
1109
|
+
for (const [key, val] of Object.entries(schemaIRToZodShape(ir))) inputShape[key] = val;
|
|
1110
|
+
}
|
|
1111
|
+
if (routeSchema?.querystring) {
|
|
1112
|
+
const ir = normalizeSchemaIR(routeSchema.querystring);
|
|
1113
|
+
for (const [key, val] of Object.entries(schemaIRToZodShape(ir))) if (!(key in inputShape)) inputShape[key] = val;
|
|
1114
|
+
}
|
|
1115
|
+
const toolName = prefix ? `${prefix}_${opName}_${resource.name}` : `${opName}_${resource.name}`;
|
|
1116
|
+
tools.push({
|
|
1117
|
+
name: toolName,
|
|
1118
|
+
description: toolDescription,
|
|
1119
|
+
annotations: toolAnnotations,
|
|
1120
|
+
inputSchema: inputShape,
|
|
1121
|
+
handler: mcpHandler ? createMcpHandlerPassthrough(mcpHandler) : createCustomRouteHandler(route, controller, hasId, {
|
|
1122
|
+
resourceName: resource.name,
|
|
1123
|
+
operationName: opName,
|
|
1124
|
+
permissions: route.permissions,
|
|
1125
|
+
pipeline: resource.pipe
|
|
1126
|
+
})
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
if (resource.actions) for (const [actionName, entry] of Object.entries(resource.actions)) {
|
|
1130
|
+
const def = typeof entry === "function" ? { handler: entry } : entry;
|
|
1131
|
+
if (typeof def !== "function" && "mcp" in def && def.mcp === false) continue;
|
|
1132
|
+
const mcpCfg = typeof def !== "function" && typeof def.mcp === "object" ? def.mcp : void 0;
|
|
1133
|
+
const description = mcpCfg?.description ?? (typeof def !== "function" ? def.description : void 0) ?? `${actionName} action on ${resource.displayName}`;
|
|
1134
|
+
const annotations = mcpCfg?.annotations ? { ...mcpCfg.annotations } : { destructiveHint: true };
|
|
1135
|
+
const inputShape = { id: z.string().describe("Resource ID") };
|
|
1136
|
+
const rawSchema = typeof def !== "function" ? def.schema : void 0;
|
|
1137
|
+
if (rawSchema && typeof rawSchema === "object") {
|
|
1138
|
+
const converted = convertActionSchemaToZod(rawSchema);
|
|
1139
|
+
for (const [key, val] of Object.entries(converted)) inputShape[key] = val;
|
|
1140
|
+
}
|
|
1141
|
+
const toolName = prefix ? `${prefix}_${actionName}_${resource.name}` : `${actionName}_${resource.name}`;
|
|
1142
|
+
const handler = typeof entry === "function" ? entry : def.handler;
|
|
1143
|
+
const actionPerms = resolveActionPermission({
|
|
1144
|
+
action: entry,
|
|
1145
|
+
resourcePermissions: resource.permissions,
|
|
1146
|
+
resourceActionPermissions: resource.actionPermissions
|
|
1147
|
+
});
|
|
1148
|
+
tools.push({
|
|
1149
|
+
name: toolName,
|
|
1150
|
+
description: String(description),
|
|
1151
|
+
annotations,
|
|
1152
|
+
inputSchema: inputShape,
|
|
1153
|
+
handler: createActionToolHandler(actionName, handler, actionPerms, resource.name, resource.permissions, rawSchema)
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
return tools;
|
|
1157
|
+
}
|
|
1039
1158
|
//#endregion
|
|
1040
1159
|
export { fieldRulesToZod as n, createMcpServer as r, resourceToTools as t };
|