@anteros/core 0.0.19 → 0.0.21
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 +90 -5
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +64 -40
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +115 -32
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +6 -3
- package/probe-cache.ts +16 -0
- package/server/api.ts +35 -28
- package/server/boot.ts +78 -16
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +192 -18
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- package/tests/api.test.ts +14 -12
- package/tests/cache.test.ts +124 -0
- package/tests/crypto.test.ts +101 -0
- package/tests/fixtures/heavy/collections/items.model.ts +8 -0
- package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
- package/tests/fixtures/metrics-worker.ts +21 -0
- package/tests/hono.test.ts +211 -0
- package/tests/mcp.test.ts +186 -0
- package/tests/metrics-cluster.test.ts +103 -0
- package/tests/scripts-heavy.test.ts +49 -0
- package/tests/security.test.ts +102 -0
- package/tests/ts1011-test.ts +15 -0
- package/tests/utils.test.ts +113 -0
- package/types/api.d.ts +11 -0
- package/types/collection.d.ts +20 -5
- package/types/config.d.ts +20 -3
- package/types/hook.d.ts +16 -13
- package/types/mcp.d.ts +81 -0
- package/types/scripts.d.ts +14 -1
- package/utils/cache.ts +20 -1
- package/utils/crypto.ts +25 -8
- package/utils/func.ts +178 -18
package/server/hono.ts
CHANGED
|
@@ -1,22 +1,111 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
1
|
+
import { Hono, type Context } from "hono";
|
|
2
2
|
import { cors } from "hono/cors"
|
|
3
3
|
import { compress } from 'hono/compress'
|
|
4
4
|
import { ipRestriction } from "hono/ip-restriction"
|
|
5
5
|
import { bodyLimit } from "hono/body-limit"
|
|
6
6
|
import { secureHeaders } from "hono/secure-headers"
|
|
7
|
+
import { HTTPException } from "hono/http-exception"
|
|
7
8
|
import { getConnInfo } from "hono/bun"
|
|
9
|
+
import type { ConnInfo, GetConnInfo } from "hono/conninfo"
|
|
10
|
+
import { Redis } from "ioredis"
|
|
8
11
|
import { cfg } from "./config"
|
|
9
|
-
import { rateLimit } from "./security"
|
|
12
|
+
import { rateLimit, createRedisStore, createMemoryStore, type RateLimitStore } from "./security"
|
|
10
13
|
import { initializeRoutes } from "./routes";
|
|
11
|
-
import {
|
|
14
|
+
import { asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
|
|
12
15
|
import { initializeApi } from "./api";
|
|
16
|
+
import { initializeMcp } from "./mcp";
|
|
13
17
|
import { getGlobalMiddlewares } from "../lib/middleware";
|
|
14
18
|
import { jwt } from "../utils/func";
|
|
15
19
|
import { AppError } from "../lib/error";
|
|
20
|
+
import { getMetrics } from "./metrics";
|
|
16
21
|
import type { HonoVariables } from "./env";
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
// ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
|
|
24
|
+
// Lazy singleton: created once per process and reused across createApp() calls.
|
|
25
|
+
// Each increment falls back to the in-memory store if Redis is unavailable,
|
|
26
|
+
// so the limiter keeps working during a Redis outage.
|
|
27
|
+
let redisRateLimitStore: RateLimitStore | null | undefined;
|
|
28
|
+
let redisRateLimitSignature: string | undefined;
|
|
29
|
+
|
|
30
|
+
function getRedisRateLimitStore(): RateLimitStore | null {
|
|
31
|
+
const rl = cfg.server.rateLimit;
|
|
32
|
+
// Re-evaluate only when the rate-limit config or Redis env vars change,
|
|
33
|
+
// so multiple createApp() calls (tests, hot reload) reuse the same client.
|
|
34
|
+
const signature = JSON.stringify([
|
|
35
|
+
rl?.useRedis,
|
|
36
|
+
rl?.redis,
|
|
37
|
+
process.env.REDIS_URL,
|
|
38
|
+
process.env.REDIS_HOST,
|
|
39
|
+
process.env.REDIS_PORT,
|
|
40
|
+
process.env.REDIS_PASSWORD,
|
|
41
|
+
]);
|
|
42
|
+
if (redisRateLimitSignature === signature && redisRateLimitStore !== undefined) return redisRateLimitStore;
|
|
43
|
+
redisRateLimitSignature = signature;
|
|
44
|
+
|
|
45
|
+
if (!rl) {
|
|
46
|
+
redisRateLimitStore = null;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Redis is used AUTOMATICALLY when connection info is present (rateLimit.redis
|
|
51
|
+
// block or REDIS_* env vars) — no need for useRedis: true.
|
|
52
|
+
// - useRedis: false → force the in-memory store
|
|
53
|
+
// - useRedis: true → force Redis even without connection info (localhost)
|
|
54
|
+
// - unset → auto: Redis if configured, else memory
|
|
55
|
+
const conn = rl.redis ?? {};
|
|
56
|
+
const hasRedisConfig =
|
|
57
|
+
Boolean(conn.url || conn.host || conn.port || conn.password) ||
|
|
58
|
+
Boolean(process.env.REDIS_URL || process.env.REDIS_HOST || process.env.REDIS_PORT || process.env.REDIS_PASSWORD);
|
|
59
|
+
const useRedis = rl.useRedis === true || (rl.useRedis !== false && hasRedisConfig);
|
|
60
|
+
|
|
61
|
+
if (!useRedis) {
|
|
62
|
+
redisRateLimitStore = null;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const conn = rl.redis ?? {};
|
|
68
|
+
// Stop reconnecting after a few attempts: a dead Redis must not spam logs
|
|
69
|
+
// or keep the process alive — the limiter falls back to memory meanwhile.
|
|
70
|
+
const retryStrategy = (times: number) => (times > 5 ? null : Math.min(times * 500, 3000));
|
|
71
|
+
const client = conn.url
|
|
72
|
+
? new Redis(conn.url, { retryStrategy, maxRetriesPerRequest: 2 })
|
|
73
|
+
: new Redis({
|
|
74
|
+
host: conn.host ?? process.env.REDIS_HOST ?? 'localhost',
|
|
75
|
+
port: conn.port ?? (Number(process.env.REDIS_PORT) || 6379),
|
|
76
|
+
password: conn.password ?? process.env.REDIS_PASSWORD,
|
|
77
|
+
retryStrategy,
|
|
78
|
+
maxRetriesPerRequest: 2,
|
|
79
|
+
});
|
|
80
|
+
let errorLogged = false;
|
|
81
|
+
client.on('error', (err) => {
|
|
82
|
+
if (errorLogged) return;
|
|
83
|
+
errorLogged = true;
|
|
84
|
+
console.error('[rateLimit] Redis connection error:', err?.message);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const redisStore = createRedisStore(client);
|
|
88
|
+
const memoryFallback = createMemoryStore();
|
|
89
|
+
redisRateLimitStore = {
|
|
90
|
+
async increment(key: string, windowMs: number) {
|
|
91
|
+
try {
|
|
92
|
+
return await redisStore.increment(key, windowMs);
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error('[rateLimit] Redis store failed, falling back to in-memory:', err?.message);
|
|
95
|
+
return memoryFallback.increment(key, windowMs);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
console.log('[rateLimit] store: Redis (with in-memory fallback)');
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
console.error('[rateLimit] Redis init failed, using in-memory store:', err?.message);
|
|
102
|
+
redisRateLimitStore = null;
|
|
103
|
+
}
|
|
104
|
+
return redisRateLimitStore;
|
|
105
|
+
}
|
|
106
|
+
|
|
19
107
|
function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
108
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
20
109
|
const allowHeaders = [
|
|
21
110
|
"Tenant-Id",
|
|
22
111
|
"Content-Type",
|
|
@@ -43,17 +132,78 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
43
132
|
"HEAD",
|
|
44
133
|
...(cfg.server.cors?.allowMethods as string[] || []),
|
|
45
134
|
]
|
|
46
|
-
// cors
|
|
47
|
-
const
|
|
48
|
-
const corsCredentials = cfg.server.cors?.credentials
|
|
135
|
+
// cors — origin accepts string | string[] | function (see ServerConfig type)
|
|
136
|
+
const corsConfig = cfg.server.cors?.origin;
|
|
137
|
+
const corsCredentials = cfg.server.cors?.credentials ?? false;
|
|
138
|
+
|
|
139
|
+
// Detect a wildcard in any supported form without breaking on string/function origins
|
|
140
|
+
const hasWildcardOrigin =
|
|
141
|
+
corsConfig === '*' ||
|
|
142
|
+
(Array.isArray(corsConfig) && corsConfig.includes('*'));
|
|
49
143
|
|
|
50
|
-
if (corsCredentials && (!
|
|
144
|
+
if (corsCredentials && (!corsConfig || hasWildcardOrigin)) {
|
|
51
145
|
throw new Error(
|
|
52
146
|
'CORS misconfiguration: credentials: true is incompatible with origin: *. '
|
|
53
147
|
+ 'Set explicit origins in cfg.server.cors.origin.'
|
|
54
148
|
);
|
|
55
149
|
}
|
|
56
150
|
|
|
151
|
+
// Config function form: (ctx: { origin, c }) => string | string[]
|
|
152
|
+
// Hono expects: (origin, c) => string | Promise<string | null | undefined> | null | undefined
|
|
153
|
+
type CorsOriginOption = NonNullable<Parameters<typeof cors>[0]>['origin'];
|
|
154
|
+
const corsOrigin =
|
|
155
|
+
typeof corsConfig === 'function'
|
|
156
|
+
? ((origin: string, c: Context) => corsConfig({ origin, c })) as unknown as CorsOriginOption
|
|
157
|
+
: corsConfig;
|
|
158
|
+
|
|
159
|
+
// Global error handler — maps AppError thrown by user routes, global
|
|
160
|
+
// middlewares and useRest to their proper status/code/message.
|
|
161
|
+
// (API routes keep their own errorResponse() for backward compat.)
|
|
162
|
+
app.onError((err, c) => {
|
|
163
|
+
// Let Hono's HTTPException respond with its own status (401/413/…)
|
|
164
|
+
if (err instanceof HTTPException) {
|
|
165
|
+
return err.getResponse();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const isAppError = err instanceof AppError;
|
|
169
|
+
// Always log unexpected errors (default Hono behavior preserved)
|
|
170
|
+
if (!isAppError) console.error(err);
|
|
171
|
+
|
|
172
|
+
const status = isAppError ? Number(err.status) || 500 : 500;
|
|
173
|
+
return c.json({
|
|
174
|
+
message: isAppError ? err.message : 'Internal server error',
|
|
175
|
+
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
176
|
+
meta: isAppError ? err.meta : undefined,
|
|
177
|
+
}, status as any);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Client IP: resolved once per request and shared by all middlewares ──
|
|
181
|
+
// getConnInfo(c) = server.requestIP() → the real socket address. Proxy headers
|
|
182
|
+
// (CF-Connecting-IP / X-Forwarded-For) are ONLY trusted when cfg.server.trustProxy
|
|
183
|
+
// is explicitly enabled, otherwise they are spoofable.
|
|
184
|
+
// getConnInfo throws when the app runs outside a real Bun.serve (e.g. app.request()
|
|
185
|
+
// in tests) → fall back to an empty address.
|
|
186
|
+
const safeGetConnInfo = (c: Context): ConnInfo => {
|
|
187
|
+
try {
|
|
188
|
+
return getConnInfo(c);
|
|
189
|
+
} catch {
|
|
190
|
+
return { remote: {} };
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
app.use(async (c, next) => {
|
|
195
|
+
const connInfo = safeGetConnInfo(c);
|
|
196
|
+
c.set('connInfo', connInfo);
|
|
197
|
+
c.set('clientIp', cfg.server.trustProxy
|
|
198
|
+
? c.req.header('CF-Connecting-IP') ?? c.req.header('X-Forwarded-For') ?? connInfo.remote.address ?? 'unknown'
|
|
199
|
+
: connInfo.remote.address ?? 'unknown');
|
|
200
|
+
await next();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Reuse the cached connInfo instead of re-querying the socket per middleware
|
|
204
|
+
const cachedGetConnInfo: GetConnInfo = (c) =>
|
|
205
|
+
(c as Context<{ Variables: HonoVariables }>).get('connInfo') ?? safeGetConnInfo(c);
|
|
206
|
+
|
|
57
207
|
|
|
58
208
|
|
|
59
209
|
app.use(cors({
|
|
@@ -67,7 +217,7 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
67
217
|
app.use(compress());
|
|
68
218
|
|
|
69
219
|
// ip restriction
|
|
70
|
-
app.use(ipRestriction(
|
|
220
|
+
app.use(ipRestriction(cachedGetConnInfo, {
|
|
71
221
|
denyList: cfg.server?.ipRestriction?.denyList || [],
|
|
72
222
|
allowList: cfg.server?.ipRestriction?.allowList || [],
|
|
73
223
|
}))
|
|
@@ -88,19 +238,38 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
88
238
|
maxSize: cfg.server.body?.maxSize ?? 1024 * 1024 * 100, // 100MB
|
|
89
239
|
}));
|
|
90
240
|
|
|
241
|
+
// ── Health & metrics (built-in, zero dependency) ──
|
|
242
|
+
app.get('/health', (c) => {
|
|
243
|
+
return c.json({
|
|
244
|
+
...getMetrics(),
|
|
245
|
+
ip: c.get('clientIp') ?? 'unknown',
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
91
249
|
|
|
92
250
|
|
|
93
251
|
// rate limiting (global)
|
|
94
252
|
if (cfg.server.rateLimit?.enabled !== false) {
|
|
253
|
+
// Redis-backed store when configured (C3), in-memory otherwise
|
|
254
|
+
const store = getRedisRateLimitStore() ?? undefined;
|
|
255
|
+
|
|
256
|
+
// Distinct keys so the stricter login limit never shares counters with the global one
|
|
257
|
+
const keyByClientIp = (prefix: string) => (c: Context) =>
|
|
258
|
+
`rn:rl:${prefix}${(c as any).get('clientIp') ?? 'unknown'}`;
|
|
259
|
+
|
|
95
260
|
app.use('*', rateLimit({
|
|
96
261
|
windowMs: cfg.server.rateLimit?.windowMs ?? 60_000,
|
|
97
262
|
max: cfg.server.rateLimit?.max ?? 100,
|
|
263
|
+
store,
|
|
264
|
+
keyGenerator: keyByClientIp(''),
|
|
98
265
|
}));
|
|
99
266
|
|
|
100
267
|
// stricter limit for login endpoints
|
|
101
268
|
app.use('/api/*/login', rateLimit({
|
|
102
269
|
windowMs: cfg.server.rateLimit?.login?.windowMs ?? 60_000,
|
|
103
270
|
max: cfg.server.rateLimit?.login?.max ?? 10,
|
|
271
|
+
store,
|
|
272
|
+
keyGenerator: keyByClientIp('login:'),
|
|
104
273
|
}));
|
|
105
274
|
}
|
|
106
275
|
|
|
@@ -108,17 +277,20 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
108
277
|
app.use(async (c, next) => {
|
|
109
278
|
return asyncContextStorage.run(new Map(), async () => {
|
|
110
279
|
const traceId = crypto.randomUUID();
|
|
111
|
-
const connInfo = getConnInfo(c);
|
|
112
280
|
requestCtxStorage.set('trace', { id: traceId });
|
|
113
281
|
requestCtxStorage.set('internal', false);
|
|
114
282
|
|
|
115
283
|
|
|
116
284
|
|
|
285
|
+
const requestCtx: Record<string, string> = c.req.header();
|
|
286
|
+
// Faille 1: never log sensitive headers (JWT, cookies)
|
|
287
|
+
delete requestCtx.authorization;
|
|
288
|
+
delete requestCtx.cookie;
|
|
117
289
|
requestCtxStorage.set('meta', {
|
|
118
290
|
request: {
|
|
119
|
-
ip:
|
|
291
|
+
ip: c.get('clientIp') ?? 'unknown',
|
|
120
292
|
user_agent: c.req.header('User-Agent') ?? '',
|
|
121
|
-
headers:
|
|
293
|
+
headers: requestCtx,
|
|
122
294
|
method: c.req.method,
|
|
123
295
|
path: c.req.path,
|
|
124
296
|
query: c.req.query(),
|
|
@@ -129,15 +301,15 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
129
301
|
})
|
|
130
302
|
const bearer = c.req.header('Authorization')?.replace('Bearer ', '');
|
|
131
303
|
if (bearer) {
|
|
132
|
-
const { value, error } = await jwt.verify(bearer);
|
|
304
|
+
const { value, error, expired } = await jwt.verify(bearer);
|
|
133
305
|
const isValid = !error && value != null;
|
|
134
|
-
const isExpired = !!error && error.toLowerCase().includes('exp');
|
|
135
306
|
|
|
136
307
|
const tokenData = {
|
|
137
|
-
value: bearer,
|
|
308
|
+
value: isValid ? bearer : null,
|
|
138
309
|
decoded: isValid ? (value as Record<string, unknown>) : null,
|
|
139
310
|
provided: true,
|
|
140
|
-
|
|
311
|
+
// jwt.verify already flags expiry (expired: true, value: null)
|
|
312
|
+
expired,
|
|
141
313
|
};
|
|
142
314
|
|
|
143
315
|
requestCtxStorage.set('token', tokenData);
|
|
@@ -163,6 +335,9 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
163
335
|
// initialize api
|
|
164
336
|
initializeApi(app);
|
|
165
337
|
|
|
338
|
+
// initialize MCP (tools served at /mcp/:tenant_id)
|
|
339
|
+
initializeMcp(app);
|
|
340
|
+
|
|
166
341
|
|
|
167
342
|
return app;
|
|
168
343
|
}
|
|
@@ -171,6 +346,5 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
171
346
|
|
|
172
347
|
|
|
173
348
|
export {
|
|
174
|
-
createApp
|
|
175
|
-
app
|
|
349
|
+
createApp
|
|
176
350
|
}
|
package/server/mcp.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import type { Hono, Context } from "hono";
|
|
2
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
|
+
import {
|
|
4
|
+
ListToolsRequestSchema,
|
|
5
|
+
CallToolRequestSchema,
|
|
6
|
+
ListResourcesRequestSchema,
|
|
7
|
+
ListResourceTemplatesRequestSchema,
|
|
8
|
+
ReadResourceRequestSchema,
|
|
9
|
+
McpError,
|
|
10
|
+
ErrorCode,
|
|
11
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
12
|
+
import { StreamableHTTPTransport } from "@hono/mcp";
|
|
13
|
+
import type Joi from "joi";
|
|
14
|
+
import { cfg } from "./config";
|
|
15
|
+
import { useRest } from "../database/rest";
|
|
16
|
+
import type { McpTool, McpResource } from "../types/mcp";
|
|
17
|
+
import type { HonoVariables } from "./env";
|
|
18
|
+
|
|
19
|
+
// ─── Joi → JSON Schema (for the MCP protocol) ────────────────────────────
|
|
20
|
+
|
|
21
|
+
function joiTypeToJson(type: string): string {
|
|
22
|
+
switch (type) {
|
|
23
|
+
case 'number': return 'number';
|
|
24
|
+
case 'integer': return 'integer';
|
|
25
|
+
case 'boolean': return 'boolean';
|
|
26
|
+
case 'date': return 'string';
|
|
27
|
+
default: return 'string';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Minimal Joi → JSON Schema converter (covers the common field types). */
|
|
32
|
+
function joiToJsonSchema(schema: Joi.Schema, describe = schema.describe() as any): any {
|
|
33
|
+
const node: any = {};
|
|
34
|
+
const type = describe?.type;
|
|
35
|
+
|
|
36
|
+
switch (type) {
|
|
37
|
+
case 'object': {
|
|
38
|
+
node.type = 'object';
|
|
39
|
+
const properties: Record<string, any> = {};
|
|
40
|
+
const required: string[] = [];
|
|
41
|
+
for (const [key, child] of Object.entries(describe.keys ?? {})) {
|
|
42
|
+
properties[key] = joiToJsonSchema(child as any, child);
|
|
43
|
+
if ((child as any)?.flags?.presence === 'required') required.push(key);
|
|
44
|
+
}
|
|
45
|
+
node.properties = properties;
|
|
46
|
+
if (required.length) node.required = required;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
case 'array': {
|
|
50
|
+
node.type = 'array';
|
|
51
|
+
if (describe.items?.length) node.items = joiToJsonSchema(describe.items[0], describe.items[0]);
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
default: {
|
|
55
|
+
node.type = joiTypeToJson(type);
|
|
56
|
+
if (type === 'date') node.format = 'date-time';
|
|
57
|
+
if (type === 'string') {
|
|
58
|
+
for (const rule of describe.rules ?? []) {
|
|
59
|
+
if (['email', 'uri', 'uuid', 'isoDate', 'ip'].includes(rule.name)) {
|
|
60
|
+
node.format = rule.name;
|
|
61
|
+
}
|
|
62
|
+
if (rule.name === 'pattern' && rule.args?.regex) {
|
|
63
|
+
node.pattern = String(rule.args.regex).replace(/^\/|\/[gimsuy]*$/g, '');
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (describe.valids?.length && describe.valids.length <= 50) {
|
|
68
|
+
node.enum = describe.valids;
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (describe?.flags?.presence === 'required') node.description = node.description ?? 'required';
|
|
75
|
+
if (describe?.flags?.description) node.description = describe.flags.description;
|
|
76
|
+
return node;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── URI template matching (users://{id} → params) ───────────────────────
|
|
80
|
+
|
|
81
|
+
function compileTemplate(template: string): { regex: RegExp; params: string[] } {
|
|
82
|
+
const params: string[] = [];
|
|
83
|
+
const pattern = template.replace(/\{([^}]+)\}/g, (_, name) => {
|
|
84
|
+
params.push(name);
|
|
85
|
+
return '([^/]+)';
|
|
86
|
+
});
|
|
87
|
+
return { regex: new RegExp(`^${pattern}$`), params };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function matchUri(uri: string, resources: McpResource[]): { resource: McpResource; params: Record<string, string> } | null {
|
|
91
|
+
// Exact static match first
|
|
92
|
+
const staticMatch = resources.find((r) => r.uri === uri);
|
|
93
|
+
if (staticMatch) return { resource: staticMatch, params: {} };
|
|
94
|
+
|
|
95
|
+
// Template match
|
|
96
|
+
for (const resource of resources) {
|
|
97
|
+
if (!resource.uri.includes('{')) continue;
|
|
98
|
+
const { regex, params } = compileTemplate(resource.uri);
|
|
99
|
+
const m = uri.match(regex);
|
|
100
|
+
if (!m) continue;
|
|
101
|
+
const extracted: Record<string, string> = {};
|
|
102
|
+
params.forEach((name, i) => { extracted[name] = m[i + 1] ?? ''; });
|
|
103
|
+
return { resource, params: extracted };
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── MCP server (one fresh instance per request — stateless) ─────────────
|
|
109
|
+
|
|
110
|
+
function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[]): Server {
|
|
111
|
+
const server = new Server(
|
|
112
|
+
{ name: 'anteros-mcp', version: cfg.version ?? '1.0.0' },
|
|
113
|
+
{ capabilities: { tools: {}, resources: {} } }
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
117
|
+
tools: tools.map((t) => ({
|
|
118
|
+
name: t.name,
|
|
119
|
+
title: t.name,
|
|
120
|
+
description: t.description,
|
|
121
|
+
inputSchema: joiToJsonSchema(t.inputSchema),
|
|
122
|
+
})),
|
|
123
|
+
}));
|
|
124
|
+
|
|
125
|
+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
126
|
+
const tool = tools.find((t) => t.name === req.params.name);
|
|
127
|
+
if (!tool) {
|
|
128
|
+
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${req.params.name}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const raw = req.params.arguments ?? {};
|
|
132
|
+
const { error, value } = tool.inputSchema.validate(raw, { allowUnknown: false });
|
|
133
|
+
if (error) {
|
|
134
|
+
return {
|
|
135
|
+
content: [{ type: 'text' as const, text: `Invalid arguments: ${error.message}` }],
|
|
136
|
+
isError: true,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const rest = new useRest({ tenant_id: tenantId });
|
|
141
|
+
try {
|
|
142
|
+
const result = await tool.exec({ c: undefined as any, rest, args: value });
|
|
143
|
+
if (result && Array.isArray(result.content)) return result;
|
|
144
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
|
145
|
+
return { content: [{ type: 'text' as const, text }] };
|
|
146
|
+
} catch (err: any) {
|
|
147
|
+
return {
|
|
148
|
+
content: [{ type: 'text' as const, text: `Error: ${err?.message ?? 'unknown'}` }],
|
|
149
|
+
isError: true,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => ({
|
|
155
|
+
resources: resources
|
|
156
|
+
.filter((r) => !r.uri.includes('{')) // static only; templates are listed separately
|
|
157
|
+
.map((r) => ({
|
|
158
|
+
uri: r.uri,
|
|
159
|
+
name: r.name,
|
|
160
|
+
description: r.description,
|
|
161
|
+
mimeType: r.mimeType,
|
|
162
|
+
})),
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
|
|
166
|
+
resourceTemplates: resources
|
|
167
|
+
.filter((r) => r.uri.includes('{'))
|
|
168
|
+
.map((r) => ({
|
|
169
|
+
uriTemplate: r.uri,
|
|
170
|
+
name: r.name,
|
|
171
|
+
description: r.description,
|
|
172
|
+
mimeType: r.mimeType,
|
|
173
|
+
})),
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
177
|
+
const uri = req.params.uri;
|
|
178
|
+
const matched = matchUri(uri, resources);
|
|
179
|
+
if (!matched) {
|
|
180
|
+
throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const rest = new useRest({ tenant_id: tenantId });
|
|
184
|
+
try {
|
|
185
|
+
const result = await matched.resource.read({
|
|
186
|
+
c: undefined as any,
|
|
187
|
+
rest,
|
|
188
|
+
params: matched.params,
|
|
189
|
+
uri,
|
|
190
|
+
});
|
|
191
|
+
return result;
|
|
192
|
+
} catch (err: any) {
|
|
193
|
+
throw new McpError(ErrorCode.InternalError, `Failed to read resource: ${err?.message ?? 'unknown'}`);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
return server;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Expose each tenant's MCP tools & resources over the Model Context Protocol
|
|
202
|
+
* at `GET/POST /mcp/:tenant_id` (Streamable HTTP transport — works with
|
|
203
|
+
* Claude, Cursor, VS Code, and any MCP client).
|
|
204
|
+
*/
|
|
205
|
+
export function initializeMcp(app: Hono<{ Variables: HonoVariables }>) {
|
|
206
|
+
const toolsByTenant = new Map<string, McpTool[]>();
|
|
207
|
+
for (const tool of cfg.mcpTools ?? []) {
|
|
208
|
+
const list = toolsByTenant.get(tool._tenant_ ?? '') ?? [];
|
|
209
|
+
list.push(tool);
|
|
210
|
+
toolsByTenant.set(tool._tenant_ ?? '', list);
|
|
211
|
+
}
|
|
212
|
+
const resourcesByTenant = new Map<string, McpResource[]>();
|
|
213
|
+
for (const resource of cfg.mcpResources ?? []) {
|
|
214
|
+
const list = resourcesByTenant.get(resource._tenant_ ?? '') ?? [];
|
|
215
|
+
list.push(resource);
|
|
216
|
+
resourcesByTenant.set(resource._tenant_ ?? '', list);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
app.all('/mcp/:tenant_id', async (c: Context<{ Variables: HonoVariables }>) => {
|
|
220
|
+
const tenantId = c.req.param('tenant_id') ?? '';
|
|
221
|
+
const tools = toolsByTenant.get(tenantId) ?? [];
|
|
222
|
+
const resources = resourcesByTenant.get(tenantId) ?? [];
|
|
223
|
+
if (!tools.length && !resources.length) {
|
|
224
|
+
return c.json({ error: `No MCP tools or resources for tenant '${tenantId}'` }, 404);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const server = buildServer(tenantId, tools, resources);
|
|
228
|
+
const transport = new StreamableHTTPTransport();
|
|
229
|
+
await server.connect(transport);
|
|
230
|
+
return transport.handleRequest(c);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
|
|
4
|
+
// ─── Zero-dependency runtime metrics (memory, CPU, requests) ─────────────────
|
|
5
|
+
|
|
6
|
+
export type MetricsSnapshot = {
|
|
7
|
+
pid: number;
|
|
8
|
+
startedAt: number;
|
|
9
|
+
uptime: number;
|
|
10
|
+
env: string;
|
|
11
|
+
connections: number;
|
|
12
|
+
pendingRequests: number;
|
|
13
|
+
pendingWebSockets: number;
|
|
14
|
+
requests: {
|
|
15
|
+
total: number;
|
|
16
|
+
active: number;
|
|
17
|
+
errors: number;
|
|
18
|
+
byMethod: Record<string, number>;
|
|
19
|
+
byStatus: Record<string, number>;
|
|
20
|
+
};
|
|
21
|
+
memory: {
|
|
22
|
+
rss: number;
|
|
23
|
+
heapUsed: number;
|
|
24
|
+
heapTotal: number;
|
|
25
|
+
external: number;
|
|
26
|
+
};
|
|
27
|
+
cpu: {
|
|
28
|
+
/** % of a single core over the last sample window (0..100*cores) */
|
|
29
|
+
percent: number;
|
|
30
|
+
cores: number;
|
|
31
|
+
user: number;
|
|
32
|
+
system: number;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let serverRef: Server<any> | null = null;
|
|
37
|
+
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
let totalRequests = 0;
|
|
40
|
+
let totalErrors = 0;
|
|
41
|
+
const byMethod = new Map<string, number>();
|
|
42
|
+
const byStatus = new Map<string, number>();
|
|
43
|
+
|
|
44
|
+
let lastCpu = process.cpuUsage();
|
|
45
|
+
let lastCpuAt = Date.now();
|
|
46
|
+
|
|
47
|
+
/** Attach the running Bun server (used for connection / pending request counts). */
|
|
48
|
+
export function registerMetrics(server: Server<any>) {
|
|
49
|
+
serverRef = server;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Count a finished request (call once the response is resolved). */
|
|
53
|
+
export function trackRequest(req: Request, res: Response) {
|
|
54
|
+
totalRequests++;
|
|
55
|
+
const method = req.method || 'UNKNOWN';
|
|
56
|
+
byMethod.set(method, (byMethod.get(method) ?? 0) + 1);
|
|
57
|
+
const status = String(res.status);
|
|
58
|
+
byStatus.set(status, (byStatus.get(status) ?? 0) + 1);
|
|
59
|
+
if (res.status >= 500) totalErrors++;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Count an error that did not produce a response. */
|
|
63
|
+
export function trackError() {
|
|
64
|
+
totalErrors++;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Build a serializable metrics snapshot for the current process. */
|
|
68
|
+
export function getMetrics(): MetricsSnapshot {
|
|
69
|
+
const mem = process.memoryUsage();
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const cpu = process.cpuUsage();
|
|
72
|
+
|
|
73
|
+
const user = cpu.user - lastCpu.user;
|
|
74
|
+
const system = cpu.system - lastCpu.system;
|
|
75
|
+
const elapsedMs = Math.max(1, now - lastCpuAt);
|
|
76
|
+
const cores = Math.max(1, os.availableParallelism());
|
|
77
|
+
lastCpu = cpu;
|
|
78
|
+
lastCpuAt = now;
|
|
79
|
+
|
|
80
|
+
const cpuPercent = Math.min(100 * cores, ((user + system) / 1000 / elapsedMs) * 100);
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
pid: process.pid,
|
|
84
|
+
startedAt,
|
|
85
|
+
uptime: Math.floor((now - startedAt) / 1000),
|
|
86
|
+
env: process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev',
|
|
87
|
+
connections: serverRef?.pendingWebSockets ?? 0,
|
|
88
|
+
pendingRequests: serverRef?.pendingRequests ?? 0,
|
|
89
|
+
pendingWebSockets: serverRef?.pendingWebSockets ?? 0,
|
|
90
|
+
requests: {
|
|
91
|
+
total: totalRequests,
|
|
92
|
+
active: serverRef?.pendingRequests ?? 0,
|
|
93
|
+
errors: totalErrors,
|
|
94
|
+
byMethod: Object.fromEntries(byMethod),
|
|
95
|
+
byStatus: Object.fromEntries(byStatus),
|
|
96
|
+
},
|
|
97
|
+
memory: {
|
|
98
|
+
rss: mem.rss,
|
|
99
|
+
heapUsed: mem.heapUsed,
|
|
100
|
+
heapTotal: mem.heapTotal,
|
|
101
|
+
external: mem.external,
|
|
102
|
+
},
|
|
103
|
+
cpu: {
|
|
104
|
+
percent: Math.round(cpuPercent * 10) / 10,
|
|
105
|
+
cores,
|
|
106
|
+
user,
|
|
107
|
+
system,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
package/server/security.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
2
|
import { getConnInfo } from "hono/bun";
|
|
3
|
+
import type { ConnInfo } from "hono/conninfo";
|
|
3
4
|
|
|
4
5
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
5
6
|
|
|
@@ -96,15 +97,18 @@ export function createRedisStore(redis: RedisClient): RateLimitStore {
|
|
|
96
97
|
// ─── Default key generator ───────────────────────────────────────────────────
|
|
97
98
|
|
|
98
99
|
function defaultKeyGenerator(c: Context): string {
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
100
|
+
// Prefer the client IP resolved once by the app middleware (trustProxy-aware).
|
|
101
|
+
const clientIp = (c as any).get('clientIp') as string | undefined;
|
|
102
|
+
if (clientIp) return `rn:rl:${clientIp}`;
|
|
103
|
+
|
|
104
|
+
// Fallback: real socket address only (no spoofable proxy headers)
|
|
105
|
+
let connInfo: ConnInfo | null = null;
|
|
106
|
+
try {
|
|
107
|
+
connInfo = getConnInfo(c);
|
|
108
|
+
} catch {
|
|
109
|
+
// getConnInfo throws outside a real Bun.serve (e.g. unit tests)
|
|
110
|
+
}
|
|
111
|
+
return `rn:rl:${connInfo?.remote.address ?? 'unknown'}`;
|
|
108
112
|
}
|
|
109
113
|
|
|
110
114
|
// ─── Middleware ──────────────────────────────────────────────────────────────
|