@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.
Files changed (46) hide show
  1. package/README.md +90 -5
  2. package/database/decorator.ts +1 -1
  3. package/database/mongodbadapter.ts +64 -40
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +115 -32
  7. package/lib/mcp.ts +59 -0
  8. package/lib/scriptWorker.ts +76 -0
  9. package/lib/scripts.ts +77 -12
  10. package/package.json +6 -3
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +35 -28
  13. package/server/boot.ts +78 -16
  14. package/server/cluster.ts +177 -0
  15. package/server/config.ts +0 -1
  16. package/server/env.ts +6 -0
  17. package/server/hono.ts +192 -18
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/api.test.ts +14 -12
  22. package/tests/cache.test.ts +124 -0
  23. package/tests/crypto.test.ts +101 -0
  24. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  25. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  27. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  29. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  30. package/tests/fixtures/metrics-worker.ts +21 -0
  31. package/tests/hono.test.ts +211 -0
  32. package/tests/mcp.test.ts +186 -0
  33. package/tests/metrics-cluster.test.ts +103 -0
  34. package/tests/scripts-heavy.test.ts +49 -0
  35. package/tests/security.test.ts +102 -0
  36. package/tests/ts1011-test.ts +15 -0
  37. package/tests/utils.test.ts +113 -0
  38. package/types/api.d.ts +11 -0
  39. package/types/collection.d.ts +20 -5
  40. package/types/config.d.ts +20 -3
  41. package/types/hook.d.ts +16 -13
  42. package/types/mcp.d.ts +81 -0
  43. package/types/scripts.d.ts +14 -1
  44. package/utils/cache.ts +20 -1
  45. package/utils/crypto.ts +25 -8
  46. 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 { sessionCtxStorage, asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
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
- const app = new Hono<{ Variables: HonoVariables }>();
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 corsOrigin = cfg.server.cors?.origin as string[] | undefined;
48
- const corsCredentials = cfg.server.cors?.credentials as boolean | undefined ?? false;
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 && (!corsOrigin || corsOrigin.includes('*'))) {
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(getConnInfo, {
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: connInfo.remote.address ?? c.req.header('CF-Connecting-IP') ?? c.req.header('True-Client-IP') ?? c.req.header('X-Forwarded-For') ?? 'unknown',
291
+ ip: c.get('clientIp') ?? 'unknown',
120
292
  user_agent: c.req.header('User-Agent') ?? '',
121
- headers: c.req.header(),
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
- expired: isExpired || (value != null && typeof (value as any)?.exp === 'number' && (value as any).exp * 1000 < Date.now()),
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
+ }
@@ -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
- const connInfo = getConnInfo(c);
100
- const ip =
101
- connInfo.remote.address ??
102
- c.req.header("CF-Connecting-IP") ??
103
- c.req.header("True-Client-IP") ??
104
- c.req.header("X-Forwarded-For") ??
105
- "unknown";
106
-
107
- return `rn:rl:${ip}`;
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 ──────────────────────────────────────────────────────────────