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