@anteros/core 0.0.24 → 0.0.26
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/database/db.cache.ts +197 -0
- package/database/decorator.ts +32 -0
- package/database/mongodbadapter.ts +244 -32
- package/index.ts +2 -0
- package/package.json +4 -2
- package/server/api.ts +1 -1
- package/server/boot.ts +106 -17
- package/server/hono.ts +63 -0
- package/server/mcp.ts +34 -12
- package/server/routes.ts +10 -0
- package/tests/api.test.ts +217 -3
- package/tests/db-cache.test.ts +214 -0
- package/tests/logger.test.ts +123 -0
- package/types/collection.d.ts +6 -0
- package/types/config.d.ts +37 -0
- package/types/mongo.d.ts +10 -0
- package/types/rest.d.ts +37 -0
- package/utils/cache.ts +2 -2
- package/utils/func.ts +3 -0
- package/utils/logger.ts +212 -0
package/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import * as crypto from "./utils/crypto";
|
|
|
13
13
|
|
|
14
14
|
// BentoCache-based caching (memory L1 + filesystem/Redis L2)
|
|
15
15
|
import { useMemoryCache, useFilesystemCache, useRedisCache } from "./utils/cache";
|
|
16
|
+
import { logger } from "./utils/logger";
|
|
16
17
|
const cache = {
|
|
17
18
|
useMemoryCache,
|
|
18
19
|
useFilesystemCache,
|
|
@@ -31,4 +32,5 @@ export {
|
|
|
31
32
|
utils,
|
|
32
33
|
cache,
|
|
33
34
|
crypto,
|
|
35
|
+
logger,
|
|
34
36
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anteros/core",
|
|
3
3
|
"module": "index.ts",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.26",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Anteros Core",
|
|
7
7
|
"private": false,
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"bun": ">=1.3.0"
|
|
11
11
|
},
|
|
12
12
|
"devDependencies": {
|
|
13
|
-
"@types/bun": "^1.3.14"
|
|
13
|
+
"@types/bun": "^1.3.14",
|
|
14
|
+
"@types/tcp-port-used": "^1.0.4"
|
|
14
15
|
},
|
|
15
16
|
"peerDependencies": {
|
|
16
17
|
"typescript": "6.0.3"
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"jose": "6.2.8",
|
|
30
31
|
"mongodb": "7.5.0",
|
|
31
32
|
"socket.io": "4.8.3",
|
|
33
|
+
"tcp-port-used": "^1.0.3",
|
|
32
34
|
"ufo": "1.6.4",
|
|
33
35
|
"zod": "^4.4.3"
|
|
34
36
|
}
|
package/server/api.ts
CHANGED
|
@@ -174,7 +174,7 @@ type CrudHandler = (rest: InstanceType<typeof useRest>, collection: string, body
|
|
|
174
174
|
|
|
175
175
|
const crudHandlers: Record<string, CrudHandler> = {
|
|
176
176
|
aggregate: (rest, collection, body) => rest.aggregate(collection, body?.pipeline || []),
|
|
177
|
-
find: (rest, collection, body) => rest.find(collection, body?.params || {}),
|
|
177
|
+
find: (rest, collection, body) => rest.find(collection, body?.params || {}, { useCache: body?.options?.useCache }),
|
|
178
178
|
findOne: (rest, collection, body) => rest.findOne(collection, body?.id, body?.params || {}),
|
|
179
179
|
insertOne: (rest, collection, body) => rest.insertOne(collection, body?.data),
|
|
180
180
|
insertMany: (rest, collection, body) => rest.insertMany(collection, body?.data),
|
package/server/boot.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
|
|
2
|
-
import { createApp } from './hono'
|
|
2
|
+
import { createApp, collectHeaders } from './hono'
|
|
3
3
|
import type { ServerConfig } from '../types/config'
|
|
4
4
|
import dayjs from 'dayjs';
|
|
5
5
|
import pkg from '../package.json';
|
|
@@ -22,18 +22,8 @@ import crypto from 'node:crypto';
|
|
|
22
22
|
import os from 'node:os';
|
|
23
23
|
import { registerMetrics, getMetrics, trackRequest } from './metrics';
|
|
24
24
|
import { startMaster, aggregateMetrics } from './cluster';
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
async function isPortInUse(port: number): Promise<boolean> {
|
|
28
|
-
try {
|
|
29
|
-
const proc = Bun.spawn(['lsof', '-ti', `:${port}`], { stdout: 'pipe' });
|
|
30
|
-
const output = await new Response(proc.stdout).text();
|
|
31
|
-
await proc.exited;
|
|
32
|
-
return output.trim().length > 0;
|
|
33
|
-
} catch {
|
|
34
|
-
return false;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
25
|
+
import tcpPortUsed from 'tcp-port-used';
|
|
26
|
+
import { logger } from '../utils/logger';
|
|
37
27
|
|
|
38
28
|
type BootAppOptions = ServerConfig & {
|
|
39
29
|
|
|
@@ -45,6 +35,15 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
45
35
|
// Load required resources
|
|
46
36
|
//******************************* */
|
|
47
37
|
options = formatConfig(options);
|
|
38
|
+
|
|
39
|
+
// Logging: configure from cfg.server.logging (console + file, e.g. logs/anteros.log)
|
|
40
|
+
logger.configure(cfg.server.logging);
|
|
41
|
+
logger.info('Starting Anteros server', {
|
|
42
|
+
name: cfg.server.name ?? process.env.APP_NAME ?? 'SERVER',
|
|
43
|
+
port: cfg.server.port ?? 4000,
|
|
44
|
+
env: process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev',
|
|
45
|
+
tenants: cfg.tenants?.map((t) => t.id),
|
|
46
|
+
});
|
|
48
47
|
await syncTenants(); // sync tenants and connect to database
|
|
49
48
|
await syncCollections(); // sync collections and create collections on database
|
|
50
49
|
await syncFileCollections(); // sync file collections
|
|
@@ -117,14 +116,38 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
117
116
|
|
|
118
117
|
console.log(`[cluster] Aggregated /health → http://localhost:${metricsPort}/health`.gray);
|
|
119
118
|
|
|
119
|
+
// Graceful shutdown: stop workers + metrics server so a restart
|
|
120
|
+
// (e.g. bun --watch) never leaves the port occupied
|
|
121
|
+
const shutdownCluster = (signal: string) => {
|
|
122
|
+
console.log(`\n${signal} received — shutting down workers…`.gray);
|
|
123
|
+
try { master.shutdown(); } catch {}
|
|
124
|
+
try { metricsServer.stop(true); } catch {}
|
|
125
|
+
for (const tenant of cfg.tenants ?? []) {
|
|
126
|
+
try { tenant.database?.client?.close(); } catch {}
|
|
127
|
+
}
|
|
128
|
+
process.exit(0);
|
|
129
|
+
};
|
|
130
|
+
process.once('SIGINT', () => shutdownCluster('SIGINT'));
|
|
131
|
+
process.once('SIGTERM', () => shutdownCluster('SIGTERM'));
|
|
132
|
+
|
|
120
133
|
// Keep the master alive
|
|
121
134
|
return { master, metricsServer } as any;
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
// Detect zombie: check if port is already in use when NOT sharing the port
|
|
137
|
+
// Detect zombie: check if port is already in use when NOT sharing the port.
|
|
138
|
+
// tcp-port-used probes with a real TCP connection — only a listening server
|
|
139
|
+
// counts (a connected client never triggers a false positive) and it works
|
|
140
|
+
// on any platform (no lsof dependency). Grace period: after a restart the
|
|
141
|
+
// previous process may still be releasing the port (Mongo / Socket.IO
|
|
142
|
+
// shutdown) — wait before declaring a zombie.
|
|
125
143
|
if (!reusePort && env === 'dev') {
|
|
126
|
-
|
|
127
|
-
|
|
144
|
+
if (await tcpPortUsed.check(PORT)) {
|
|
145
|
+
console.log(`⏳ Port ${PORT} is still held, waiting for the previous process to release it (~2.5s)…`.gray);
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
// ~2.5s grace (5 × 500ms); rejects if the port stays occupied
|
|
149
|
+
await tcpPortUsed.waitUntilFree(PORT, 500, 2500);
|
|
150
|
+
} catch {
|
|
128
151
|
console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n`.red.bold);
|
|
129
152
|
process.exit(1);
|
|
130
153
|
}
|
|
@@ -147,7 +170,39 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
147
170
|
});
|
|
148
171
|
|
|
149
172
|
if (url.pathname === "/socket.io/") {
|
|
150
|
-
|
|
173
|
+
const start = performance.now();
|
|
174
|
+
return respond(engineIo.handleRequest(req, server)).then((res) => {
|
|
175
|
+
// Access log for the Socket.IO upgrade path (handled outside Hono) —
|
|
176
|
+
// same Caddy-compatible structure as the API access log
|
|
177
|
+
const remote: any = (server as any).requestIP?.(req);
|
|
178
|
+
const remoteIp = remote?.address ?? null;
|
|
179
|
+
const remotePort = remote?.port ?? null;
|
|
180
|
+
logger.info(`${req.method} ${url.pathname}${url.search} → ${res.status}`, {
|
|
181
|
+
logger: 'http.log.access',
|
|
182
|
+
status: res.status,
|
|
183
|
+
duration: Math.round((performance.now() - start) * 10) / 10,
|
|
184
|
+
method: req.method,
|
|
185
|
+
uri: url.pathname + url.search,
|
|
186
|
+
remote_ip: remoteIp,
|
|
187
|
+
bytes_read: Number(req.headers.get('content-length')) || 0,
|
|
188
|
+
size: Number(res.headers.get('content-length')) || 0,
|
|
189
|
+
user_id: '',
|
|
190
|
+
ip: remoteIp ?? 'unknown',
|
|
191
|
+
traceId: null,
|
|
192
|
+
tenant: null,
|
|
193
|
+
request: {
|
|
194
|
+
headers: collectHeaders(req.headers),
|
|
195
|
+
client_ip: remoteIp ?? 'unknown',
|
|
196
|
+
host: req.headers.get('host') ?? '',
|
|
197
|
+
method: req.method,
|
|
198
|
+
remote_ip: remoteIp,
|
|
199
|
+
remote_port: remotePort,
|
|
200
|
+
uri: url.pathname + url.search,
|
|
201
|
+
},
|
|
202
|
+
resp_headers: collectHeaders(res.headers),
|
|
203
|
+
});
|
|
204
|
+
return res;
|
|
205
|
+
});
|
|
151
206
|
}
|
|
152
207
|
return respond(app.fetch(req, server));
|
|
153
208
|
},
|
|
@@ -158,6 +213,19 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
158
213
|
// Register server for built-in metrics (GET /health)
|
|
159
214
|
registerMetrics(server);
|
|
160
215
|
|
|
216
|
+
// Graceful shutdown: release the port & DB connections so restarts
|
|
217
|
+
// (e.g. bun --watch) never hit a zombie / EADDRINUSE
|
|
218
|
+
const shutdownServer = (signal: string) => {
|
|
219
|
+
console.log(`\n${signal} received — shutting down…`.gray);
|
|
220
|
+
try { server.stop(true); } catch {}
|
|
221
|
+
for (const tenant of cfg.tenants ?? []) {
|
|
222
|
+
try { tenant.database?.client?.close(); } catch {}
|
|
223
|
+
}
|
|
224
|
+
process.exit(0);
|
|
225
|
+
};
|
|
226
|
+
process.once('SIGINT', () => shutdownServer('SIGINT'));
|
|
227
|
+
process.once('SIGTERM', () => shutdownServer('SIGTERM'));
|
|
228
|
+
|
|
161
229
|
// ── WORKER (cluster): report metrics to the master via IPC ──
|
|
162
230
|
if (isWorker && typeof process.send === 'function') {
|
|
163
231
|
setInterval(() => {
|
|
@@ -198,8 +266,29 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
198
266
|
|
|
199
267
|
}, 150);
|
|
200
268
|
|
|
269
|
+
// Boot event — recorded in the log file only (the console banner already
|
|
270
|
+
// shows name / PID / role / URL, so this line would be redundant on screen)
|
|
271
|
+
logger.file('Server started', {
|
|
272
|
+
name: NAME,
|
|
273
|
+
pid: process.pid,
|
|
274
|
+
role,
|
|
275
|
+
url: `http://localhost:${PORT}`,
|
|
276
|
+
env: env || 'dev',
|
|
277
|
+
logFile: logger.filePath || undefined,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Uncaught errors → log file (MongoDB-style), then exit
|
|
281
|
+
process.on('uncaughtException', (err) => {
|
|
282
|
+
logger.error('Uncaught exception', { message: err?.message, stack: err?.stack });
|
|
283
|
+
process.exit(1);
|
|
284
|
+
});
|
|
285
|
+
process.on('unhandledRejection', (reason: any) => {
|
|
286
|
+
logger.error('Unhandled rejection', { message: reason?.message ?? String(reason), stack: reason?.stack });
|
|
287
|
+
});
|
|
288
|
+
|
|
201
289
|
return server;
|
|
202
290
|
} catch (err: any) {
|
|
291
|
+
logger.error('Failed to boot server', cfg.debug ? { error: err, message: err?.message } : { message: err?.message });
|
|
203
292
|
console.error('Failed to boot server', cfg.debug ? err : err?.message);
|
|
204
293
|
process.exit(1);
|
|
205
294
|
}
|
package/server/hono.ts
CHANGED
|
@@ -18,8 +18,24 @@ import { getGlobalMiddlewares } from "../lib/middleware";
|
|
|
18
18
|
import { jwt } from "../utils/func";
|
|
19
19
|
import { AppError } from "../lib/error";
|
|
20
20
|
import { getMetrics } from "./metrics";
|
|
21
|
+
import { logger } from "../utils/logger";
|
|
21
22
|
import type { HonoVariables } from "./env";
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Caddy-style header collection (array values); sensitive headers stripped
|
|
26
|
+
* (`authorization`, `cookie`, `proxy-authorization`, `set-cookie`).
|
|
27
|
+
*/
|
|
28
|
+
export function collectHeaders(h: Headers): Record<string, string[]> {
|
|
29
|
+
const EXCLUDE = ['authorization', 'cookie', 'proxy-authorization', 'set-cookie'];
|
|
30
|
+
const out: Record<string, string[]> = {};
|
|
31
|
+
for (const [name, value] of h.entries()) {
|
|
32
|
+
const key = name.toLowerCase();
|
|
33
|
+
if (EXCLUDE.includes(key)) continue;
|
|
34
|
+
out[key] = [value];
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
23
39
|
// ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
|
|
24
40
|
// Lazy singleton: created once per process and reused across createApp() calls.
|
|
25
41
|
// Each increment falls back to the in-memory store if Redis is unavailable,
|
|
@@ -200,6 +216,51 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
200
216
|
await next();
|
|
201
217
|
});
|
|
202
218
|
|
|
219
|
+
// Access log — registered before /health & routes so EVERY request is logged.
|
|
220
|
+
// Runs around the async-context middleware, so traceId/tenant are readable here.
|
|
221
|
+
// Caddy-compatible JSONL payload: `request`/`resp_headers` blocks + flat fields.
|
|
222
|
+
app.use(async (c, next) => {
|
|
223
|
+
const start = performance.now();
|
|
224
|
+
try {
|
|
225
|
+
await next();
|
|
226
|
+
} finally {
|
|
227
|
+
const duration = performance.now() - start;
|
|
228
|
+
const trace = requestCtxStorage.get<{ id: string }>('trace');
|
|
229
|
+
const conn = c.get('connInfo');
|
|
230
|
+
const token = c.get('token');
|
|
231
|
+
const url = new URL(c.req.raw.url);
|
|
232
|
+
const uri = url.pathname + url.search;
|
|
233
|
+
const ip = c.get('clientIp') ?? 'unknown';
|
|
234
|
+
const remoteIp = conn?.remote?.address ?? null;
|
|
235
|
+
const remotePort = conn?.remote?.port ?? null;
|
|
236
|
+
|
|
237
|
+
logger.info(`${c.req.method} ${uri} → ${c.res.status}`, {
|
|
238
|
+
logger: 'http.log.access',
|
|
239
|
+
status: c.res.status,
|
|
240
|
+
duration: Math.round(duration * 10) / 10,
|
|
241
|
+
method: c.req.method,
|
|
242
|
+
uri,
|
|
243
|
+
remote_ip: remoteIp,
|
|
244
|
+
bytes_read: Number(c.req.header('content-length')) || 0,
|
|
245
|
+
size: Number(c.res.headers.get('content-length')) || 0,
|
|
246
|
+
user_id: (token?.decoded as Record<string, unknown> | null)?.['sub'] as string ?? '',
|
|
247
|
+
ip,
|
|
248
|
+
traceId: trace?.id ?? crypto.randomUUID(),
|
|
249
|
+
tenant: requestCtxStorage.get<string>('tenant_id') ?? null,
|
|
250
|
+
request: {
|
|
251
|
+
headers: collectHeaders(c.req.raw.headers),
|
|
252
|
+
client_ip: ip,
|
|
253
|
+
host: c.req.header('host') ?? '',
|
|
254
|
+
method: c.req.method,
|
|
255
|
+
remote_ip: remoteIp,
|
|
256
|
+
remote_port: remotePort,
|
|
257
|
+
uri,
|
|
258
|
+
},
|
|
259
|
+
resp_headers: collectHeaders(c.res.headers),
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
203
264
|
// Reuse the cached connInfo instead of re-querying the socket per middleware
|
|
204
265
|
const cachedGetConnInfo: GetConnInfo = (c) =>
|
|
205
266
|
(c as Context<{ Variables: HonoVariables }>).get('connInfo') ?? safeGetConnInfo(c);
|
|
@@ -320,6 +381,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
320
381
|
c.set('token', emptyToken);
|
|
321
382
|
}
|
|
322
383
|
|
|
384
|
+
// (access log is a dedicated middleware registered earlier — see top of createApp)
|
|
385
|
+
|
|
323
386
|
return await next();
|
|
324
387
|
})
|
|
325
388
|
})
|
package/server/mcp.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { StreamableHTTPTransport } from "@hono/mcp";
|
|
|
13
13
|
import type Joi from "joi";
|
|
14
14
|
import { cfg } from "./config";
|
|
15
15
|
import { useRest } from "../database/rest";
|
|
16
|
+
import { logger } from "../utils/logger";
|
|
16
17
|
import type { McpTool, McpResource } from "../types/mcp";
|
|
17
18
|
import type { HonoVariables } from "./env";
|
|
18
19
|
|
|
@@ -113,24 +114,32 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
113
114
|
{ capabilities: { tools: {}, resources: {} } }
|
|
114
115
|
);
|
|
115
116
|
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
const ms = (start: number) => Math.round((performance.now() - start) * 10) / 10;
|
|
118
|
+
|
|
119
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
120
|
+
const start = performance.now();
|
|
121
|
+
const toolsList = tools.map((t) => ({
|
|
118
122
|
name: t.name,
|
|
119
123
|
title: t.name,
|
|
120
124
|
description: t.description,
|
|
121
125
|
inputSchema: joiToJsonSchema(t.inputSchema),
|
|
122
|
-
}))
|
|
123
|
-
|
|
126
|
+
}));
|
|
127
|
+
logger.info('MCP tools/list', { method: 'tools/list', tenant: tenantId, count: toolsList.length, duration: ms(start) });
|
|
128
|
+
return { tools: toolsList };
|
|
129
|
+
});
|
|
124
130
|
|
|
125
131
|
server.setRequestHandler(CallToolRequestSchema, async (req) => {
|
|
132
|
+
const start = performance.now();
|
|
126
133
|
const tool = tools.find((t) => t.name === req.params.name);
|
|
127
134
|
if (!tool) {
|
|
135
|
+
logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, error: 'unknown tool', duration: ms(start) });
|
|
128
136
|
throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${req.params.name}`);
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
const raw = req.params.arguments ?? {};
|
|
132
140
|
const { error, value } = tool.inputSchema.validate(raw, { allowUnknown: false });
|
|
133
141
|
if (error) {
|
|
142
|
+
logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: raw, error: 'invalid arguments', duration: ms(start) });
|
|
134
143
|
return {
|
|
135
144
|
content: [{ type: 'text' as const, text: `Invalid arguments: ${error.message}` }],
|
|
136
145
|
isError: true,
|
|
@@ -140,10 +149,13 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
140
149
|
const rest = new useRest({ tenant_id: tenantId });
|
|
141
150
|
try {
|
|
142
151
|
const result = await tool.exec({ c: undefined as any, rest, args: value });
|
|
152
|
+
const isError = !!(result as any)?.isError;
|
|
153
|
+
logger[isError ? 'warn' : 'info']('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, isError, duration: ms(start) });
|
|
143
154
|
if (result && Array.isArray(result.content)) return result;
|
|
144
155
|
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
|
145
156
|
return { content: [{ type: 'text' as const, text }] };
|
|
146
157
|
} catch (err: any) {
|
|
158
|
+
logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, error: err?.message ?? 'unknown', duration: ms(start) });
|
|
147
159
|
return {
|
|
148
160
|
content: [{ type: 'text' as const, text: `Error: ${err?.message ?? 'unknown'}` }],
|
|
149
161
|
isError: true,
|
|
@@ -151,32 +163,40 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
151
163
|
}
|
|
152
164
|
});
|
|
153
165
|
|
|
154
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () =>
|
|
155
|
-
|
|
166
|
+
server.setRequestHandler(ListResourcesRequestSchema, async () => {
|
|
167
|
+
const start = performance.now();
|
|
168
|
+
const resourcesList = resources
|
|
156
169
|
.filter((r) => !r.uri.includes('{')) // static only; templates are listed separately
|
|
157
170
|
.map((r) => ({
|
|
158
171
|
uri: r.uri,
|
|
159
172
|
name: r.name,
|
|
160
173
|
description: r.description,
|
|
161
174
|
mimeType: r.mimeType,
|
|
162
|
-
}))
|
|
163
|
-
|
|
175
|
+
}));
|
|
176
|
+
logger.info('MCP resources/list', { method: 'resources/list', tenant: tenantId, count: resourcesList.length, duration: ms(start) });
|
|
177
|
+
return { resources: resourcesList };
|
|
178
|
+
});
|
|
164
179
|
|
|
165
|
-
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () =>
|
|
166
|
-
|
|
180
|
+
server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
|
|
181
|
+
const start = performance.now();
|
|
182
|
+
const templates = resources
|
|
167
183
|
.filter((r) => r.uri.includes('{'))
|
|
168
184
|
.map((r) => ({
|
|
169
185
|
uriTemplate: r.uri,
|
|
170
186
|
name: r.name,
|
|
171
187
|
description: r.description,
|
|
172
188
|
mimeType: r.mimeType,
|
|
173
|
-
}))
|
|
174
|
-
|
|
189
|
+
}));
|
|
190
|
+
logger.info('MCP resources/templates/list', { method: 'resources/templates/list', tenant: tenantId, count: templates.length, duration: ms(start) });
|
|
191
|
+
return { resourceTemplates: templates };
|
|
192
|
+
});
|
|
175
193
|
|
|
176
194
|
server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
|
|
195
|
+
const start = performance.now();
|
|
177
196
|
const uri = req.params.uri;
|
|
178
197
|
const matched = matchUri(uri, resources);
|
|
179
198
|
if (!matched) {
|
|
199
|
+
logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: 'unknown uri', duration: ms(start) });
|
|
180
200
|
throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
|
|
181
201
|
}
|
|
182
202
|
|
|
@@ -188,8 +208,10 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
|
|
|
188
208
|
params: matched.params,
|
|
189
209
|
uri,
|
|
190
210
|
});
|
|
211
|
+
logger.info('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, resource: matched.resource.name, params: matched.params, duration: ms(start) });
|
|
191
212
|
return result;
|
|
192
213
|
} catch (err: any) {
|
|
214
|
+
logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: err?.message ?? 'unknown', duration: ms(start) });
|
|
193
215
|
throw new McpError(ErrorCode.InternalError, `Failed to read resource: ${err?.message ?? 'unknown'}`);
|
|
194
216
|
}
|
|
195
217
|
});
|
package/server/routes.ts
CHANGED
|
@@ -4,6 +4,7 @@ import path from "path";
|
|
|
4
4
|
import { jwt } from "../utils/func";
|
|
5
5
|
import { useRest } from "../database/rest";
|
|
6
6
|
import { io } from "./io";
|
|
7
|
+
import { requestCtxStorage } from "../lib/asyncContextStorage";
|
|
7
8
|
import type { HonoVariables } from "./env";
|
|
8
9
|
|
|
9
10
|
function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
@@ -12,10 +13,17 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
12
13
|
let method = route.method.toLocaleLowerCase();
|
|
13
14
|
let routePath = path.posix.join(route._prefix_, route.path);
|
|
14
15
|
|
|
16
|
+
// Resolve the tenant in the request context so the access log,
|
|
17
|
+
// nested useRest() calls and audit see the right tenant_id
|
|
18
|
+
const resolveTenant = () => {
|
|
19
|
+
requestCtxStorage.set('tenant_id', route._tenant_);
|
|
20
|
+
};
|
|
21
|
+
|
|
15
22
|
|
|
16
23
|
|
|
17
24
|
if (route.method == "GET") {
|
|
18
25
|
app.get(routePath, async (c) => { //
|
|
26
|
+
resolveTenant();
|
|
19
27
|
return route.handler({
|
|
20
28
|
c,
|
|
21
29
|
rest: new useRest({
|
|
@@ -29,6 +37,7 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
29
37
|
|
|
30
38
|
if (route.method == 'POST') {
|
|
31
39
|
app.post(routePath, async (c) => {
|
|
40
|
+
resolveTenant();
|
|
32
41
|
return route.handler({
|
|
33
42
|
c,
|
|
34
43
|
rest: new useRest({
|
|
@@ -42,6 +51,7 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
|
|
|
42
51
|
|
|
43
52
|
if (route.method == "PUT") {
|
|
44
53
|
app.put(routePath, async (c) => { //
|
|
54
|
+
resolveTenant();
|
|
45
55
|
return route.handler({
|
|
46
56
|
c,
|
|
47
57
|
rest: new useRest({
|