@classytic/arc 2.2.5 → 2.4.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/README.md +187 -18
- package/bin/arc.js +11 -3
- package/dist/BaseController-CkM5dUh_.mjs +1031 -0
- package/dist/{EventTransport-BkUDYZEb.d.mts → EventTransport-wc5hSLik.d.mts} +1 -1
- package/dist/{HookSystem-BsGV-j2l.mjs → HookSystem-COkyWztM.mjs} +2 -3
- package/dist/{ResourceRegistry-7Ic20ZMw.mjs → ResourceRegistry-DeCIFlix.mjs} +8 -5
- package/dist/adapters/index.d.mts +3 -5
- package/dist/adapters/index.mjs +2 -3
- package/dist/{prisma-DJbMt3yf.mjs → adapters-DTC4Ug66.mjs} +45 -12
- package/dist/audit/index.d.mts +4 -7
- package/dist/audit/index.mjs +2 -29
- package/dist/audit/mongodb.d.mts +1 -4
- package/dist/audit/mongodb.mjs +2 -3
- package/dist/auth/index.d.mts +7 -9
- package/dist/auth/index.mjs +65 -63
- package/dist/auth/redis-session.d.mts +1 -1
- package/dist/auth/redis-session.mjs +1 -2
- package/dist/{betterAuthOpenApi-DjWDddNc.mjs → betterAuthOpenApi-lz0IRbXJ.mjs} +4 -6
- package/dist/cache/index.d.mts +23 -23
- package/dist/cache/index.mjs +4 -6
- package/dist/{caching-GSDJcA6-.mjs → caching-BSXB-Xr7.mjs} +2 -24
- package/dist/chunk-BpYLSNr0.mjs +14 -0
- package/dist/circuitBreaker-BOBOpN2w.mjs +284 -0
- package/dist/circuitBreaker-JP2GdJ4b.d.mts +206 -0
- package/dist/cli/commands/describe.mjs +24 -7
- package/dist/cli/commands/docs.mjs +6 -7
- package/dist/cli/commands/doctor.d.mts +10 -0
- package/dist/cli/commands/doctor.mjs +156 -0
- package/dist/cli/commands/generate.mjs +66 -17
- package/dist/cli/commands/init.mjs +315 -45
- package/dist/cli/commands/introspect.mjs +2 -4
- package/dist/cli/index.d.mts +1 -10
- package/dist/cli/index.mjs +4 -153
- package/dist/{constants-DdXFXQtN.mjs → constants-Cxde4rpC.mjs} +1 -2
- package/dist/core/index.d.mts +3 -5
- package/dist/core/index.mjs +5 -4
- package/dist/core-C1XCMtqM.mjs +185 -0
- package/dist/{createApp-BKHSl2nT.mjs → createApp-ByWNRsZj.mjs} +65 -36
- package/dist/{defineResource-DO9ONe_D.mjs → defineResource-D9aY5Cy6.mjs} +154 -1165
- package/dist/discovery/index.mjs +37 -5
- package/dist/docs/index.d.mts +6 -9
- package/dist/docs/index.mjs +3 -21
- package/dist/dynamic/index.d.mts +93 -0
- package/dist/dynamic/index.mjs +122 -0
- package/dist/{elevation-DSTbVvYj.mjs → elevation-BEdACOLB.mjs} +5 -36
- package/dist/{elevation-DGo5shaX.d.mts → elevation-Ca_yveIO.d.mts} +41 -7
- package/dist/{errorHandler-C3GY3_ow.mjs → errorHandler--zp54tGc.mjs} +3 -5
- package/dist/errorHandler-Do4vVQ1f.d.mts +139 -0
- package/dist/{errors-DBANPbGr.mjs → errors-rxhfP7Hf.mjs} +1 -2
- package/dist/{eventPlugin-BEOvaDqo.mjs → eventPlugin-Ba00swHF.mjs} +25 -27
- package/dist/{eventPlugin-H6wDDjGO.d.mts → eventPlugin-iGrSEmwJ.d.mts} +105 -5
- package/dist/events/index.d.mts +72 -7
- package/dist/events/index.mjs +216 -4
- package/dist/events/transports/redis-stream-entry.d.mts +1 -1
- package/dist/events/transports/redis-stream-entry.mjs +19 -7
- package/dist/events/transports/redis.d.mts +1 -1
- package/dist/events/transports/redis.mjs +3 -4
- package/dist/factory/index.d.mts +23 -9
- package/dist/factory/index.mjs +48 -3
- package/dist/{fields-Bi_AVKSo.d.mts → fields-DFwdaWCq.d.mts} +1 -1
- package/dist/{fields-CTd_CrKr.mjs → fields-ipsbIRPK.mjs} +1 -2
- package/dist/hooks/index.d.mts +1 -3
- package/dist/hooks/index.mjs +2 -3
- package/dist/idempotency/index.d.mts +5 -5
- package/dist/idempotency/index.mjs +3 -7
- package/dist/idempotency/mongodb.d.mts +1 -1
- package/dist/idempotency/mongodb.mjs +4 -5
- package/dist/idempotency/redis.d.mts +1 -1
- package/dist/idempotency/redis.mjs +2 -5
- package/dist/{fastifyAdapter-CyAA2zlB.d.mts → index-BL8CaQih.d.mts} +56 -57
- package/dist/index-Diqcm14c.d.mts +369 -0
- package/dist/{prisma-xjhMEq_S.d.mts → index-yhxyjqNb.d.mts} +4 -5
- package/dist/index.d.mts +100 -105
- package/dist/index.mjs +85 -58
- package/dist/integrations/event-gateway.d.mts +1 -1
- package/dist/integrations/event-gateway.mjs +8 -4
- package/dist/integrations/index.d.mts +4 -2
- package/dist/integrations/index.mjs +1 -1
- package/dist/integrations/jobs.d.mts +2 -2
- package/dist/integrations/jobs.mjs +63 -14
- package/dist/integrations/mcp/index.d.mts +219 -0
- package/dist/integrations/mcp/index.mjs +572 -0
- package/dist/integrations/mcp/testing.d.mts +53 -0
- package/dist/integrations/mcp/testing.mjs +104 -0
- package/dist/integrations/streamline.mjs +39 -19
- package/dist/integrations/webhooks.d.mts +56 -0
- package/dist/integrations/webhooks.mjs +139 -0
- package/dist/integrations/websocket-redis.d.mts +46 -0
- package/dist/integrations/websocket-redis.mjs +50 -0
- package/dist/integrations/websocket.d.mts +68 -2
- package/dist/integrations/websocket.mjs +96 -13
- package/dist/{interface-CSNjltAc.d.mts → interface-B4awm1RJ.d.mts} +2 -2
- package/dist/interface-DGmPxakH.d.mts +2213 -0
- package/dist/{keys-DhqDRxv3.mjs → keys-qcD-TVJl.mjs} +3 -4
- package/dist/{logger-ByrvQWZO.mjs → logger-Dz3j1ItV.mjs} +2 -4
- package/dist/{memory-B2v7KrCB.mjs → memory-Cb_7iy9e.mjs} +2 -4
- package/dist/metrics-Csh4nsvv.mjs +224 -0
- package/dist/migrations/index.mjs +3 -7
- package/dist/{mongodb-DNKEExbf.mjs → mongodb-BuQ7fNTg.mjs} +1 -4
- package/dist/{mongodb-ClykrfGo.d.mts → mongodb-CUpYfxfD.d.mts} +2 -3
- package/dist/{mongodb-Dg8O_gvd.d.mts → mongodb-bga9AbkD.d.mts} +2 -2
- package/dist/{openapi-9nB_kiuR.mjs → openapi-CBmZ6EQN.mjs} +4 -21
- package/dist/org/index.d.mts +12 -14
- package/dist/org/index.mjs +92 -119
- package/dist/org/types.d.mts +2 -2
- package/dist/org/types.mjs +1 -1
- package/dist/permissions/index.d.mts +4 -278
- package/dist/permissions/index.mjs +4 -579
- package/dist/permissions-CA5zg0yK.mjs +751 -0
- package/dist/plugins/index.d.mts +104 -107
- package/dist/plugins/index.mjs +203 -313
- package/dist/plugins/response-cache.mjs +4 -69
- package/dist/plugins/tracing-entry.d.mts +1 -1
- package/dist/plugins/tracing-entry.mjs +24 -11
- package/dist/{pluralize-CM-jZg7p.mjs → pluralize-CcT6qF0a.mjs} +12 -13
- package/dist/policies/index.d.mts +2 -2
- package/dist/policies/index.mjs +80 -83
- package/dist/presets/index.d.mts +26 -19
- package/dist/presets/index.mjs +2 -142
- package/dist/presets/multiTenant.d.mts +1 -4
- package/dist/presets/multiTenant.mjs +4 -6
- package/dist/presets-C9QXJV1u.mjs +422 -0
- package/dist/{queryCachePlugin-B6R0d4av.mjs → queryCachePlugin-ClosZdNS.mjs} +6 -27
- package/dist/{queryCachePlugin-Q6SYuHZ6.d.mts → queryCachePlugin-DcmETvcB.d.mts} +3 -3
- package/dist/queryParser-CgCtsjti.mjs +352 -0
- package/dist/{redis-UwjEp8Ea.d.mts → redis-CQ5YxMC5.d.mts} +2 -2
- package/dist/{redis-stream-CBg0upHI.d.mts → redis-stream-BW9UKLZM.d.mts} +9 -2
- package/dist/registry/index.d.mts +1 -4
- package/dist/registry/index.mjs +3 -4
- package/dist/{introspectionPlugin-B3JkrjwU.mjs → registry-I-ogLgL9.mjs} +1 -8
- package/dist/{requestContext-xi6OKBL-.mjs → requestContext-DYtmNpm5.mjs} +1 -3
- package/dist/resourceToTools-B6ZN9Ing.mjs +489 -0
- package/dist/rpc/index.d.mts +90 -0
- package/dist/rpc/index.mjs +248 -0
- package/dist/{schemaConverter-Dtg0Kt9T.mjs → schemaConverter-DjzHpFam.mjs} +1 -2
- package/dist/schemas/index.d.mts +30 -30
- package/dist/schemas/index.mjs +4 -6
- package/dist/scope/index.d.mts +13 -2
- package/dist/scope/index.mjs +18 -5
- package/dist/{sessionManager-D_iEHjQl.d.mts → sessionManager-wbkYj2HL.d.mts} +2 -2
- package/dist/{sse-DkqQ1uxb.mjs → sse-BkViJPlT.mjs} +4 -25
- package/dist/testing/index.d.mts +551 -567
- package/dist/testing/index.mjs +1744 -1799
- package/dist/{tracing-8CEbhF0w.d.mts → tracing-bz_U4EM1.d.mts} +6 -1
- package/dist/{typeGuards-DwxA1t_L.mjs → typeGuards-Cj5Rgvlg.mjs} +1 -2
- package/dist/types/index.d.mts +4 -946
- package/dist/types/index.mjs +2 -4
- package/dist/types-BJmgxNbF.d.mts +275 -0
- package/dist/{types-RLkFVgaw.d.mts → types-BNUccdcf.d.mts} +2 -2
- package/dist/{types-Beqn1Un7.mjs → types-C6TQjtdi.mjs} +30 -2
- package/dist/{types-DMSBMkaZ.d.mts → types-Dt0-AI6E.d.mts} +85 -27
- package/dist/{types-DelU6kln.mjs → types-ZUu_h0jp.mjs} +1 -2
- package/dist/utils/index.d.mts +255 -352
- package/dist/utils/index.mjs +7 -6
- package/dist/utils-Dc0WhlIl.mjs +594 -0
- package/dist/versioning-BzfeHmhj.mjs +37 -0
- package/package.json +46 -12
- package/skills/arc/SKILL.md +506 -0
- package/skills/arc/references/auth.md +250 -0
- package/skills/arc/references/events.md +272 -0
- package/skills/arc/references/integrations.md +385 -0
- package/skills/arc/references/mcp.md +386 -0
- package/skills/arc/references/production.md +610 -0
- package/skills/arc/references/testing.md +183 -0
- package/dist/audited-CGdLiSlE.mjs +0 -140
- package/dist/chunk-C7Uep-_p.mjs +0 -20
- package/dist/circuitBreaker-DYhWBW_D.mjs +0 -1096
- package/dist/errorHandler-CW3OOeYq.d.mts +0 -72
- package/dist/interface-DZYNK9bb.d.mts +0 -1112
- package/dist/presets-BTeYbw7h.d.mts +0 -57
- package/dist/presets-CeFtfDR8.mjs +0 -119
- /package/dist/{errors-DAWRdiYP.d.mts → errors-CPpvPHT0.d.mts} +0 -0
- /package/dist/{externalPaths-SyPF2tgK.d.mts → externalPaths-DpO-s7r8.d.mts} +0 -0
- /package/dist/{interface-DTbsvIWe.d.mts → interface-D_BWALyZ.d.mts} +0 -0
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
import { n as fieldRulesToZod, r as createMcpServer, t as resourceToTools } from "../../resourceToTools-B6ZN9Ing.mjs";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import fp from "fastify-plugin";
|
|
4
|
+
//#region src/integrations/mcp/definePrompt.ts
|
|
5
|
+
/**
|
|
6
|
+
* Define a type-safe MCP prompt.
|
|
7
|
+
*
|
|
8
|
+
* @param name - Prompt name (snake_case recommended)
|
|
9
|
+
* @param config - Description, args schema, handler
|
|
10
|
+
*/
|
|
11
|
+
function definePrompt(name, config) {
|
|
12
|
+
return {
|
|
13
|
+
name,
|
|
14
|
+
description: config.description,
|
|
15
|
+
title: config.title,
|
|
16
|
+
argsSchema: config.args,
|
|
17
|
+
handler: config.handler
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/integrations/mcp/defineTool.ts
|
|
22
|
+
/**
|
|
23
|
+
* Define a type-safe MCP tool.
|
|
24
|
+
*
|
|
25
|
+
* @param name - Tool name (snake_case recommended)
|
|
26
|
+
* @param config - Tool description, input schema, annotations, handler
|
|
27
|
+
*/
|
|
28
|
+
function defineTool(name, config) {
|
|
29
|
+
return {
|
|
30
|
+
name,
|
|
31
|
+
description: config.description,
|
|
32
|
+
title: config.title,
|
|
33
|
+
inputSchema: config.input,
|
|
34
|
+
outputSchema: config.output,
|
|
35
|
+
annotations: config.annotations,
|
|
36
|
+
handler: config.handler
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/integrations/mcp/guards.ts
|
|
41
|
+
/** Check if the tool context has an authenticated user (not anonymous/null) */
|
|
42
|
+
function isAuthenticated(ctx) {
|
|
43
|
+
return !!ctx.session && ctx.session.userId !== "anonymous";
|
|
44
|
+
}
|
|
45
|
+
/** Check if the tool context has an organization scope */
|
|
46
|
+
function hasOrg(ctx) {
|
|
47
|
+
return !!ctx.session?.organizationId;
|
|
48
|
+
}
|
|
49
|
+
/** Check if the tool context matches a specific org */
|
|
50
|
+
function isOrg(ctx, orgId) {
|
|
51
|
+
return ctx.session?.organizationId === orgId;
|
|
52
|
+
}
|
|
53
|
+
/** Get the current user ID from context (undefined if anonymous/null) */
|
|
54
|
+
function getUserId(ctx) {
|
|
55
|
+
const id = ctx.session?.userId;
|
|
56
|
+
return id && id !== "anonymous" ? id : void 0;
|
|
57
|
+
}
|
|
58
|
+
/** Get the current org ID from context */
|
|
59
|
+
function getOrgId(ctx) {
|
|
60
|
+
return ctx.session?.organizationId;
|
|
61
|
+
}
|
|
62
|
+
/** Create a denied (isError) CallToolResult */
|
|
63
|
+
function denied(reason) {
|
|
64
|
+
return {
|
|
65
|
+
content: [{
|
|
66
|
+
type: "text",
|
|
67
|
+
text: reason
|
|
68
|
+
}],
|
|
69
|
+
isError: true
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Wrap a tool handler with one or more guards.
|
|
74
|
+
* If any guard returns a string, the tool returns an error with that message.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* defineTool('delete_all', {
|
|
79
|
+
* description: 'Delete everything',
|
|
80
|
+
* handler: guard(requireAuth, requireOrg, async (input, ctx) => {
|
|
81
|
+
* // only runs if authenticated + has org
|
|
82
|
+
* }),
|
|
83
|
+
* });
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
function guard(...args) {
|
|
87
|
+
const handler = args.pop();
|
|
88
|
+
const guards = args;
|
|
89
|
+
return async (input, ctx) => {
|
|
90
|
+
for (const g of guards) {
|
|
91
|
+
const err = await g(ctx);
|
|
92
|
+
if (err) return denied(err);
|
|
93
|
+
}
|
|
94
|
+
return handler(input, ctx);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
/** Require authenticated user (not anonymous) */
|
|
98
|
+
const requireAuth = (ctx) => isAuthenticated(ctx) ? null : "Authentication required";
|
|
99
|
+
/** Require organization context */
|
|
100
|
+
const requireOrg = (ctx) => hasOrg(ctx) ? null : "Organization context required";
|
|
101
|
+
/** Require specific user role (checks session metadata if available) */
|
|
102
|
+
function requireRole(...roles) {
|
|
103
|
+
return (ctx) => {
|
|
104
|
+
if (!isAuthenticated(ctx)) return "Authentication required";
|
|
105
|
+
const userRoles = ctx.session?.roles ?? [];
|
|
106
|
+
return roles.some((r) => userRoles.includes(r)) ? null : `Required role: ${roles.join(" or ")}`;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Require specific organization */
|
|
110
|
+
function requireOrgId(orgId) {
|
|
111
|
+
return (ctx) => isOrg(ctx, orgId) ? null : `Access restricted to organization ${orgId}`;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Custom guard from a predicate function.
|
|
115
|
+
*
|
|
116
|
+
* @example
|
|
117
|
+
* ```ts
|
|
118
|
+
* const businessHoursOnly = customGuard(
|
|
119
|
+
* (ctx) => new Date().getHours() >= 9 && new Date().getHours() < 17,
|
|
120
|
+
* 'This tool is only available during business hours (9-5)',
|
|
121
|
+
* );
|
|
122
|
+
* ```
|
|
123
|
+
*/
|
|
124
|
+
function customGuard(check, errorMessage) {
|
|
125
|
+
return async (ctx) => {
|
|
126
|
+
return await check(ctx) ? null : errorMessage;
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
//#endregion
|
|
130
|
+
//#region src/integrations/mcp/authBridge.ts
|
|
131
|
+
/**
|
|
132
|
+
* @classytic/arc — MCP Auth Bridge
|
|
133
|
+
*
|
|
134
|
+
* Resolves MCP session identity from request headers.
|
|
135
|
+
* Supports three modes — the user chooses:
|
|
136
|
+
*
|
|
137
|
+
* 1. `false` — no auth, anonymous access
|
|
138
|
+
* 2. `BetterAuthHandler` — OAuth 2.1 via Better Auth
|
|
139
|
+
* 3. `McpAuthResolver` — custom function (API key, JWT, gateway headers, etc.)
|
|
140
|
+
*/
|
|
141
|
+
/** Distinguish BetterAuthHandler from McpAuthResolver */
|
|
142
|
+
function isBetterAuth(auth) {
|
|
143
|
+
return typeof auth === "object" && auth !== null && "api" in auth && "handler" in auth;
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Resolve MCP session identity from request headers.
|
|
147
|
+
*
|
|
148
|
+
* @param headers - HTTP request headers
|
|
149
|
+
* @param auth - false | BetterAuthHandler | McpAuthResolver
|
|
150
|
+
* @param authCache - Optional short-lived cache to avoid redundant auth lookups
|
|
151
|
+
*/
|
|
152
|
+
async function resolveMcpAuth(headers, auth, authCache) {
|
|
153
|
+
if (auth === false) return { userId: "anonymous" };
|
|
154
|
+
const cacheKey = authCache ? extractAuthCacheKey(headers) : null;
|
|
155
|
+
if (cacheKey && authCache) {
|
|
156
|
+
const cached = authCache.get(cacheKey);
|
|
157
|
+
if (cached !== void 0) return cached;
|
|
158
|
+
}
|
|
159
|
+
let result = null;
|
|
160
|
+
if (typeof auth === "function") try {
|
|
161
|
+
result = await auth(headers);
|
|
162
|
+
} catch {
|
|
163
|
+
result = null;
|
|
164
|
+
}
|
|
165
|
+
else if (isBetterAuth(auth)) try {
|
|
166
|
+
const session = await auth.api.getMcpSession({ headers });
|
|
167
|
+
if (!session?.userId) result = null;
|
|
168
|
+
else result = {
|
|
169
|
+
userId: session.userId,
|
|
170
|
+
organizationId: session.activeOrganizationId
|
|
171
|
+
};
|
|
172
|
+
} catch {
|
|
173
|
+
result = null;
|
|
174
|
+
}
|
|
175
|
+
if (cacheKey && authCache) authCache.set(cacheKey, result);
|
|
176
|
+
return result;
|
|
177
|
+
}
|
|
178
|
+
const DEFAULT_AUTH_CACHE_TTL_MS = 5e3;
|
|
179
|
+
const DEFAULT_AUTH_CACHE_MAX = 500;
|
|
180
|
+
/** Short-lived auth cache to avoid redundant auth resolver calls in stateless mode */
|
|
181
|
+
var McpAuthCache = class {
|
|
182
|
+
cache = /* @__PURE__ */ new Map();
|
|
183
|
+
ttlMs;
|
|
184
|
+
maxEntries;
|
|
185
|
+
constructor(opts) {
|
|
186
|
+
this.ttlMs = opts?.ttlMs ?? DEFAULT_AUTH_CACHE_TTL_MS;
|
|
187
|
+
this.maxEntries = opts?.maxEntries ?? DEFAULT_AUTH_CACHE_MAX;
|
|
188
|
+
}
|
|
189
|
+
get(key) {
|
|
190
|
+
const entry = this.cache.get(key);
|
|
191
|
+
if (!entry) return void 0;
|
|
192
|
+
if (Date.now() > entry.expires) {
|
|
193
|
+
this.cache.delete(key);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
return entry.result;
|
|
197
|
+
}
|
|
198
|
+
set(key, result) {
|
|
199
|
+
if (this.cache.size >= this.maxEntries) {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
for (const [k, v] of this.cache) if (now > v.expires) this.cache.delete(k);
|
|
202
|
+
if (this.cache.size >= this.maxEntries) {
|
|
203
|
+
const firstKey = this.cache.keys().next().value;
|
|
204
|
+
if (firstKey) this.cache.delete(firstKey);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
this.cache.set(key, {
|
|
208
|
+
result,
|
|
209
|
+
expires: Date.now() + this.ttlMs
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* Extract a cache key from auth-related headers.
|
|
215
|
+
* Uses SHA-256 hash of header values to prevent cache key collisions
|
|
216
|
+
* and avoid storing raw credentials in memory.
|
|
217
|
+
*/
|
|
218
|
+
function extractAuthCacheKey(headers) {
|
|
219
|
+
if (headers.authorization) return `authz:${hashForCache(headers.authorization)}`;
|
|
220
|
+
if (headers["x-api-key"]) return `apikey:${hashForCache(headers["x-api-key"])}`;
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
function hashForCache(value) {
|
|
224
|
+
return createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Register OAuth 2.1 discovery endpoints for MCP clients.
|
|
228
|
+
* Only relevant when using Better Auth — custom auth doesn't need these.
|
|
229
|
+
*/
|
|
230
|
+
async function registerOAuthDiscovery(fastify, auth) {
|
|
231
|
+
fastify.get("/.well-known/oauth-authorization-server", async (req, reply) => {
|
|
232
|
+
await forwardResponse(reply, await auth.handler(toWebRequest(req)));
|
|
233
|
+
});
|
|
234
|
+
fastify.get("/.well-known/oauth-protected-resource", async (req, reply) => {
|
|
235
|
+
await forwardResponse(reply, await auth.handler(toWebRequest(req)));
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
function toWebRequest(req) {
|
|
239
|
+
const protocol = req.protocol ?? "http";
|
|
240
|
+
const host = req.hostname ?? "localhost";
|
|
241
|
+
return new Request(`${protocol}://${host}${req.url}`, {
|
|
242
|
+
method: req.method,
|
|
243
|
+
headers: req.headers
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
async function forwardResponse(reply, response) {
|
|
247
|
+
reply.status(response.status);
|
|
248
|
+
response.headers.forEach((value, key) => {
|
|
249
|
+
if (key.toLowerCase() !== "transfer-encoding") reply.header(key, value);
|
|
250
|
+
});
|
|
251
|
+
reply.send(await response.text());
|
|
252
|
+
}
|
|
253
|
+
//#endregion
|
|
254
|
+
//#region src/integrations/mcp/schemaResources.ts
|
|
255
|
+
/**
|
|
256
|
+
* Register MCP Resources for schema discovery.
|
|
257
|
+
*/
|
|
258
|
+
function registerSchemaResources(server, resources, overrides) {
|
|
259
|
+
const srv = server;
|
|
260
|
+
srv.resource("schemas", "arc://schemas", {
|
|
261
|
+
title: "Arc Resource Schemas",
|
|
262
|
+
description: "All available resources",
|
|
263
|
+
mimeType: "application/json"
|
|
264
|
+
}, async () => ({ contents: [{
|
|
265
|
+
uri: "arc://schemas",
|
|
266
|
+
mimeType: "application/json",
|
|
267
|
+
text: JSON.stringify(resources.map((r) => ({
|
|
268
|
+
name: r.name,
|
|
269
|
+
displayName: r.displayName,
|
|
270
|
+
fieldCount: r.schemaOptions?.fieldRules ? Object.keys(r.schemaOptions.fieldRules).length : 0,
|
|
271
|
+
operations: getOps(r, overrides?.[r.name]?.operations),
|
|
272
|
+
presets: r._appliedPresets ?? []
|
|
273
|
+
})), null, 2)
|
|
274
|
+
}] }));
|
|
275
|
+
for (const r of resources) {
|
|
276
|
+
const uri = `arc://schemas/${r.name}`;
|
|
277
|
+
const schemaOpts = r.schemaOptions;
|
|
278
|
+
srv.resource(`schema-${r.name}`, uri, {
|
|
279
|
+
title: `${r.displayName} Schema`,
|
|
280
|
+
description: `Schema for ${r.displayName}`,
|
|
281
|
+
mimeType: "application/json"
|
|
282
|
+
}, async () => ({ contents: [{
|
|
283
|
+
uri,
|
|
284
|
+
mimeType: "application/json",
|
|
285
|
+
text: JSON.stringify({
|
|
286
|
+
name: r.name,
|
|
287
|
+
displayName: r.displayName,
|
|
288
|
+
operations: getOps(r, overrides?.[r.name]?.operations),
|
|
289
|
+
fields: r.schemaOptions?.fieldRules ?? {},
|
|
290
|
+
filterableFields: schemaOpts?.filterableFields ?? [],
|
|
291
|
+
presets: r._appliedPresets ?? []
|
|
292
|
+
}, null, 2)
|
|
293
|
+
}] }));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function getOps(r, override) {
|
|
297
|
+
let ops = [
|
|
298
|
+
"list",
|
|
299
|
+
"get",
|
|
300
|
+
"create",
|
|
301
|
+
"update",
|
|
302
|
+
"delete"
|
|
303
|
+
].filter((op) => !r.disabledRoutes?.includes(op));
|
|
304
|
+
if (override) ops = ops.filter((op) => override.includes(op));
|
|
305
|
+
return ops;
|
|
306
|
+
}
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/integrations/mcp/sessionCache.ts
|
|
309
|
+
const DEFAULT_TTL_MS = 1800 * 1e3;
|
|
310
|
+
const DEFAULT_MAX_SESSIONS = 1e3;
|
|
311
|
+
var McpSessionCache = class {
|
|
312
|
+
sessions = /* @__PURE__ */ new Map();
|
|
313
|
+
ttlMs;
|
|
314
|
+
maxSessions;
|
|
315
|
+
cleanupTimer = null;
|
|
316
|
+
constructor(opts = {}) {
|
|
317
|
+
this.ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
318
|
+
this.maxSessions = opts.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
319
|
+
if (this.ttlMs > 0) {
|
|
320
|
+
this.cleanupTimer = setInterval(() => this.cleanup(), Math.max(this.ttlMs / 2, 5e3));
|
|
321
|
+
if (this.cleanupTimer.unref) this.cleanupTimer.unref();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
/** Get an existing session by ID */
|
|
325
|
+
get(sessionId) {
|
|
326
|
+
const entry = this.sessions.get(sessionId);
|
|
327
|
+
if (!entry) return void 0;
|
|
328
|
+
if (Date.now() - entry.lastAccessed > this.ttlMs) {
|
|
329
|
+
this.remove(sessionId);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
return entry;
|
|
333
|
+
}
|
|
334
|
+
/** Store a new session */
|
|
335
|
+
set(sessionId, entry) {
|
|
336
|
+
if (this.sessions.size >= this.maxSessions && !this.sessions.has(sessionId)) this.evictOldest();
|
|
337
|
+
entry.lastAccessed = Date.now();
|
|
338
|
+
this.sessions.set(sessionId, entry);
|
|
339
|
+
}
|
|
340
|
+
/** Refresh the TTL on a session */
|
|
341
|
+
touch(sessionId) {
|
|
342
|
+
const entry = this.sessions.get(sessionId);
|
|
343
|
+
if (entry) entry.lastAccessed = Date.now();
|
|
344
|
+
}
|
|
345
|
+
/** Remove and close a session */
|
|
346
|
+
remove(sessionId) {
|
|
347
|
+
const entry = this.sessions.get(sessionId);
|
|
348
|
+
if (entry) {
|
|
349
|
+
this.closeTransport(entry);
|
|
350
|
+
this.sessions.delete(sessionId);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
/** Remove all expired sessions */
|
|
354
|
+
cleanup() {
|
|
355
|
+
const now = Date.now();
|
|
356
|
+
for (const [id, entry] of this.sessions) if (now - entry.lastAccessed > this.ttlMs) {
|
|
357
|
+
this.closeTransport(entry);
|
|
358
|
+
this.sessions.delete(id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
/** Close all sessions and stop cleanup timer */
|
|
362
|
+
close() {
|
|
363
|
+
if (this.cleanupTimer) {
|
|
364
|
+
clearInterval(this.cleanupTimer);
|
|
365
|
+
this.cleanupTimer = null;
|
|
366
|
+
}
|
|
367
|
+
for (const [id, entry] of this.sessions) {
|
|
368
|
+
this.closeTransport(entry);
|
|
369
|
+
this.sessions.delete(id);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
/** Current session count */
|
|
373
|
+
get size() {
|
|
374
|
+
return this.sessions.size;
|
|
375
|
+
}
|
|
376
|
+
/** Evict the oldest (least recently accessed) session */
|
|
377
|
+
evictOldest() {
|
|
378
|
+
let oldestId = null;
|
|
379
|
+
let oldestTime = Infinity;
|
|
380
|
+
for (const [id, entry] of this.sessions) if (entry.lastAccessed < oldestTime) {
|
|
381
|
+
oldestTime = entry.lastAccessed;
|
|
382
|
+
oldestId = id;
|
|
383
|
+
}
|
|
384
|
+
if (oldestId) this.remove(oldestId);
|
|
385
|
+
}
|
|
386
|
+
/** Safely close a transport */
|
|
387
|
+
closeTransport(entry) {
|
|
388
|
+
try {
|
|
389
|
+
const transport = entry.transport;
|
|
390
|
+
if (transport && typeof transport.close === "function") transport.close();
|
|
391
|
+
} catch {}
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
//#endregion
|
|
395
|
+
//#region src/integrations/mcp/mcpPlugin.ts
|
|
396
|
+
const mcpPluginImpl = async (fastify, options) => {
|
|
397
|
+
let StreamableHTTPServerTransport;
|
|
398
|
+
try {
|
|
399
|
+
StreamableHTTPServerTransport = (await import("@modelcontextprotocol/sdk/server/streamableHttp.js")).StreamableHTTPServerTransport;
|
|
400
|
+
await import("zod");
|
|
401
|
+
} catch {
|
|
402
|
+
throw new Error("@modelcontextprotocol/sdk and zod are required for MCP support. Install them: npm install @modelcontextprotocol/sdk zod");
|
|
403
|
+
}
|
|
404
|
+
let enabledResources;
|
|
405
|
+
if (options.include) {
|
|
406
|
+
const includeSet = new Set(options.include);
|
|
407
|
+
enabledResources = options.resources.filter((r) => includeSet.has(r.name));
|
|
408
|
+
} else {
|
|
409
|
+
const excludeSet = new Set(options.exclude ?? []);
|
|
410
|
+
enabledResources = options.resources.filter((r) => !excludeSet.has(r.name));
|
|
411
|
+
}
|
|
412
|
+
const overrides = options.overrides ?? {};
|
|
413
|
+
const allTools = enabledResources.flatMap((r) => {
|
|
414
|
+
const resOverrides = overrides[r.name] ?? {};
|
|
415
|
+
return resourceToTools(r, {
|
|
416
|
+
...resOverrides,
|
|
417
|
+
toolNamePrefix: resOverrides.toolNamePrefix ?? options.toolNamePrefix
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
if (options.extraTools) allTools.push(...options.extraTools);
|
|
421
|
+
fastify.log.info(`mcpPlugin: ${allTools.length} tools from ${enabledResources.length} resources`);
|
|
422
|
+
const overrideOpsMap = {};
|
|
423
|
+
for (const [name, cfg] of Object.entries(overrides)) overrideOpsMap[name] = { operations: cfg.operations };
|
|
424
|
+
const stateful = options.stateful === true;
|
|
425
|
+
const cache = stateful ? new McpSessionCache({
|
|
426
|
+
ttlMs: options.sessionTtlMs,
|
|
427
|
+
maxSessions: options.maxSessions
|
|
428
|
+
}) : null;
|
|
429
|
+
async function createServerInstance(authRef) {
|
|
430
|
+
const server = await createMcpServer({
|
|
431
|
+
name: options.serverName ?? "arc-mcp",
|
|
432
|
+
version: options.serverVersion ?? "1.0.0",
|
|
433
|
+
instructions: options.instructions,
|
|
434
|
+
tools: allTools,
|
|
435
|
+
prompts: options.extraPrompts
|
|
436
|
+
}, authRef);
|
|
437
|
+
registerSchemaResources(server, enabledResources, overrideOpsMap);
|
|
438
|
+
return server;
|
|
439
|
+
}
|
|
440
|
+
if (options.auth && isBetterAuth(options.auth)) await registerOAuthDiscovery(fastify, options.auth);
|
|
441
|
+
const prefix = options.prefix ?? "/mcp";
|
|
442
|
+
fastify.get(`${prefix}/health`, async (_request, reply) => {
|
|
443
|
+
reply.send({
|
|
444
|
+
status: "ok",
|
|
445
|
+
mode: stateful ? "stateful" : "stateless",
|
|
446
|
+
tools: allTools.length,
|
|
447
|
+
resources: enabledResources.length,
|
|
448
|
+
toolNames: allTools.map((t) => t.name),
|
|
449
|
+
sessions: cache?.size ?? null
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
if (stateful) registerStatefulRoutes(fastify, prefix, options, cache, createServerInstance, StreamableHTTPServerTransport);
|
|
453
|
+
else {
|
|
454
|
+
const authCache = options.auth && options.authCacheTtlMs !== 0 ? new McpAuthCache({ ttlMs: options.authCacheTtlMs }) : void 0;
|
|
455
|
+
registerStatelessRoutes(fastify, prefix, options, createServerInstance, StreamableHTTPServerTransport, authCache);
|
|
456
|
+
}
|
|
457
|
+
if (cache) fastify.addHook("onClose", async () => cache.close());
|
|
458
|
+
if (!fastify.hasDecorator("mcp")) fastify.decorate("mcp", {
|
|
459
|
+
sessions: cache,
|
|
460
|
+
toolNames: allTools.map((t) => t.name),
|
|
461
|
+
resourceNames: enabledResources.map((r) => r.name),
|
|
462
|
+
stateful
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
function registerStatelessRoutes(fastify, prefix, options, createServer, Transport, authCache) {
|
|
466
|
+
fastify.post(prefix, async (request, reply) => {
|
|
467
|
+
const authResult = await resolveMcpAuth(request.headers, options.auth ?? false, authCache);
|
|
468
|
+
if (!authResult && options.auth) {
|
|
469
|
+
fastify.log.warn("mcpPlugin: auth failed — returning 401 (client may silently drop server)");
|
|
470
|
+
return reply.code(401).send({
|
|
471
|
+
jsonrpc: "2.0",
|
|
472
|
+
error: {
|
|
473
|
+
code: -32e3,
|
|
474
|
+
message: "Unauthorized"
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
const authRef = { current: authResult };
|
|
479
|
+
const transport = new Transport({ sessionIdGenerator: void 0 });
|
|
480
|
+
await (await createServer(authRef)).connect(transport);
|
|
481
|
+
await transport.handleRequest(request.raw, reply.raw, request.body);
|
|
482
|
+
});
|
|
483
|
+
fastify.get(prefix, async (_request, reply) => {
|
|
484
|
+
reply.code(405).send({
|
|
485
|
+
jsonrpc: "2.0",
|
|
486
|
+
error: {
|
|
487
|
+
code: -32e3,
|
|
488
|
+
message: "SSE not available in stateless mode. Use stateful: true for server-initiated messages."
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
492
|
+
fastify.delete(prefix, async (_request, reply) => {
|
|
493
|
+
reply.code(200).send();
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
function registerStatefulRoutes(fastify, prefix, options, cache, createServer, Transport) {
|
|
497
|
+
/** Check if the requesting user owns the session */
|
|
498
|
+
function isSessionOwner(entry, authResult) {
|
|
499
|
+
if (!options.auth || !entry.auth || !authResult) return true;
|
|
500
|
+
return entry.auth.userId === authResult.userId && entry.auth.organizationId === authResult.organizationId;
|
|
501
|
+
}
|
|
502
|
+
fastify.post(prefix, async (request, reply) => {
|
|
503
|
+
const authResult = await resolveMcpAuth(request.headers, options.auth ?? false);
|
|
504
|
+
if (!authResult && options.auth) {
|
|
505
|
+
fastify.log.warn("mcpPlugin: auth failed — returning 401 (client may silently drop server)");
|
|
506
|
+
return reply.code(401).send({
|
|
507
|
+
jsonrpc: "2.0",
|
|
508
|
+
error: {
|
|
509
|
+
code: -32e3,
|
|
510
|
+
message: "Unauthorized"
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
const sessionId = request.headers["mcp-session-id"];
|
|
515
|
+
if (sessionId) {
|
|
516
|
+
const entry = cache.get(sessionId);
|
|
517
|
+
if (entry) {
|
|
518
|
+
if (!isSessionOwner(entry, authResult)) return reply.code(403).send({
|
|
519
|
+
jsonrpc: "2.0",
|
|
520
|
+
error: {
|
|
521
|
+
code: -32e3,
|
|
522
|
+
message: "Session ownership mismatch"
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
cache.touch(sessionId);
|
|
526
|
+
entry.auth = authResult;
|
|
527
|
+
entry.authRef.current = authResult;
|
|
528
|
+
await entry.transport.handleRequest(request.raw, reply.raw, request.body);
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
const authRef = { current: authResult };
|
|
533
|
+
const transport = new Transport({ sessionIdGenerator: void 0 });
|
|
534
|
+
await (await createServer(authRef)).connect(transport);
|
|
535
|
+
cache.set(transport.sessionId, {
|
|
536
|
+
transport,
|
|
537
|
+
lastAccessed: Date.now(),
|
|
538
|
+
organizationId: authResult?.organizationId ?? "",
|
|
539
|
+
auth: authResult,
|
|
540
|
+
authRef
|
|
541
|
+
});
|
|
542
|
+
await transport.handleRequest(request.raw, reply.raw, request.body);
|
|
543
|
+
});
|
|
544
|
+
fastify.get(prefix, async (request, reply) => {
|
|
545
|
+
const sessionId = request.headers["mcp-session-id"];
|
|
546
|
+
if (!sessionId) return reply.code(400).send({ error: "Missing Mcp-Session-Id header" });
|
|
547
|
+
const entry = cache.get(sessionId);
|
|
548
|
+
if (!entry) return reply.code(403).send({ error: "Unauthorized" });
|
|
549
|
+
if (options.auth) {
|
|
550
|
+
if (!isSessionOwner(entry, await resolveMcpAuth(request.headers, options.auth))) return reply.code(403).send({ error: "Unauthorized" });
|
|
551
|
+
}
|
|
552
|
+
cache.touch(sessionId);
|
|
553
|
+
await entry.transport.handleRequest(request.raw, reply.raw);
|
|
554
|
+
});
|
|
555
|
+
fastify.delete(prefix, async (request, reply) => {
|
|
556
|
+
const sessionId = request.headers["mcp-session-id"];
|
|
557
|
+
if (!sessionId) return reply.code(400).send({ error: "Missing Mcp-Session-Id header" });
|
|
558
|
+
const entry = cache.get(sessionId);
|
|
559
|
+
if (!entry) return reply.code(204).send();
|
|
560
|
+
if (options.auth) {
|
|
561
|
+
if (!isSessionOwner(entry, await resolveMcpAuth(request.headers, options.auth))) return reply.code(403).send({ error: "Unauthorized" });
|
|
562
|
+
}
|
|
563
|
+
cache.remove(sessionId);
|
|
564
|
+
reply.code(204).send();
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const mcpPlugin = fp(mcpPluginImpl, {
|
|
568
|
+
name: "arc-mcp",
|
|
569
|
+
fastify: "5.x"
|
|
570
|
+
});
|
|
571
|
+
//#endregion
|
|
572
|
+
export { createMcpServer, customGuard, definePrompt, defineTool, denied, fieldRulesToZod, getOrgId, getUserId, guard, hasOrg, isAuthenticated, isOrg, mcpPlugin, requireAuth, requireOrg, requireOrgId, requireRole, resourceToTools };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { o as McpAuthResult, s as McpPluginOptions } from "../../types-BJmgxNbF.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/integrations/mcp/testing.d.ts
|
|
4
|
+
interface TestMcpClientOptions {
|
|
5
|
+
/** MCP plugin options (resources, overrides, etc.) — same as mcpPlugin config */
|
|
6
|
+
pluginOptions?: Pick<McpPluginOptions, "resources" | "overrides" | "include" | "exclude" | "toolNamePrefix" | "extraTools" | "extraPrompts" | "instructions">;
|
|
7
|
+
/** Auth identity for the test session */
|
|
8
|
+
auth?: McpAuthResult | null;
|
|
9
|
+
/** Server name (default: 'test-mcp') */
|
|
10
|
+
serverName?: string;
|
|
11
|
+
}
|
|
12
|
+
interface TestMcpClient {
|
|
13
|
+
/** List all registered tools */
|
|
14
|
+
listTools(): Promise<Array<{
|
|
15
|
+
name: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
}>>;
|
|
18
|
+
/** Call a tool by name */
|
|
19
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<{
|
|
20
|
+
content: Array<{
|
|
21
|
+
type: string;
|
|
22
|
+
text: string;
|
|
23
|
+
}>;
|
|
24
|
+
isError?: boolean;
|
|
25
|
+
}>;
|
|
26
|
+
/** Disconnect and clean up */
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Create an in-process MCP test client connected to an Arc MCP server.
|
|
31
|
+
*
|
|
32
|
+
* Pass resources and tools directly — no running Fastify server needed.
|
|
33
|
+
* For HTTP-level integration tests against a running server, use `app.inject()` instead.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```typescript
|
|
37
|
+
* const client = await createTestMcpClient({
|
|
38
|
+
* pluginOptions: { resources: [productResource], extraTools: [myTool] },
|
|
39
|
+
* auth: { userId: 'test-user', organizationId: 'org-1' },
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* const tools = await client.listTools();
|
|
43
|
+
* expect(tools.map(t => t.name)).toContain('list_products');
|
|
44
|
+
*
|
|
45
|
+
* const result = await client.callTool('list_products', { limit: 5 });
|
|
46
|
+
* expect(result.isError).toBeFalsy();
|
|
47
|
+
*
|
|
48
|
+
* await client.close();
|
|
49
|
+
* ```
|
|
50
|
+
*/
|
|
51
|
+
declare function createTestMcpClient(options?: TestMcpClientOptions): Promise<TestMcpClient>;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { TestMcpClient, TestMcpClientOptions, createTestMcpClient };
|