@wener/mcps 1.0.2 → 1.0.4
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 +144 -0
- package/dist/index.mjs +213076 -1
- package/dist/mcps-cli.mjs +102547 -59344
- package/lib/chat/handler.js +2 -2
- package/lib/chat/handler.js.map +1 -1
- package/lib/cli-start.js +36 -0
- package/lib/cli-start.js.map +1 -0
- package/lib/cli.js +19 -0
- package/lib/cli.js.map +1 -0
- package/lib/dev.server.js +7 -1
- package/lib/dev.server.js.map +1 -1
- package/lib/index.js +21 -3
- package/lib/index.js.map +1 -1
- package/lib/mcps-cli.js +6 -35
- package/lib/mcps-cli.js.map +1 -1
- package/lib/providers/feishu/def.js +35 -0
- package/lib/providers/feishu/def.js.map +1 -0
- package/lib/providers/findMcpServerDef.js +1 -0
- package/lib/providers/findMcpServerDef.js.map +1 -1
- package/lib/scripts/bundle.js +7 -1
- package/lib/scripts/bundle.js.map +1 -1
- package/lib/server/api-routes.js +7 -8
- package/lib/server/api-routes.js.map +1 -1
- package/lib/server/audit-db.js +64 -0
- package/lib/server/audit-db.js.map +1 -0
- package/lib/server/{audit.js → audit-plugin.js} +72 -126
- package/lib/server/audit-plugin.js.map +1 -0
- package/lib/server/events.js +13 -0
- package/lib/server/events.js.map +1 -0
- package/lib/server/mcp-routes.js +31 -60
- package/lib/server/mcp-routes.js.map +1 -1
- package/lib/server/mcps-router.js +19 -24
- package/lib/server/mcps-router.js.map +1 -1
- package/lib/server/schema.js +22 -2
- package/lib/server/schema.js.map +1 -1
- package/lib/server/server.js +142 -87
- package/lib/server/server.js.map +1 -1
- package/package.json +33 -6
- package/src/chat/handler.ts +2 -2
- package/src/cli-start.ts +43 -0
- package/src/cli.ts +45 -0
- package/src/dev.server.ts +8 -1
- package/src/index.ts +47 -1
- package/src/mcps-cli.ts +6 -48
- package/src/providers/feishu/def.ts +37 -0
- package/src/providers/findMcpServerDef.ts +1 -0
- package/src/scripts/bundle.ts +12 -1
- package/src/server/api-routes.ts +11 -8
- package/src/server/audit-db.ts +65 -0
- package/src/server/{audit.ts → audit-plugin.ts} +69 -142
- package/src/server/events.ts +29 -0
- package/src/server/mcp-routes.ts +30 -58
- package/src/server/mcps-router.ts +21 -29
- package/src/server/schema.ts +23 -2
- package/src/server/server.ts +149 -81
- package/lib/server/audit.js.map +0 -1
- package/lib/server/db.js +0 -97
- package/lib/server/db.js.map +0 -1
- package/src/server/db.ts +0 -115
package/src/mcps-cli.ts
CHANGED
|
@@ -1,56 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
* MCPS - MCP Proxy Server CLI
|
|
4
|
-
*
|
|
5
|
-
* A unified MCP server that supports:
|
|
6
|
-
* - Tencent CLS (Cloud Log Service)
|
|
7
|
-
* - SQL (MySQL, PostgreSQL, SQLite)
|
|
8
|
-
* - Prometheus
|
|
9
|
-
* - Relay (proxy to other MCP servers)
|
|
10
|
-
*/
|
|
11
|
-
import { serve } from '@hono/node-server';
|
|
12
|
-
import { Command } from 'commander';
|
|
13
2
|
import consola from 'consola';
|
|
14
|
-
import {
|
|
3
|
+
import { createProgram } from './cli';
|
|
15
4
|
|
|
16
5
|
const log = consola.withTag('mcps');
|
|
17
6
|
|
|
18
|
-
const program =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.version('0.1.0')
|
|
24
|
-
.option('-p, --port <port>', 'Port to listen on', '8036')
|
|
25
|
-
.option('-c, --cwd <path>', 'Working directory for config files', process.cwd())
|
|
26
|
-
.option('--discovery-config', 'Enable server config discovery endpoints', false)
|
|
27
|
-
.action(async (options) => {
|
|
28
|
-
const port = Number.parseInt(options.port, 10);
|
|
29
|
-
const { app } = createServer({
|
|
30
|
-
cwd: options.cwd,
|
|
31
|
-
port,
|
|
32
|
-
discoveryConfig: options.discoveryConfig,
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
log.info(`Starting MCPS server on port ${port}`);
|
|
36
|
-
|
|
37
|
-
serve({
|
|
38
|
-
fetch: app.fetch,
|
|
39
|
-
port,
|
|
40
|
-
hostname: '0.0.0.0',
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
log.success(`MCPS server running at http://localhost:${port}`);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// Handle graceful shutdown
|
|
47
|
-
process.on('SIGINT', () => {
|
|
48
|
-
log.info('Shutting down...');
|
|
49
|
-
process.exit(130);
|
|
50
|
-
});
|
|
51
|
-
process.on('SIGTERM', () => {
|
|
52
|
-
log.info('Shutting down...');
|
|
53
|
-
process.exit(143);
|
|
7
|
+
const program = createProgram({
|
|
8
|
+
setup: async (ctx) => {
|
|
9
|
+
const { setupAudit } = await import('./server/audit-plugin.js');
|
|
10
|
+
setupAudit(ctx);
|
|
11
|
+
},
|
|
54
12
|
});
|
|
55
13
|
|
|
56
14
|
program.parseAsync(process.argv).catch((error) => {
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { FeishuMcpServerDef, type CreateFeishuMcpServerOptions } from '@wener/ai/mcp/feishu';
|
|
2
|
+
import { HeaderNames, type FeishuConfig } from '../../server/schema';
|
|
3
|
+
import { defineMcpServerHandler, registerMcpServerHandler } from '../McpServerHandlerDef';
|
|
4
|
+
|
|
5
|
+
export const FeishuMcpServerHandlerDef = defineMcpServerHandler<CreateFeishuMcpServerOptions, FeishuConfig>(
|
|
6
|
+
FeishuMcpServerDef,
|
|
7
|
+
{
|
|
8
|
+
headerMappings: [
|
|
9
|
+
{ header: HeaderNames.FEISHU_APP_ID, property: 'appId', required: true },
|
|
10
|
+
{ header: HeaderNames.FEISHU_APP_SECRET, property: 'appSecret', required: true },
|
|
11
|
+
{ header: HeaderNames.FEISHU_DOMAIN, property: 'domain' },
|
|
12
|
+
],
|
|
13
|
+
|
|
14
|
+
resolveConfig(config, headers) {
|
|
15
|
+
const appId =
|
|
16
|
+
config.appId ||
|
|
17
|
+
headers?.get(HeaderNames.FEISHU_APP_ID) ||
|
|
18
|
+
config.headers?.[HeaderNames.FEISHU_APP_ID];
|
|
19
|
+
const appSecret =
|
|
20
|
+
config.appSecret ||
|
|
21
|
+
headers?.get(HeaderNames.FEISHU_APP_SECRET) ||
|
|
22
|
+
config.headers?.[HeaderNames.FEISHU_APP_SECRET];
|
|
23
|
+
|
|
24
|
+
if (!appId || !appSecret) return null;
|
|
25
|
+
|
|
26
|
+
const domain =
|
|
27
|
+
config.domain ||
|
|
28
|
+
headers?.get(HeaderNames.FEISHU_DOMAIN) ||
|
|
29
|
+
config.headers?.[HeaderNames.FEISHU_DOMAIN] ||
|
|
30
|
+
'feishu';
|
|
31
|
+
|
|
32
|
+
return { appId, appSecret, domain };
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
registerMcpServerHandler(FeishuMcpServerHandlerDef);
|
package/src/scripts/bundle.ts
CHANGED
|
@@ -39,7 +39,18 @@ const commonOptions: esbuild.BuildOptions = {
|
|
|
39
39
|
sourcemap: false,
|
|
40
40
|
legalComments: 'none',
|
|
41
41
|
// External native modules
|
|
42
|
-
external: [
|
|
42
|
+
external: [
|
|
43
|
+
'better-sqlite3',
|
|
44
|
+
'bun:sqlite',
|
|
45
|
+
'kysely-bun-sqlite',
|
|
46
|
+
'oracledb',
|
|
47
|
+
'mariadb/callback',
|
|
48
|
+
'mysql',
|
|
49
|
+
'@nestjs/websockets',
|
|
50
|
+
'@nestjs/microservices',
|
|
51
|
+
'@nestjs/platform-express',
|
|
52
|
+
'@larksuiteoapi/node-sdk',
|
|
53
|
+
],
|
|
43
54
|
};
|
|
44
55
|
|
|
45
56
|
// Ensure dist directory exists
|
package/src/server/api-routes.ts
CHANGED
|
@@ -6,26 +6,29 @@ import { CORSPlugin } from '@orpc/server/plugins';
|
|
|
6
6
|
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4';
|
|
7
7
|
import type { Hono } from 'hono';
|
|
8
8
|
import { html } from 'hono/html';
|
|
9
|
-
import { AuditRouter } from './audit';
|
|
10
9
|
import { createMcpsRouter } from './mcps-router';
|
|
11
10
|
import type { McpsConfig } from './schema';
|
|
11
|
+
import type { StatsProvider } from './server';
|
|
12
12
|
|
|
13
13
|
export interface RegisterApiRoutesOptions {
|
|
14
14
|
app: Hono;
|
|
15
15
|
config: McpsConfig;
|
|
16
|
+
/** Additional oRPC routers registered by plugins (e.g. audit) */
|
|
17
|
+
apiRouters?: Record<string, any>;
|
|
18
|
+
/** Optional stats provider from audit plugin */
|
|
19
|
+
statsProvider?: StatsProvider;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
|
-
* Register oRPC API routes for
|
|
23
|
+
* Register oRPC API routes for MCPS.
|
|
24
|
+
* Audit router is not included by default - use setupAudit() plugin to add it.
|
|
20
25
|
*/
|
|
21
|
-
export function registerApiRoutes({ app, config }: RegisterApiRoutesOptions) {
|
|
22
|
-
|
|
23
|
-
const McpsRouter = createMcpsRouter({ config });
|
|
26
|
+
export function registerApiRoutes({ app, config, apiRouters, statsProvider }: RegisterApiRoutesOptions) {
|
|
27
|
+
const McpsRouter = createMcpsRouter({ config, statsProvider });
|
|
24
28
|
|
|
25
|
-
|
|
26
|
-
const combinedRouter = {
|
|
27
|
-
audit: AuditRouter,
|
|
29
|
+
const combinedRouter: Record<string, any> = {
|
|
28
30
|
mcps: McpsRouter,
|
|
31
|
+
...apiRouters,
|
|
29
32
|
};
|
|
30
33
|
|
|
31
34
|
const handleByRpc = new RPCHandler(combinedRouter);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { MikroORM, type Options } from '@mikro-orm/core';
|
|
2
|
+
import { SqliteDriver } from '@mikro-orm/sql';
|
|
3
|
+
import { createSqliteDialect } from '@wener/server/mikro-orm';
|
|
4
|
+
import { ChatRequestEntity } from '../entities/ChatRequestEntity';
|
|
5
|
+
import { McpRequestEntity } from '../entities/McpRequestEntity';
|
|
6
|
+
import { RequestLogEntity } from '../entities/RequestLogEntity';
|
|
7
|
+
import { ResponseEntity } from '../entities/ResponseEntity';
|
|
8
|
+
import type { DbConfig } from './schema';
|
|
9
|
+
|
|
10
|
+
export { RequestLogEntity };
|
|
11
|
+
|
|
12
|
+
let orm: MikroORM<SqliteDriver> | null = null;
|
|
13
|
+
let initPromise: Promise<MikroORM<SqliteDriver>> | null = null;
|
|
14
|
+
|
|
15
|
+
async function getOrmConfig(dbConfig?: DbConfig): Promise<Options<SqliteDriver>> {
|
|
16
|
+
const dbPath = dbConfig?.path || '.mcps.db';
|
|
17
|
+
return {
|
|
18
|
+
driver: SqliteDriver,
|
|
19
|
+
dbName: dbPath,
|
|
20
|
+
entities: [ChatRequestEntity, McpRequestEntity, RequestLogEntity, ResponseEntity],
|
|
21
|
+
driverOptions: await createSqliteDialect(dbPath),
|
|
22
|
+
debug: process.env.NODE_ENV === 'development',
|
|
23
|
+
allowGlobalContext: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function ensureDbInitialized(dbConfig?: DbConfig): Promise<MikroORM<SqliteDriver>> {
|
|
28
|
+
if (orm) return orm;
|
|
29
|
+
if (initPromise) return initPromise;
|
|
30
|
+
|
|
31
|
+
initPromise = (async () => {
|
|
32
|
+
const config = await getOrmConfig(dbConfig);
|
|
33
|
+
orm = await MikroORM.init(config);
|
|
34
|
+
await orm.schema.update();
|
|
35
|
+
return orm;
|
|
36
|
+
})();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
return await initPromise;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
initPromise = null;
|
|
42
|
+
throw e;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getOrm(): MikroORM<SqliteDriver> {
|
|
47
|
+
if (!orm) throw new Error('Database not initialized');
|
|
48
|
+
return orm;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getEntityManager() {
|
|
52
|
+
return getOrm().em;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function closeDb(): Promise<void> {
|
|
56
|
+
if (orm) {
|
|
57
|
+
await orm.close();
|
|
58
|
+
orm = null;
|
|
59
|
+
initPromise = null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function isDbInitialized(): boolean {
|
|
64
|
+
return orm !== null;
|
|
65
|
+
}
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import { implement } from '@orpc/server';
|
|
2
|
-
import type { Context, Next } from 'hono';
|
|
3
2
|
import { LRUCache } from 'lru-cache';
|
|
4
3
|
import { AuditContract, type AuditEvent } from '../contracts';
|
|
5
|
-
import {
|
|
6
|
-
import { ensureDbInitialized, configureDb } from './db';
|
|
4
|
+
import { McpsEventType, type McpsEmitter } from './events';
|
|
7
5
|
import type { AuditConfig, DbConfig } from './schema';
|
|
6
|
+
import type { McpsServerContext } from './server';
|
|
8
7
|
|
|
9
|
-
/**
|
|
10
|
-
* Convert Headers to a plain record
|
|
11
|
-
*/
|
|
12
8
|
function headersToRecord(headers: Headers): Record<string, string> {
|
|
13
9
|
const record: Record<string, string> = {};
|
|
14
10
|
headers.forEach((value, key) => {
|
|
@@ -17,56 +13,22 @@ function headersToRecord(headers: Headers): Record<string, string> {
|
|
|
17
13
|
return record;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
|
-
// In-memory audit store using LRU cache
|
|
21
16
|
const auditStore = new LRUCache<string, AuditEvent>({
|
|
22
|
-
max: 10000,
|
|
23
|
-
ttl: 1000 * 60 * 60 * 24,
|
|
17
|
+
max: 10000,
|
|
18
|
+
ttl: 1000 * 60 * 60 * 24,
|
|
24
19
|
});
|
|
25
20
|
|
|
26
|
-
// Counter for IDs
|
|
27
21
|
let eventCounter = 0;
|
|
28
|
-
|
|
29
|
-
// Audit configuration state
|
|
30
|
-
let auditEnabled = true; // default to enabled
|
|
31
22
|
let dbConfigured = false;
|
|
23
|
+
let storedAuditConfig: AuditConfig | undefined;
|
|
24
|
+
let storedDbConfig: DbConfig | undefined;
|
|
32
25
|
|
|
33
|
-
/**
|
|
34
|
-
* Configure audit module with settings
|
|
35
|
-
* Call this before using audit features
|
|
36
|
-
*
|
|
37
|
-
* @param auditConfig - Audit config section
|
|
38
|
-
* @param fallbackDbConfig - Fallback db config from root config
|
|
39
|
-
*/
|
|
40
|
-
export function configureAudit(auditConfig?: AuditConfig, fallbackDbConfig?: DbConfig): void {
|
|
41
|
-
// Determine if audit is enabled (default: true)
|
|
42
|
-
auditEnabled = auditConfig?.enabled !== false;
|
|
43
|
-
|
|
44
|
-
if (auditEnabled) {
|
|
45
|
-
// Use audit.db config if present, otherwise fallback to root db config
|
|
46
|
-
const dbConfig = auditConfig?.db ?? fallbackDbConfig;
|
|
47
|
-
configureDb(dbConfig);
|
|
48
|
-
dbConfigured = true;
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Check if audit is enabled
|
|
54
|
-
*/
|
|
55
|
-
export function isAuditEnabled(): boolean {
|
|
56
|
-
return auditEnabled;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Persist audit event to database (lazy init)
|
|
61
|
-
*/
|
|
62
26
|
async function persistToDb(event: AuditEvent, id: string): Promise<void> {
|
|
63
|
-
if (!
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
27
|
+
if (!dbConfigured) return;
|
|
66
28
|
|
|
67
29
|
try {
|
|
68
|
-
|
|
69
|
-
const orm = await ensureDbInitialized();
|
|
30
|
+
const { ensureDbInitialized, RequestLogEntity } = await import('./audit-db.js');
|
|
31
|
+
const orm = await ensureDbInitialized(storedDbConfig);
|
|
70
32
|
const em = orm.em.fork();
|
|
71
33
|
|
|
72
34
|
const logEntry = new RequestLogEntity();
|
|
@@ -80,7 +42,6 @@ async function persistToDb(event: AuditEvent, id: string): Promise<void> {
|
|
|
80
42
|
logEntry.durationMs = event.durationMs ?? undefined;
|
|
81
43
|
logEntry.error = event.error ?? undefined;
|
|
82
44
|
logEntry.requestHeaders = event.requestHeaders ?? undefined;
|
|
83
|
-
// Determine request type
|
|
84
45
|
if (event.path.startsWith('/mcp/')) {
|
|
85
46
|
logEntry.requestType = 'mcp';
|
|
86
47
|
} else if (event.path.startsWith('/v1/')) {
|
|
@@ -91,30 +52,20 @@ async function persistToDb(event: AuditEvent, id: string): Promise<void> {
|
|
|
91
52
|
em.persist(logEntry);
|
|
92
53
|
await em.flush();
|
|
93
54
|
} catch (e) {
|
|
94
|
-
// Log persistence errors but don't throw - in-memory store is the primary
|
|
95
55
|
console.error('Failed to persist audit log:', e);
|
|
96
56
|
}
|
|
97
57
|
}
|
|
98
58
|
|
|
99
|
-
/**
|
|
100
|
-
* Add an audit event
|
|
101
|
-
*/
|
|
102
59
|
export function addAuditEvent(event: Omit<AuditEvent, 'id'>): AuditEvent {
|
|
103
60
|
const id = `${Date.now()}-${++eventCounter}`;
|
|
104
61
|
const fullEvent: AuditEvent = { ...event, id };
|
|
105
62
|
auditStore.set(id, fullEvent);
|
|
106
63
|
|
|
107
|
-
|
|
108
|
-
persistToDb(fullEvent, id).catch(() => {
|
|
109
|
-
// Already logged in persistToDb
|
|
110
|
-
});
|
|
64
|
+
persistToDb(fullEvent, id).catch(() => {});
|
|
111
65
|
|
|
112
66
|
return fullEvent;
|
|
113
67
|
}
|
|
114
68
|
|
|
115
|
-
/**
|
|
116
|
-
* Query audit events
|
|
117
|
-
*/
|
|
118
69
|
export function queryAuditEvents(options: {
|
|
119
70
|
limit?: number;
|
|
120
71
|
offset?: number;
|
|
@@ -126,25 +77,16 @@ export function queryAuditEvents(options: {
|
|
|
126
77
|
}): { events: AuditEvent[]; total: number } {
|
|
127
78
|
const { limit = 50, offset = 0, serverName, serverType, method, from, to } = options;
|
|
128
79
|
|
|
129
|
-
// Get all events as array
|
|
130
80
|
let events: AuditEvent[] = [];
|
|
131
81
|
for (const [, event] of auditStore.entries()) {
|
|
132
82
|
events.push(event);
|
|
133
83
|
}
|
|
134
84
|
|
|
135
|
-
// Sort by timestamp desc
|
|
136
85
|
events.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
137
86
|
|
|
138
|
-
|
|
139
|
-
if (
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
if (serverType) {
|
|
143
|
-
events = events.filter((e) => e.serverType === serverType);
|
|
144
|
-
}
|
|
145
|
-
if (method) {
|
|
146
|
-
events = events.filter((e) => e.method === method);
|
|
147
|
-
}
|
|
87
|
+
if (serverName) events = events.filter((e) => e.serverName === serverName);
|
|
88
|
+
if (serverType) events = events.filter((e) => e.serverType === serverType);
|
|
89
|
+
if (method) events = events.filter((e) => e.method === method);
|
|
148
90
|
if (from) {
|
|
149
91
|
const fromTime = new Date(from).getTime();
|
|
150
92
|
events = events.filter((e) => new Date(e.timestamp).getTime() >= fromTime);
|
|
@@ -155,23 +97,17 @@ export function queryAuditEvents(options: {
|
|
|
155
97
|
}
|
|
156
98
|
|
|
157
99
|
const total = events.length;
|
|
158
|
-
|
|
159
|
-
// Paginate
|
|
160
100
|
events = events.slice(offset, offset + limit);
|
|
161
101
|
|
|
162
102
|
return { events, total };
|
|
163
103
|
}
|
|
164
104
|
|
|
165
|
-
/**
|
|
166
|
-
* Get audit statistics
|
|
167
|
-
*/
|
|
168
105
|
export function getAuditStats(options: { from?: string | null; to?: string | null }) {
|
|
169
106
|
let events: AuditEvent[] = [];
|
|
170
107
|
for (const [, event] of auditStore.entries()) {
|
|
171
108
|
events.push(event);
|
|
172
109
|
}
|
|
173
110
|
|
|
174
|
-
// Apply time filters
|
|
175
111
|
if (options.from) {
|
|
176
112
|
const fromTime = new Date(options.from).getTime();
|
|
177
113
|
events = events.filter((e) => new Date(e.timestamp).getTime() >= fromTime);
|
|
@@ -181,14 +117,12 @@ export function getAuditStats(options: { from?: string | null; to?: string | nul
|
|
|
181
117
|
events = events.filter((e) => new Date(e.timestamp).getTime() <= toTime);
|
|
182
118
|
}
|
|
183
119
|
|
|
184
|
-
// Calculate stats
|
|
185
120
|
const totalRequests = events.length;
|
|
186
121
|
const totalErrors = events.filter((e) => e.error || (e.status && e.status >= 400)).length;
|
|
187
122
|
|
|
188
123
|
const durations = events.map((e) => e.durationMs).filter((d): d is number => d != null);
|
|
189
124
|
const avgDurationMs = durations.length > 0 ? durations.reduce((a, b) => a + b, 0) / durations.length : 0;
|
|
190
125
|
|
|
191
|
-
// Group by server
|
|
192
126
|
const serverCounts = new Map<string, number>();
|
|
193
127
|
for (const event of events) {
|
|
194
128
|
const name = event.serverName || 'unknown';
|
|
@@ -198,11 +132,10 @@ export function getAuditStats(options: { from?: string | null; to?: string | nul
|
|
|
198
132
|
.map(([name, count]) => ({ name, count }))
|
|
199
133
|
.sort((a, b) => b.count - a.count);
|
|
200
134
|
|
|
201
|
-
// Group by method
|
|
202
135
|
const methodCounts = new Map<string, number>();
|
|
203
136
|
for (const event of events) {
|
|
204
|
-
const
|
|
205
|
-
methodCounts.set(
|
|
137
|
+
const m = event.method || 'unknown';
|
|
138
|
+
methodCounts.set(m, (methodCounts.get(m) || 0) + 1);
|
|
206
139
|
}
|
|
207
140
|
const byMethod = Array.from(methodCounts.entries())
|
|
208
141
|
.map(([method, count]) => ({ method, count }))
|
|
@@ -211,9 +144,6 @@ export function getAuditStats(options: { from?: string | null; to?: string | nul
|
|
|
211
144
|
return { totalRequests, totalErrors, avgDurationMs, byServer, byMethod };
|
|
212
145
|
}
|
|
213
146
|
|
|
214
|
-
/**
|
|
215
|
-
* Clear audit events before a timestamp
|
|
216
|
-
*/
|
|
217
147
|
export function clearAuditEvents(before: string): number {
|
|
218
148
|
const beforeTime = new Date(before).getTime();
|
|
219
149
|
let deleted = 0;
|
|
@@ -228,22 +158,16 @@ export function clearAuditEvents(before: string): number {
|
|
|
228
158
|
return deleted;
|
|
229
159
|
}
|
|
230
160
|
|
|
231
|
-
/**
|
|
232
|
-
* Audit Router implementation
|
|
233
|
-
*/
|
|
234
161
|
export const AuditRouter = implement(AuditContract).router({
|
|
235
162
|
list: implement(AuditContract.list).handler(async ({ input }) => {
|
|
236
163
|
return queryAuditEvents(input);
|
|
237
164
|
}),
|
|
238
|
-
|
|
239
165
|
get: implement(AuditContract.get).handler(async ({ input }) => {
|
|
240
166
|
return auditStore.get(input.id) ?? null;
|
|
241
167
|
}),
|
|
242
|
-
|
|
243
168
|
stats: implement(AuditContract.stats).handler(async ({ input }) => {
|
|
244
169
|
return getAuditStats(input);
|
|
245
170
|
}),
|
|
246
|
-
|
|
247
171
|
clear: implement(AuditContract.clear).handler(async ({ input }) => {
|
|
248
172
|
const deleted = clearAuditEvents(input.before);
|
|
249
173
|
return { deleted };
|
|
@@ -251,60 +175,63 @@ export const AuditRouter = implement(AuditContract).router({
|
|
|
251
175
|
});
|
|
252
176
|
|
|
253
177
|
/**
|
|
254
|
-
*
|
|
178
|
+
* Set up audit by subscribing to the server emitter.
|
|
179
|
+
* Call this from the `setup` callback of `createServer` to opt in to audit.
|
|
180
|
+
*
|
|
181
|
+
* @example
|
|
182
|
+
* ```ts
|
|
183
|
+
* createServer({
|
|
184
|
+
* setup: (ctx) => {
|
|
185
|
+
* setupAudit(ctx);
|
|
186
|
+
* },
|
|
187
|
+
* });
|
|
188
|
+
* ```
|
|
255
189
|
*/
|
|
256
|
-
export function
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
const path = c.req.path;
|
|
260
|
-
|
|
261
|
-
// Extract server info from path
|
|
262
|
-
let serverName: string | undefined;
|
|
263
|
-
let serverType: string | undefined;
|
|
264
|
-
|
|
265
|
-
const mcpMatch = path.match(/^\/mcp\/([^/]+)/);
|
|
266
|
-
if (mcpMatch) {
|
|
267
|
-
serverName = mcpMatch[1];
|
|
268
|
-
// Infer type from well-known paths
|
|
269
|
-
if (serverName === 'tencent-cls') serverType = 'tencent-cls';
|
|
270
|
-
else if (serverName === 'sql') serverType = 'sql';
|
|
271
|
-
else if (serverName === 'prometheus') serverType = 'prometheus';
|
|
272
|
-
else if (serverName === 'relay') serverType = 'relay';
|
|
273
|
-
else serverType = 'custom';
|
|
274
|
-
}
|
|
190
|
+
export function setupAudit(ctx: McpsServerContext, options?: { auditConfig?: AuditConfig; dbConfig?: DbConfig }) {
|
|
191
|
+
const auditConfig = options?.auditConfig ?? ctx.config.audit;
|
|
192
|
+
const dbConfig = options?.dbConfig ?? ctx.config.db;
|
|
275
193
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
serverType = 'chat';
|
|
279
|
-
}
|
|
194
|
+
const enabled = auditConfig?.enabled !== false;
|
|
195
|
+
if (!enabled) return;
|
|
280
196
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
197
|
+
const auditDbConfig = auditConfig?.db ?? dbConfig;
|
|
198
|
+
if (auditDbConfig) {
|
|
199
|
+
storedDbConfig = auditDbConfig;
|
|
200
|
+
dbConfigured = true;
|
|
201
|
+
}
|
|
202
|
+
storedAuditConfig = auditConfig;
|
|
203
|
+
|
|
204
|
+
// Subscribe to request events
|
|
205
|
+
ctx.emitter.on(McpsEventType.Request, (event) => {
|
|
206
|
+
const shouldAudit =
|
|
207
|
+
event.path.startsWith('/mcp/') ||
|
|
208
|
+
event.path.startsWith('/v1/') ||
|
|
209
|
+
(event.path.startsWith('/api/') && event.method !== 'GET');
|
|
210
|
+
|
|
211
|
+
if (shouldAudit) {
|
|
212
|
+
addAuditEvent({
|
|
213
|
+
timestamp: event.timestamp,
|
|
214
|
+
method: event.method,
|
|
215
|
+
path: event.path,
|
|
216
|
+
serverName: event.serverName,
|
|
217
|
+
serverType: event.serverType,
|
|
218
|
+
status: event.status,
|
|
219
|
+
durationMs: event.durationMs,
|
|
220
|
+
error: event.error,
|
|
221
|
+
requestHeaders: event.requestHeaders,
|
|
222
|
+
});
|
|
308
223
|
}
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Register audit API router
|
|
227
|
+
ctx.apiRouters.audit = AuditRouter;
|
|
228
|
+
|
|
229
|
+
// Register stats provider so mcps-router can access stats
|
|
230
|
+
ctx.statsProvider = {
|
|
231
|
+
getStats: getAuditStats,
|
|
232
|
+
queryEvents: (opts) => {
|
|
233
|
+
const result = queryAuditEvents(opts);
|
|
234
|
+
return { events: result.events.map((e) => ({ path: e.path })), total: result.total };
|
|
235
|
+
},
|
|
309
236
|
};
|
|
310
237
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import Emittery from 'emittery';
|
|
2
|
+
|
|
3
|
+
export const McpsEventType = {
|
|
4
|
+
Request: 'Mcps:Request',
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export type McpsRequestEvent = {
|
|
8
|
+
timestamp: string;
|
|
9
|
+
method: string;
|
|
10
|
+
path: string;
|
|
11
|
+
serverName?: string;
|
|
12
|
+
serverType?: string;
|
|
13
|
+
status?: number;
|
|
14
|
+
durationMs?: number;
|
|
15
|
+
error?: string;
|
|
16
|
+
requestHeaders?: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type McpsEventData = {
|
|
20
|
+
[McpsEventType.Request]: McpsRequestEvent;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type McpsEmitter = Emittery<McpsEventData>;
|
|
24
|
+
|
|
25
|
+
export function createMcpsEmitter(): McpsEmitter {
|
|
26
|
+
return new Emittery<McpsEventData>({
|
|
27
|
+
debug: { name: 'McpsEmitter' },
|
|
28
|
+
});
|
|
29
|
+
}
|