@classytic/arc 2.10.8 → 2.11.1
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/{BaseController-DVNKvoX4.mjs → BaseController-JNV08qOT.mjs} +480 -442
- package/dist/{queryCachePlugin-Dumka73q.d.mts → QueryCache-DOBNHBE0.d.mts} +2 -32
- 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 +1 -1
- package/dist/auth/index.d.mts +1 -1
- package/dist/auth/index.mjs +5 -5
- package/dist/{betterAuthOpenApi--rdY15Ld.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 +46 -33
- package/dist/cli/commands/introspect.mjs +1 -1
- package/dist/context/index.mjs +1 -1
- 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-BwnEAO2h.mjs → createApp-P1d6rjPy.mjs} +75 -27
- package/dist/docs/index.d.mts +1 -1
- package/dist/docs/index.mjs +2 -2
- package/dist/{elevation-Dci0AYLT.mjs → elevation-DOFoxoDs.mjs} +1 -1
- package/dist/{errorHandler-CSxe7KIM.mjs → errorHandler-BQm8ZxTK.mjs} +1 -1
- package/dist/{eventPlugin-ByU4Cv0e.mjs → eventPlugin--5HIkdPU.mjs} +1 -1
- package/dist/events/index.d.mts +3 -3
- package/dist/events/index.mjs +2 -2
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/factory/index.d.mts +2 -2
- package/dist/factory/index.mjs +2 -2
- 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 +1 -1
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/{index-C_Noptz-.d.mts → index-BYCqHCVu.d.mts} +2 -2
- package/dist/{index-BGbpGVyM.d.mts → index-C_bgx9o4.d.mts} +712 -500
- package/dist/{index-BziRPS4H.d.mts → index-CvM1e09j.d.mts} +29 -10
- package/dist/{index-EqQN6p0W.d.mts → index-pUczGjO0.d.mts} +11 -8
- package/dist/index-smCAoA5W.d.mts +1179 -0
- package/dist/index.d.mts +6 -38
- package/dist/index.mjs +9 -9
- package/dist/integrations/event-gateway.d.mts +1 -1
- 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-nWQGUTu1.mjs → keys-CARyUjiR.mjs} +2 -0
- package/dist/{loadResources-Bksk8ydA.mjs → loadResources-CPpkyKfM.mjs} +32 -8
- package/dist/middleware/index.d.mts +1 -1
- package/dist/middleware/index.mjs +1 -1
- package/dist/{openapi-DpNpqBmo.mjs → openapi-C0L9ar7m.mjs} +4 -4
- package/dist/org/index.d.mts +1 -1
- package/dist/permissions/index.d.mts +1 -1
- package/dist/permissions/index.mjs +2 -4
- package/dist/{permissions-wkqRwicB.mjs → permissions-B4vU9L0Q.mjs} +221 -3
- package/dist/{pipe-CGJxqDGx.mjs → pipe-DVoIheVC.mjs} +1 -1
- package/dist/pipeline/index.d.mts +1 -1
- package/dist/pipeline/index.mjs +1 -1
- package/dist/plugins/index.d.mts +4 -4
- 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 +1 -1
- package/dist/presets/filesUpload.mjs +3 -3
- package/dist/presets/index.d.mts +1 -1
- package/dist/presets/index.mjs +1 -1
- package/dist/presets/multiTenant.d.mts +1 -1
- package/dist/presets/multiTenant.mjs +6 -0
- package/dist/presets/search.d.mts +1 -1
- package/dist/presets/search.mjs +1 -1
- package/dist/{presets-CrwOvuXI.mjs → presets-k604Lj99.mjs} +1 -1
- package/dist/queryCachePlugin-BUXBSm4F.d.mts +34 -0
- package/dist/{queryCachePlugin-ChLNZvFT.mjs → queryCachePlugin-Bq6bO6vc.mjs} +3 -3
- package/dist/{redis-MXLp1oOf.d.mts → redis-Cm1gnRDf.d.mts} +1 -1
- package/dist/registry/index.d.mts +1 -1
- package/dist/registry/index.mjs +2 -2
- package/dist/{resourceToTools-BhF3JV5p.mjs → resourceToTools--okX6QBr.mjs} +534 -420
- package/dist/routerShared-DeESFp4a.mjs +515 -0
- package/dist/schemaIR-BlG9bY7v.mjs +137 -0
- package/dist/scope/index.mjs +2 -2
- package/dist/testing/index.d.mts +367 -711
- package/dist/testing/index.mjs +637 -1434
- package/dist/{tracing-xqXzWeaf.d.mts → tracing-DokiEsuz.d.mts} +9 -4
- package/dist/types/index.d.mts +3 -3
- package/dist/types/index.mjs +1 -3
- package/dist/{types-CVdgPXBW.d.mts → types-BdA4uMBV.d.mts} +191 -28
- package/dist/{types-CVKBssX5.d.mts → types-Bh_gEJBi.d.mts} +1 -1
- package/dist/utils/index.d.mts +2 -968
- package/dist/utils/index.mjs +5 -6
- package/dist/utils-D3Yxnrwr.mjs +1639 -0
- package/dist/websocket-CyJ1VIFI.d.mts +186 -0
- package/package.json +7 -5
- 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-3MWJosCH.mjs +0 -1459
- package/dist/createActionRouter-C8UUB3Px.mjs +0 -249
- package/dist/errors-BI8kEKsO.d.mts +0 -140
- package/dist/fields-CTMWOUDt.mjs +0 -126
- package/dist/queryParser-NR__Qiju.mjs +0 -419
- package/dist/types-CDnTEpga.mjs +0 -27
- package/dist/utils-LMwVidKy.mjs +0 -947
- /package/dist/{HookSystem-BjFu7zf1.mjs → HookSystem-CGsMd6oK.mjs} +0 -0
- /package/dist/{ResourceRegistry-CcN2LVrc.mjs → ResourceRegistry-DkAeAuTX.mjs} +0 -0
- /package/dist/{actionPermissions-TUVR3uiZ.mjs → actionPermissions-C8YYU92K.mjs} +0 -0
- /package/dist/{caching-3h93rkJM.mjs → caching-CheW3m-S.mjs} +0 -0
- /package/dist/{errorHandler-2ii4RIYr.d.mts → errorHandler-Co3lnVmJ.d.mts} +0 -0
- /package/dist/{errors-BqdUDja_.mjs → errors-D5c-5BJL.mjs} +0 -0
- /package/dist/{eventPlugin-D1ThQ1Pp.d.mts → eventPlugin-CUNjYYRY.d.mts} +0 -0
- /package/dist/{interface-B-pe8fhj.d.mts → interface-CkkWm5uR.d.mts} +0 -0
- /package/dist/{interface-yhyb_pLY.d.mts → interface-Da0r7Lna.d.mts} +0 -0
- /package/dist/{memory-DqI-449b.mjs → memory-DikHSvWa.mjs} +0 -0
- /package/dist/{metrics-TuOmguhi.mjs → metrics-Csh4nsvv.mjs} +0 -0
- /package/dist/{multipartBody-CUQGVlM_.mjs → multipartBody-CvTR1Un6.mjs} +0 -0
- /package/dist/{pluralize-CWP6MB39.mjs → pluralize-BneOJkpi.mjs} +0 -0
- /package/dist/{redis-stream-bkO88VHx.d.mts → redis-stream-CM8TXTix.d.mts} +0 -0
- /package/dist/{registry-B0Wl7uVV.mjs → registry-D63ee7fl.mjs} +0 -0
- /package/dist/{replyHelpers-BLojtuvR.mjs → replyHelpers-ByllIXXV.mjs} +0 -0
- /package/dist/{requestContext-C38GskNt.mjs → requestContext-CfRkaxwf.mjs} +0 -0
- /package/dist/{schemaConverter-BxFDdtXu.mjs → schemaConverter-B0oKLuqI.mjs} +0 -0
- /package/dist/{sse-D8UeDwis.mjs → sse-V7aXc3bW.mjs} +0 -0
- /package/dist/{store-helpers-DYYUQbQN.mjs → store-helpers-BhrzxvyQ.mjs} +0 -0
- /package/dist/{typeGuards-Cj5Rgvlg.mjs → typeGuards-CcFZXgU7.mjs} +0 -0
- /package/dist/{types-D57iXYb8.mjs → types-DV9WDfeg.mjs} +0 -0
- /package/dist/{versioning-B6mimogM.mjs → versioning-CGPjkqAg.mjs} +0 -0
- /package/dist/{versioning-CeUXHfjw.d.mts → versioning-M9lNLhO8.d.mts} +0 -0
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { t as BaseController } from "./BaseController-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import { t as
|
|
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";
|
|
5
8
|
import { z } from "zod";
|
|
6
9
|
//#region src/integrations/mcp/createMcpServer.ts
|
|
7
10
|
/**
|
|
@@ -374,6 +377,246 @@ function buildScope(auth) {
|
|
|
374
377
|
};
|
|
375
378
|
}
|
|
376
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
|
|
377
620
|
//#region src/integrations/mcp/jsonSchemaToZod.ts
|
|
378
621
|
/**
|
|
379
622
|
* @classytic/arc — JSON Schema → Zod shape converter
|
|
@@ -514,199 +757,61 @@ function applyDescription(zodType, prop) {
|
|
|
514
757
|
return zodType;
|
|
515
758
|
}
|
|
516
759
|
//#endregion
|
|
517
|
-
//#region src/integrations/mcp/
|
|
760
|
+
//#region src/integrations/mcp/input-schema.ts
|
|
518
761
|
/**
|
|
519
|
-
*
|
|
762
|
+
* MCP tool input schema generation.
|
|
520
763
|
*
|
|
521
|
-
*
|
|
522
|
-
*
|
|
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.
|
|
523
767
|
*
|
|
524
|
-
*
|
|
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.
|
|
525
775
|
*/
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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();
|
|
544
810
|
}
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
* MCP tools call BaseController directly — they bypass HTTP routes entirely.
|
|
550
|
-
* Therefore `disableDefaultRoutes` does NOT affect MCP tool generation;
|
|
551
|
-
* only `disabledRoutes` (the per-operation array) controls which ops are skipped.
|
|
552
|
-
*
|
|
553
|
-
* If the resource has an adapter but no controller (e.g. `disableDefaultRoutes: true`),
|
|
554
|
-
* a lightweight BaseController is auto-created from the adapter for MCP use.
|
|
555
|
-
*
|
|
556
|
-
* @param resource - Arc resource definition
|
|
557
|
-
* @param config - Optional overrides (operations, descriptions, hideFields, prefix, names)
|
|
558
|
-
*/
|
|
559
|
-
function resourceToTools(resource, config = {}) {
|
|
560
|
-
const controller = resource.controller ?? (resource.adapter ? createMcpController(resource) : void 0);
|
|
561
|
-
const explicitFieldRules = resource.schemaOptions?.fieldRules;
|
|
562
|
-
const hiddenFields = resource.schemaOptions?.hiddenFields;
|
|
563
|
-
const readonlyFields = resource.schemaOptions?.readonlyFields;
|
|
564
|
-
const adapterBodies = explicitFieldRules ? void 0 : getAdapterBodies(resource);
|
|
565
|
-
const fieldRules = explicitFieldRules ?? deriveFieldRulesFromAdapter(resource);
|
|
566
|
-
const filterableFields = resource.schemaOptions?.filterableFields ?? resource.queryParser?.allowedFilterFields;
|
|
567
|
-
const sortableFields = resource.queryParser?.allowedSortFields;
|
|
568
|
-
const allowedOperators = resource.queryParser?.allowedOperators;
|
|
569
|
-
const hasSoftDelete = resource._appliedPresets?.includes("softDelete") ?? false;
|
|
570
|
-
const tools = [];
|
|
571
|
-
const prefix = config.toolNamePrefix;
|
|
572
|
-
if (controller) {
|
|
573
|
-
let ops = ALL_CRUD_OPS.filter((op) => {
|
|
574
|
-
if (resource.disabledRoutes?.includes(op)) return false;
|
|
575
|
-
return true;
|
|
576
|
-
});
|
|
577
|
-
if (config.operations) ops = ops.filter((op) => config.operations?.includes(op));
|
|
578
|
-
for (const op of ops) {
|
|
579
|
-
const name = config.names?.[op] ?? (op === "list" ? `${prefix ? `${prefix}_` : ""}list_${pluralize(resource.name)}` : `${prefix ? `${prefix}_` : ""}${op}_${resource.name}`);
|
|
580
|
-
tools.push({
|
|
581
|
-
name,
|
|
582
|
-
description: config.descriptions?.[op] ?? defaultDescription(op, resource.displayName, hasSoftDelete, {
|
|
583
|
-
filterableFields,
|
|
584
|
-
allowedOperators,
|
|
585
|
-
sortableFields
|
|
586
|
-
}),
|
|
587
|
-
annotations: ANNOTATIONS[op],
|
|
588
|
-
inputSchema: buildInputSchema(op, fieldRules, {
|
|
589
|
-
hiddenFields,
|
|
590
|
-
readonlyFields,
|
|
591
|
-
extraHideFields: config.hideFields,
|
|
592
|
-
filterableFields,
|
|
593
|
-
allowedOperators,
|
|
594
|
-
adapterBodies
|
|
595
|
-
}),
|
|
596
|
-
handler: createHandler(op, controller, resource.name, resource.permissions)
|
|
597
|
-
});
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
for (const route of resource.routes ?? []) {
|
|
601
|
-
if (route.mcp === false) continue;
|
|
602
|
-
const mcpHandler = route.mcpHandler;
|
|
603
|
-
if (!!route.raw && !mcpHandler) continue;
|
|
604
|
-
if (!mcpHandler && ![
|
|
605
|
-
"POST",
|
|
606
|
-
"PUT",
|
|
607
|
-
"PATCH",
|
|
608
|
-
"DELETE"
|
|
609
|
-
].includes(route.method)) continue;
|
|
610
|
-
if (!mcpHandler && typeof route.handler === "string" && !controller) continue;
|
|
611
|
-
const opName = route.operation ?? slugifyRoute(route.method, route.path);
|
|
612
|
-
const hasId = route.path.includes(":id");
|
|
613
|
-
const mcpConfig = typeof route.mcp === "object" && route.mcp !== null ? route.mcp : void 0;
|
|
614
|
-
const toolDescription = mcpConfig?.description ?? route.summary ?? route.description ?? `${opName} on ${resource.displayName}`;
|
|
615
|
-
const toolAnnotations = mcpConfig?.annotations ? { ...mcpConfig.annotations } : { openWorldHint: true };
|
|
616
|
-
const inputShape = {};
|
|
617
|
-
if (hasId) inputShape.id = z.string().describe("Resource ID");
|
|
618
|
-
if (mcpHandler) tools.push({
|
|
619
|
-
name: prefix ? `${prefix}_${opName}_${resource.name}` : `${opName}_${resource.name}`,
|
|
620
|
-
description: toolDescription,
|
|
621
|
-
annotations: toolAnnotations,
|
|
622
|
-
inputSchema: inputShape,
|
|
623
|
-
handler: async (input, _ctx) => {
|
|
624
|
-
try {
|
|
625
|
-
return await mcpHandler(input);
|
|
626
|
-
} catch (err) {
|
|
627
|
-
return {
|
|
628
|
-
content: [{
|
|
629
|
-
type: "text",
|
|
630
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
631
|
-
}],
|
|
632
|
-
isError: true
|
|
633
|
-
};
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
});
|
|
637
|
-
else tools.push({
|
|
638
|
-
name: prefix ? `${prefix}_${opName}_${resource.name}` : `${opName}_${resource.name}`,
|
|
639
|
-
description: toolDescription,
|
|
640
|
-
annotations: toolAnnotations,
|
|
641
|
-
inputSchema: inputShape,
|
|
642
|
-
handler: createCustomRouteHandler(route, controller, hasId)
|
|
643
|
-
});
|
|
644
|
-
}
|
|
645
|
-
if (resource.actions) for (const [actionName, entry] of Object.entries(resource.actions)) {
|
|
646
|
-
const def = typeof entry === "function" ? { handler: entry } : entry;
|
|
647
|
-
if (typeof def !== "function" && "mcp" in def && def.mcp === false) continue;
|
|
648
|
-
const mcpCfg = typeof def !== "function" && typeof def.mcp === "object" ? def.mcp : void 0;
|
|
649
|
-
const description = mcpCfg?.description ?? (typeof def !== "function" ? def.description : void 0) ?? `${actionName} action on ${resource.displayName}`;
|
|
650
|
-
const annotations = mcpCfg?.annotations ? { ...mcpCfg.annotations } : { destructiveHint: true };
|
|
651
|
-
const inputShape = { id: z.string().describe("Resource ID") };
|
|
652
|
-
const rawSchema = typeof def !== "function" ? def.schema : void 0;
|
|
653
|
-
if (rawSchema && typeof rawSchema === "object") {
|
|
654
|
-
const converted = convertActionSchemaToZod(rawSchema);
|
|
655
|
-
for (const [key, val] of Object.entries(converted)) inputShape[key] = val;
|
|
656
|
-
}
|
|
657
|
-
const toolName = prefix ? `${prefix}_${actionName}_${resource.name}` : `${actionName}_${resource.name}`;
|
|
658
|
-
const handler = typeof entry === "function" ? entry : def.handler;
|
|
659
|
-
const actionPerms = resolveActionPermission({
|
|
660
|
-
action: entry,
|
|
661
|
-
resourcePermissions: resource.permissions,
|
|
662
|
-
resourceActionPermissions: resource.actionPermissions
|
|
663
|
-
});
|
|
664
|
-
tools.push({
|
|
665
|
-
name: toolName,
|
|
666
|
-
description: String(description),
|
|
667
|
-
annotations,
|
|
668
|
-
inputSchema: inputShape,
|
|
669
|
-
handler: createActionToolHandler(actionName, handler, actionPerms, resource.name, resource.permissions)
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
return tools;
|
|
673
|
-
}
|
|
674
|
-
function buildInputSchema(op, fieldRules, opts) {
|
|
675
|
-
switch (op) {
|
|
676
|
-
case "list": return fieldRulesToZod(fieldRules, {
|
|
677
|
-
mode: "list",
|
|
678
|
-
...opts
|
|
679
|
-
});
|
|
680
|
-
case "get": return { id: z.string().describe("Resource ID") };
|
|
681
|
-
case "create":
|
|
682
|
-
if (!fieldRules && opts.adapterBodies?.createBody) {
|
|
683
|
-
const shape = jsonSchemaToZodShape(opts.adapterBodies.createBody, "create");
|
|
684
|
-
if (shape) return shape;
|
|
685
|
-
}
|
|
686
|
-
return fieldRulesToZod(fieldRules, {
|
|
687
|
-
mode: "create",
|
|
688
|
-
...opts
|
|
689
|
-
});
|
|
690
|
-
case "update": {
|
|
691
|
-
const idShape = { id: z.string().describe("Resource ID") };
|
|
692
|
-
if (!fieldRules && opts.adapterBodies?.updateBody) {
|
|
693
|
-
const shape = jsonSchemaToZodShape(opts.adapterBodies.updateBody, "update");
|
|
694
|
-
if (shape) return {
|
|
695
|
-
...idShape,
|
|
696
|
-
...shape
|
|
697
|
-
};
|
|
698
|
-
}
|
|
699
|
-
return {
|
|
700
|
-
...idShape,
|
|
701
|
-
...fieldRulesToZod(fieldRules, {
|
|
702
|
-
mode: "update",
|
|
703
|
-
...opts
|
|
704
|
-
})
|
|
705
|
-
};
|
|
706
|
-
}
|
|
707
|
-
case "delete": return { id: z.string().describe("Resource ID") };
|
|
708
|
-
}
|
|
709
|
-
}
|
|
811
|
+
}
|
|
812
|
+
function getIdShape() {
|
|
813
|
+
return { id: z.string().describe("Resource ID") };
|
|
814
|
+
}
|
|
710
815
|
/**
|
|
711
816
|
* Pull the adapter's `createBody` / `updateBody` schemas, if any.
|
|
712
817
|
* Returns `undefined` when the adapter doesn't generate schemas or throws.
|
|
@@ -729,115 +834,6 @@ function getAdapterBodies(resource) {
|
|
|
729
834
|
return;
|
|
730
835
|
}
|
|
731
836
|
}
|
|
732
|
-
function createHandler(op, controller, resourceName, permissions) {
|
|
733
|
-
const ctrl = controller;
|
|
734
|
-
return async (input, ctx) => {
|
|
735
|
-
try {
|
|
736
|
-
const method = ctrl[op];
|
|
737
|
-
if (typeof method !== "function") return {
|
|
738
|
-
content: [{
|
|
739
|
-
type: "text",
|
|
740
|
-
text: `Operation "${op}" not available on ${resourceName}`
|
|
741
|
-
}],
|
|
742
|
-
isError: true
|
|
743
|
-
};
|
|
744
|
-
const permResult = await evaluatePermission(permissions?.[op], ctx.session, resourceName, op, input);
|
|
745
|
-
if (permResult && !permResult.granted) return {
|
|
746
|
-
content: [{
|
|
747
|
-
type: "text",
|
|
748
|
-
text: `Permission denied: ${op} on ${resourceName}${permResult.reason ? ` — ${permResult.reason}` : ""}`
|
|
749
|
-
}],
|
|
750
|
-
isError: true
|
|
751
|
-
};
|
|
752
|
-
return toCallToolResult(await method(buildRequestContext(input, ctx.session, op, permResult?.filters, permResult?.scope)));
|
|
753
|
-
} catch (err) {
|
|
754
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
755
|
-
ctx.log("error", `${resourceName}.${op}: ${msg}`).catch(() => {});
|
|
756
|
-
return {
|
|
757
|
-
content: [{
|
|
758
|
-
type: "text",
|
|
759
|
-
text: `Error: ${msg}`
|
|
760
|
-
}],
|
|
761
|
-
isError: true
|
|
762
|
-
};
|
|
763
|
-
}
|
|
764
|
-
};
|
|
765
|
-
}
|
|
766
|
-
function createCustomRouteHandler(route, controller, hasId) {
|
|
767
|
-
const ctrl = controller;
|
|
768
|
-
const handlerName = typeof route.handler === "string" ? route.handler : route.operation ?? slugifyRoute(route.method, route.path);
|
|
769
|
-
return async (input, ctx) => {
|
|
770
|
-
try {
|
|
771
|
-
if (typeof route.handler === "function") {
|
|
772
|
-
const reqCtx = buildRequestContext(input, ctx.session, hasId ? "update" : "create");
|
|
773
|
-
const fn = route.handler;
|
|
774
|
-
const out = await fn(reqCtx);
|
|
775
|
-
return toCallToolResult(out !== null && typeof out === "object" && "success" in out ? out : {
|
|
776
|
-
success: true,
|
|
777
|
-
data: out
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
if (!ctrl) return {
|
|
781
|
-
content: [{
|
|
782
|
-
type: "text",
|
|
783
|
-
text: `Handler "${handlerName}" has no controller available`
|
|
784
|
-
}],
|
|
785
|
-
isError: true
|
|
786
|
-
};
|
|
787
|
-
const method = ctrl[handlerName];
|
|
788
|
-
if (typeof method !== "function") return {
|
|
789
|
-
content: [{
|
|
790
|
-
type: "text",
|
|
791
|
-
text: `Handler "${handlerName}" not found on controller`
|
|
792
|
-
}],
|
|
793
|
-
isError: true
|
|
794
|
-
};
|
|
795
|
-
return toCallToolResult(await method(buildRequestContext(input, ctx.session, hasId ? "update" : "create")));
|
|
796
|
-
} catch (err) {
|
|
797
|
-
return {
|
|
798
|
-
content: [{
|
|
799
|
-
type: "text",
|
|
800
|
-
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
801
|
-
}],
|
|
802
|
-
isError: true
|
|
803
|
-
};
|
|
804
|
-
}
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
/**
|
|
808
|
-
* Evaluate a resource's permission check in MCP context.
|
|
809
|
-
*
|
|
810
|
-
* Returns the full normalized `PermissionResult` so the caller can honor
|
|
811
|
-
* ALL side-effects (filters + scope) consistently with CRUD/action routes.
|
|
812
|
-
* Returns `null` when no permission is defined (= allow, no side effects).
|
|
813
|
-
*
|
|
814
|
-
* Promoting booleans to `PermissionResult` via the shared `normalizePermissionResult`
|
|
815
|
-
* helper keeps the contract aligned with the rest of Arc — there is a single
|
|
816
|
-
* normalization path for every call site.
|
|
817
|
-
*/
|
|
818
|
-
async function evaluatePermission(check, session, resource, action, input) {
|
|
819
|
-
if (!check) return null;
|
|
820
|
-
const user = session ? {
|
|
821
|
-
id: session.userId,
|
|
822
|
-
_id: session.userId,
|
|
823
|
-
...session
|
|
824
|
-
} : null;
|
|
825
|
-
return normalizePermissionResult(await check({
|
|
826
|
-
user,
|
|
827
|
-
request: {
|
|
828
|
-
user,
|
|
829
|
-
headers: {},
|
|
830
|
-
params: {},
|
|
831
|
-
query: {},
|
|
832
|
-
body: input
|
|
833
|
-
},
|
|
834
|
-
resource,
|
|
835
|
-
action,
|
|
836
|
-
resourceId: typeof input.id === "string" ? input.id : void 0,
|
|
837
|
-
params: {},
|
|
838
|
-
data: input
|
|
839
|
-
}));
|
|
840
|
-
}
|
|
841
837
|
/**
|
|
842
838
|
* Derive a fieldRules-shaped object from the adapter's auto-generated body
|
|
843
839
|
* schemas. Used as a fallback when the resource doesn't supply explicit
|
|
@@ -895,141 +891,110 @@ function mapJsonSchemaTypeToArcType(jsonType) {
|
|
|
895
891
|
default: return "string";
|
|
896
892
|
}
|
|
897
893
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
content: [{
|
|
901
|
-
type: "text",
|
|
902
|
-
text: result.error ?? "Operation failed"
|
|
903
|
-
}],
|
|
904
|
-
isError: true
|
|
905
|
-
};
|
|
906
|
-
const output = result.meta ? {
|
|
907
|
-
data: result.data,
|
|
908
|
-
...result.meta
|
|
909
|
-
} : result.data;
|
|
910
|
-
return { content: [{
|
|
911
|
-
type: "text",
|
|
912
|
-
text: JSON.stringify(output, null, 2)
|
|
913
|
-
}] };
|
|
914
|
-
}
|
|
915
|
-
function defaultDescription(op, displayName, softDelete, queryMeta) {
|
|
916
|
-
const name = displayName.toLowerCase();
|
|
917
|
-
switch (op) {
|
|
918
|
-
case "list": {
|
|
919
|
-
const parts = [`List ${pluralize(name)} with optional filters and pagination.`];
|
|
920
|
-
if (queryMeta?.filterableFields?.length) parts.push(`Filterable fields: ${queryMeta.filterableFields.join(", ")}.`);
|
|
921
|
-
if (queryMeta?.allowedOperators?.length) parts.push(`Filter operators: ${queryMeta.allowedOperators.join(", ")} (use field[op]=value syntax).`);
|
|
922
|
-
if (queryMeta?.sortableFields?.length) parts.push(`Sortable fields: ${queryMeta.sortableFields.join(", ")}.`);
|
|
923
|
-
return parts.join(" ");
|
|
924
|
-
}
|
|
925
|
-
case "get": return `Get a single ${name} by ID`;
|
|
926
|
-
case "create": return `Create a new ${name}`;
|
|
927
|
-
case "update": return `Update an existing ${name} by ID`;
|
|
928
|
-
case "delete": return softDelete ? `Delete a ${name} by ID (soft delete — marks as deleted, not permanently removed)` : `Delete a ${name} by ID`;
|
|
929
|
-
}
|
|
930
|
-
}
|
|
931
|
-
function slugifyRoute(method, path) {
|
|
932
|
-
const clean = path.replace(/:[^/]+/g, "").replace(/^\/+|\/+$/g, "").replace(/\//g, "_");
|
|
933
|
-
return clean ? `${method.toLowerCase()}_${clean}` : method.toLowerCase();
|
|
934
|
-
}
|
|
935
|
-
/**
|
|
936
|
-
* Auto-create a BaseController from the resource's adapter for MCP use.
|
|
937
|
-
* Called when the resource has an adapter but no controller
|
|
938
|
-
* (e.g. `disableDefaultRoutes: true` skips controller creation in defineResource).
|
|
939
|
-
*/
|
|
940
|
-
function createMcpController(resource) {
|
|
941
|
-
const repository = resource.adapter?.repository;
|
|
942
|
-
if (!repository) return void 0;
|
|
943
|
-
return new BaseController(repository, {
|
|
944
|
-
resourceName: resource.name,
|
|
945
|
-
schemaOptions: resource.schemaOptions,
|
|
946
|
-
tenantField: resource.tenantField,
|
|
947
|
-
idField: resource.idField,
|
|
948
|
-
matchesFilter: resource.adapter?.matchesFilter
|
|
949
|
-
});
|
|
950
|
-
}
|
|
894
|
+
//#endregion
|
|
895
|
+
//#region src/integrations/mcp/route-tools.ts
|
|
951
896
|
/**
|
|
952
|
-
*
|
|
953
|
-
*
|
|
954
|
-
* `
|
|
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.
|
|
955
907
|
*/
|
|
956
|
-
function convertActionSchemaToZod(raw) {
|
|
957
|
-
if ("_zod" in raw && typeof raw.shape === "object") return { ...raw.shape };
|
|
958
|
-
if ((raw.type === "object" || "properties" in raw) && typeof raw.properties === "object" && raw.properties !== null) {
|
|
959
|
-
const props = raw.properties;
|
|
960
|
-
return jsonSchemaPropsToZod(props, new Set(Array.isArray(raw.required) ? raw.required : []));
|
|
961
|
-
}
|
|
962
|
-
const result = {};
|
|
963
|
-
for (const [fieldName, fieldSchema] of Object.entries(raw)) {
|
|
964
|
-
if (fieldName === "type" || fieldName === "properties" || fieldName === "required") continue;
|
|
965
|
-
if (!fieldSchema || typeof fieldSchema !== "object") continue;
|
|
966
|
-
const fs = fieldSchema;
|
|
967
|
-
const desc = typeof fs.description === "string" ? fs.description : `${fieldName} field`;
|
|
968
|
-
const isOptional = fs.required === false;
|
|
969
|
-
const base = jsonSchemaTypeToZod(fs);
|
|
970
|
-
result[fieldName] = isOptional ? base.optional().describe(desc) : base.describe(desc);
|
|
971
|
-
}
|
|
972
|
-
return result;
|
|
973
|
-
}
|
|
974
|
-
function jsonSchemaPropsToZod(props, requiredSet) {
|
|
975
|
-
const result = {};
|
|
976
|
-
for (const [name, schema] of Object.entries(props)) {
|
|
977
|
-
const desc = typeof schema.description === "string" ? schema.description : name;
|
|
978
|
-
const base = jsonSchemaTypeToZod(schema);
|
|
979
|
-
result[name] = requiredSet.has(name) ? base.describe(desc) : base.optional().describe(desc);
|
|
980
|
-
}
|
|
981
|
-
return result;
|
|
982
|
-
}
|
|
983
|
-
function jsonSchemaTypeToZod(schema) {
|
|
984
|
-
const type = typeof schema.type === "string" ? schema.type : "string";
|
|
985
|
-
if (Array.isArray(schema.enum) && schema.enum.length > 0) return z.enum(schema.enum);
|
|
986
|
-
switch (type) {
|
|
987
|
-
case "number":
|
|
988
|
-
case "integer": return z.number();
|
|
989
|
-
case "boolean": return z.boolean();
|
|
990
|
-
case "array": return z.array(z.unknown());
|
|
991
|
-
case "object": return z.record(z.string(), z.unknown());
|
|
992
|
-
default: return z.string();
|
|
993
|
-
}
|
|
994
|
-
}
|
|
995
908
|
/**
|
|
996
|
-
*
|
|
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.
|
|
997
918
|
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
* DRY/drift risk flagged in the review: REST and MCP action tools now
|
|
1002
|
-
* 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.
|
|
1003
922
|
*/
|
|
1004
|
-
function
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
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);
|
|
1008
931
|
if (permResult && !permResult.granted) return {
|
|
1009
932
|
content: [{
|
|
1010
933
|
type: "text",
|
|
1011
934
|
text: JSON.stringify({
|
|
1012
935
|
success: false,
|
|
1013
|
-
error: permResult.reason ?? `Permission denied for
|
|
936
|
+
error: permResult.reason ?? (session ? `Permission denied for '${operationName}'` : "Authentication required")
|
|
1014
937
|
})
|
|
1015
938
|
}],
|
|
1016
939
|
isError: true
|
|
1017
940
|
};
|
|
1018
|
-
const reqCtx = buildRequestContext({
|
|
1019
|
-
...input,
|
|
1020
|
-
action: actionName
|
|
1021
|
-
}, session, "action", permResult?.filters, permResult?.scope);
|
|
1022
|
-
const id = typeof input.id === "string" ? input.id : "";
|
|
1023
|
-
const { id: _discardId, ...data } = input;
|
|
1024
941
|
try {
|
|
1025
|
-
const
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
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 : {
|
|
1029
958
|
success: true,
|
|
1030
|
-
data:
|
|
1031
|
-
})
|
|
1032
|
-
}
|
|
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));
|
|
978
|
+
} catch (err) {
|
|
979
|
+
return {
|
|
980
|
+
content: [{
|
|
981
|
+
type: "text",
|
|
982
|
+
text: `Error: ${err instanceof Error ? err.message : String(err)}`
|
|
983
|
+
}],
|
|
984
|
+
isError: true
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
};
|
|
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);
|
|
1033
998
|
} catch (err) {
|
|
1034
999
|
return {
|
|
1035
1000
|
content: [{
|
|
@@ -1041,5 +1006,154 @@ function createActionToolHandler(actionName, handler, permissions, resourceName,
|
|
|
1041
1006
|
}
|
|
1042
1007
|
};
|
|
1043
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
|
+
}
|
|
1044
1158
|
//#endregion
|
|
1045
1159
|
export { fieldRulesToZod as n, createMcpServer as r, resourceToTools as t };
|