@wener/mcps 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.mjs +15 -0
  3. package/dist/mcps-cli.mjs +174727 -0
  4. package/lib/chat/agent.js +187 -0
  5. package/lib/chat/agent.js.map +1 -0
  6. package/lib/chat/audit.js +238 -0
  7. package/lib/chat/audit.js.map +1 -0
  8. package/lib/chat/converters.js +467 -0
  9. package/lib/chat/converters.js.map +1 -0
  10. package/lib/chat/handler.js +1068 -0
  11. package/lib/chat/handler.js.map +1 -0
  12. package/lib/chat/index.js +12 -0
  13. package/lib/chat/index.js.map +1 -0
  14. package/lib/chat/types.js +35 -0
  15. package/lib/chat/types.js.map +1 -0
  16. package/lib/contracts/AuditContract.js +85 -0
  17. package/lib/contracts/AuditContract.js.map +1 -0
  18. package/lib/contracts/McpsContract.js +113 -0
  19. package/lib/contracts/McpsContract.js.map +1 -0
  20. package/lib/contracts/index.js +3 -0
  21. package/lib/contracts/index.js.map +1 -0
  22. package/lib/dev.server.js +7 -0
  23. package/lib/dev.server.js.map +1 -0
  24. package/lib/entities/ChatRequestEntity.js +318 -0
  25. package/lib/entities/ChatRequestEntity.js.map +1 -0
  26. package/lib/entities/McpRequestEntity.js +271 -0
  27. package/lib/entities/McpRequestEntity.js.map +1 -0
  28. package/lib/entities/RequestLogEntity.js +177 -0
  29. package/lib/entities/RequestLogEntity.js.map +1 -0
  30. package/lib/entities/ResponseEntity.js +150 -0
  31. package/lib/entities/ResponseEntity.js.map +1 -0
  32. package/lib/entities/index.js +11 -0
  33. package/lib/entities/index.js.map +1 -0
  34. package/lib/entities/types.js +11 -0
  35. package/lib/entities/types.js.map +1 -0
  36. package/lib/index.js +3 -0
  37. package/lib/index.js.map +1 -0
  38. package/lib/mcps-cli.js +44 -0
  39. package/lib/mcps-cli.js.map +1 -0
  40. package/lib/providers/McpServerHandlerDef.js +40 -0
  41. package/lib/providers/McpServerHandlerDef.js.map +1 -0
  42. package/lib/providers/findMcpServerDef.js +26 -0
  43. package/lib/providers/findMcpServerDef.js.map +1 -0
  44. package/lib/providers/prometheus/def.js +24 -0
  45. package/lib/providers/prometheus/def.js.map +1 -0
  46. package/lib/providers/prometheus/index.js +2 -0
  47. package/lib/providers/prometheus/index.js.map +1 -0
  48. package/lib/providers/relay/def.js +32 -0
  49. package/lib/providers/relay/def.js.map +1 -0
  50. package/lib/providers/relay/index.js +2 -0
  51. package/lib/providers/relay/index.js.map +1 -0
  52. package/lib/providers/sql/def.js +31 -0
  53. package/lib/providers/sql/def.js.map +1 -0
  54. package/lib/providers/sql/index.js +2 -0
  55. package/lib/providers/sql/index.js.map +1 -0
  56. package/lib/providers/tencent-cls/def.js +44 -0
  57. package/lib/providers/tencent-cls/def.js.map +1 -0
  58. package/lib/providers/tencent-cls/index.js +2 -0
  59. package/lib/providers/tencent-cls/index.js.map +1 -0
  60. package/lib/scripts/bundle.js +90 -0
  61. package/lib/scripts/bundle.js.map +1 -0
  62. package/lib/server/api-routes.js +96 -0
  63. package/lib/server/api-routes.js.map +1 -0
  64. package/lib/server/audit.js +274 -0
  65. package/lib/server/audit.js.map +1 -0
  66. package/lib/server/chat-routes.js +82 -0
  67. package/lib/server/chat-routes.js.map +1 -0
  68. package/lib/server/config.js +223 -0
  69. package/lib/server/config.js.map +1 -0
  70. package/lib/server/db.js +97 -0
  71. package/lib/server/db.js.map +1 -0
  72. package/lib/server/index.js +2 -0
  73. package/lib/server/index.js.map +1 -0
  74. package/lib/server/mcp-handler.js +167 -0
  75. package/lib/server/mcp-handler.js.map +1 -0
  76. package/lib/server/mcp-routes.js +112 -0
  77. package/lib/server/mcp-routes.js.map +1 -0
  78. package/lib/server/mcps-router.js +119 -0
  79. package/lib/server/mcps-router.js.map +1 -0
  80. package/lib/server/schema.js +129 -0
  81. package/lib/server/schema.js.map +1 -0
  82. package/lib/server/server.js +166 -0
  83. package/lib/server/server.js.map +1 -0
  84. package/lib/web/ChatPage.js +827 -0
  85. package/lib/web/ChatPage.js.map +1 -0
  86. package/lib/web/McpInspectorPage.js +214 -0
  87. package/lib/web/McpInspectorPage.js.map +1 -0
  88. package/lib/web/ServersPage.js +93 -0
  89. package/lib/web/ServersPage.js.map +1 -0
  90. package/lib/web/main.js +541 -0
  91. package/lib/web/main.js.map +1 -0
  92. package/package.json +83 -0
  93. package/src/chat/agent.ts +240 -0
  94. package/src/chat/audit.ts +377 -0
  95. package/src/chat/converters.test.ts +325 -0
  96. package/src/chat/converters.ts +459 -0
  97. package/src/chat/handler.test.ts +137 -0
  98. package/src/chat/handler.ts +1233 -0
  99. package/src/chat/index.ts +16 -0
  100. package/src/chat/types.ts +72 -0
  101. package/src/contracts/AuditContract.ts +93 -0
  102. package/src/contracts/McpsContract.ts +141 -0
  103. package/src/contracts/index.ts +18 -0
  104. package/src/dev.server.ts +7 -0
  105. package/src/entities/ChatRequestEntity.ts +157 -0
  106. package/src/entities/McpRequestEntity.ts +149 -0
  107. package/src/entities/RequestLogEntity.ts +78 -0
  108. package/src/entities/ResponseEntity.ts +75 -0
  109. package/src/entities/index.ts +12 -0
  110. package/src/entities/types.ts +188 -0
  111. package/src/index.ts +1 -0
  112. package/src/mcps-cli.ts +59 -0
  113. package/src/providers/McpServerHandlerDef.ts +105 -0
  114. package/src/providers/findMcpServerDef.ts +31 -0
  115. package/src/providers/prometheus/def.ts +21 -0
  116. package/src/providers/prometheus/index.ts +1 -0
  117. package/src/providers/relay/def.ts +31 -0
  118. package/src/providers/relay/index.ts +1 -0
  119. package/src/providers/relay/relay.test.ts +47 -0
  120. package/src/providers/sql/def.ts +33 -0
  121. package/src/providers/sql/index.ts +1 -0
  122. package/src/providers/tencent-cls/def.ts +38 -0
  123. package/src/providers/tencent-cls/index.ts +1 -0
  124. package/src/scripts/bundle.ts +82 -0
  125. package/src/server/api-routes.ts +98 -0
  126. package/src/server/audit.ts +310 -0
  127. package/src/server/chat-routes.ts +95 -0
  128. package/src/server/config.test.ts +162 -0
  129. package/src/server/config.ts +198 -0
  130. package/src/server/db.ts +115 -0
  131. package/src/server/index.ts +1 -0
  132. package/src/server/mcp-handler.ts +209 -0
  133. package/src/server/mcp-routes.ts +133 -0
  134. package/src/server/mcps-router.ts +133 -0
  135. package/src/server/schema.ts +175 -0
  136. package/src/server/server.ts +163 -0
  137. package/src/web/ChatPage.tsx +1005 -0
  138. package/src/web/McpInspectorPage.tsx +254 -0
  139. package/src/web/ServersPage.tsx +139 -0
  140. package/src/web/main.tsx +600 -0
  141. package/src/web/styles.css +15 -0
@@ -0,0 +1,162 @@
1
+ import { describe, it, expect, afterEach, beforeEach } from 'vitest';
2
+ import { loadConfig } from './config';
3
+ import { writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs';
4
+ import { resolve, join } from 'node:path';
5
+ import YAML from 'yaml';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ describe('Config Loading', () => {
9
+ // Use a unique temp directory for each test to avoid conflicts
10
+ let testDir: string;
11
+
12
+ beforeEach(() => {
13
+ testDir = join(tmpdir(), `mcps-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
14
+ mkdirSync(testDir, { recursive: true });
15
+ });
16
+
17
+ afterEach(() => {
18
+ // Cleanup test directory
19
+ if (existsSync(testDir)) {
20
+ rmSync(testDir, { recursive: true, force: true });
21
+ }
22
+ });
23
+
24
+ describe('YAML config loading', () => {
25
+ it('should load YAML config from .mcps.yaml', () => {
26
+ const config = {
27
+ servers: {
28
+ 'test-relay': {
29
+ type: 'relay',
30
+ url: 'https://example.com/mcp',
31
+ },
32
+ },
33
+ models: [
34
+ {
35
+ name: 'test-model',
36
+ baseUrl: 'http://localhost:8080',
37
+ adapter: 'openai',
38
+ },
39
+ ],
40
+ };
41
+
42
+ // Write YAML config
43
+ writeFileSync(resolve(testDir, '.mcps.yaml'), YAML.stringify(config));
44
+
45
+ const loaded = loadConfig(testDir);
46
+ expect(loaded.servers['test-relay']).toBeDefined();
47
+ expect(loaded.servers['test-relay'].type).toBe('relay');
48
+ expect(loaded.models).toHaveLength(1);
49
+ expect(loaded.models?.[0].name).toBe('test-model');
50
+ expect(loaded.models?.[0].baseUrl).toBe('http://localhost:8080');
51
+ });
52
+
53
+ it('should prefer YAML over JSON when both exist', () => {
54
+ // Write JSON config (lower priority)
55
+ writeFileSync(
56
+ resolve(testDir, '.mcps.json'),
57
+ JSON.stringify({
58
+ servers: {
59
+ 'json-server': { type: 'prometheus', url: 'http://json.example.com' },
60
+ },
61
+ }),
62
+ );
63
+
64
+ // Write YAML config (higher priority)
65
+ writeFileSync(
66
+ resolve(testDir, '.mcps.yaml'),
67
+ YAML.stringify({
68
+ servers: {
69
+ 'yaml-server': { type: 'relay', url: 'https://yaml.example.com' },
70
+ },
71
+ }),
72
+ );
73
+
74
+ const loaded = loadConfig(testDir);
75
+ // Both servers should be present since configs are merged
76
+ expect(loaded.servers['yaml-server']).toBeDefined();
77
+ expect(loaded.servers['json-server']).toBeDefined();
78
+ });
79
+
80
+ it('should merge local config over base config', () => {
81
+ // Write base config
82
+ writeFileSync(
83
+ resolve(testDir, '.mcps.yaml'),
84
+ YAML.stringify({
85
+ servers: {
86
+ 'base-server': { type: 'relay', url: 'https://base.example.com' },
87
+ },
88
+ models: [
89
+ {
90
+ name: 'base-model',
91
+ baseUrl: 'http://base:8080',
92
+ adapter: 'openai',
93
+ },
94
+ ],
95
+ }),
96
+ );
97
+
98
+ // Write local config (higher priority)
99
+ writeFileSync(
100
+ resolve(testDir, '.mcps.local.yaml'),
101
+ YAML.stringify({
102
+ servers: {
103
+ 'local-server': { type: 'prometheus', url: 'http://local.example.com' },
104
+ },
105
+ models: [
106
+ {
107
+ name: 'local-model',
108
+ baseUrl: 'http://local:8080',
109
+ adapter: 'anthropic',
110
+ },
111
+ ],
112
+ }),
113
+ );
114
+
115
+ const loaded = loadConfig(testDir);
116
+ // Both servers should be present
117
+ expect(loaded.servers['base-server']).toBeDefined();
118
+ expect(loaded.servers['local-server']).toBeDefined();
119
+ // Both models should be present
120
+ expect(loaded.models).toHaveLength(2);
121
+ expect(loaded.models?.find((m) => m.name === 'base-model')).toBeDefined();
122
+ expect(loaded.models?.find((m) => m.name === 'local-model')).toBeDefined();
123
+ });
124
+
125
+ it('should override model by name in local config', () => {
126
+ // Write base config
127
+ writeFileSync(
128
+ resolve(testDir, '.mcps.yaml'),
129
+ YAML.stringify({
130
+ models: [
131
+ {
132
+ name: 'shared-model',
133
+ baseUrl: 'http://base:8080',
134
+ adapter: 'openai',
135
+ },
136
+ ],
137
+ }),
138
+ );
139
+
140
+ // Write local config with same model name (should override)
141
+ writeFileSync(
142
+ resolve(testDir, '.mcps.local.yaml'),
143
+ YAML.stringify({
144
+ models: [
145
+ {
146
+ name: 'shared-model',
147
+ baseUrl: 'http://local:9090',
148
+ adapter: 'anthropic',
149
+ },
150
+ ],
151
+ }),
152
+ );
153
+
154
+ const loaded = loadConfig(testDir);
155
+ // Only one model with the name
156
+ expect(loaded.models).toHaveLength(1);
157
+ expect(loaded.models?.[0].name).toBe('shared-model');
158
+ expect(loaded.models?.[0].baseUrl).toBe('http://local:9090');
159
+ expect(loaded.models?.[0].adapter).toBe('anthropic');
160
+ });
161
+ });
162
+ });
@@ -0,0 +1,198 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+ import consola from 'consola';
4
+ import YAML from 'yaml';
5
+ import { McpsConfigSchema, type McpsConfig, type ServerConfig } from './schema';
6
+
7
+ const log = consola.withTag('config');
8
+
9
+ /**
10
+ * Load .env files into process.env
11
+ * Priority: .env.local > .env (later files override earlier)
12
+ */
13
+ export function loadEnvFiles(cwd: string = process.cwd()): void {
14
+ const envFiles = ['.env', '.env.local'];
15
+
16
+ for (const envFile of envFiles) {
17
+ const filePath = resolve(cwd, envFile);
18
+ if (!existsSync(filePath)) continue;
19
+
20
+ try {
21
+ const content = readFileSync(filePath, 'utf-8');
22
+ const lines = content.split('\n');
23
+
24
+ for (const line of lines) {
25
+ const trimmed = line.trim();
26
+ // Skip empty lines and comments
27
+ if (!trimmed || trimmed.startsWith('#')) continue;
28
+
29
+ const eqIndex = trimmed.indexOf('=');
30
+ if (eqIndex === -1) continue;
31
+
32
+ const key = trimmed.slice(0, eqIndex).trim();
33
+ let value = trimmed.slice(eqIndex + 1).trim();
34
+
35
+ // Remove quotes if present
36
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
37
+ value = value.slice(1, -1);
38
+ }
39
+
40
+ // Only set if not already set (don't override existing env vars)
41
+ if (process.env[key] === undefined) {
42
+ process.env[key] = value;
43
+ log.debug(`Loaded env var ${key} from ${envFile}`);
44
+ }
45
+ }
46
+ log.info(`Loaded env file: ${envFile}`);
47
+ } catch (e) {
48
+ log.warn(`Failed to load ${envFile}:`, e);
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Parse config file content based on format
55
+ */
56
+ function parseConfigContent(content: string, format: 'yaml' | 'json'): unknown {
57
+ if (format === 'yaml') {
58
+ return YAML.parse(content);
59
+ }
60
+ return JSON.parse(content);
61
+ }
62
+
63
+ /**
64
+ * Load and parse a single config file
65
+ */
66
+ function loadConfigFile(filePath: string, format: 'yaml' | 'json'): { data: McpsConfig; path: string } | null {
67
+ if (!existsSync(filePath)) {
68
+ return null;
69
+ }
70
+
71
+ try {
72
+ const content = readFileSync(filePath, 'utf-8');
73
+ const parsed = parseConfigContent(content, format);
74
+ const result = McpsConfigSchema.safeParse(parsed);
75
+
76
+ if (result.success) {
77
+ log.info(`Loaded config from ${filePath}`);
78
+ return { data: result.data, path: filePath };
79
+ } else {
80
+ log.warn(`Invalid config in ${filePath}: ${result.error.message}`);
81
+ return null;
82
+ }
83
+ } catch (e) {
84
+ log.error(`Failed to load ${filePath}:`, e);
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Load config from multiple config files with priority merging
91
+ *
92
+ * Priority (highest to lowest):
93
+ * 1. .mcps.local.yaml/.yml/.json (local overrides for mcps)
94
+ * 2. .mcps.yaml/.yml/.json (base mcps config)
95
+ * 3. .mcp.local.yaml/.yml/.json (local overrides for mcp)
96
+ * 4. .mcp.yaml/.yml/.json (base mcp config)
97
+ *
98
+ * Within each group, YAML has higher priority than JSON.
99
+ * All found configs are merged, with higher priority configs overriding lower ones.
100
+ */
101
+ export function loadConfig(cwd: string = process.cwd()): McpsConfig {
102
+ const config: McpsConfig = { servers: {} };
103
+
104
+ // Load configs in reverse priority order (lowest first, so higher priority overwrites)
105
+ // We want: base configs first, then local configs
106
+ // And within each: json first, then yaml (yaml overwrites json)
107
+ const loadOrder = [
108
+ // Base MCP configs (lowest priority)
109
+ { path: '.mcp.json', format: 'json' as const },
110
+ { path: '.mcp.yml', format: 'yaml' as const },
111
+ { path: '.mcp.yaml', format: 'yaml' as const },
112
+ // Local MCP configs
113
+ { path: '.mcp.local.json', format: 'json' as const },
114
+ { path: '.mcp.local.yml', format: 'yaml' as const },
115
+ { path: '.mcp.local.yaml', format: 'yaml' as const },
116
+ // Base MCPS configs
117
+ { path: '.mcps.json', format: 'json' as const },
118
+ { path: '.mcps.yml', format: 'yaml' as const },
119
+ { path: '.mcps.yaml', format: 'yaml' as const },
120
+ // Local MCPS configs (highest priority)
121
+ { path: '.mcps.local.json', format: 'json' as const },
122
+ { path: '.mcps.local.yml', format: 'yaml' as const },
123
+ { path: '.mcps.local.yaml', format: 'yaml' as const },
124
+ ];
125
+
126
+ for (const { path: configPath, format } of loadOrder) {
127
+ const fullPath = resolve(cwd, configPath);
128
+ const result = loadConfigFile(fullPath, format);
129
+ if (result) {
130
+ // Merge servers, later configs take precedence
131
+ Object.assign(config.servers, result.data.servers);
132
+
133
+ // Merge models config (array format - later configs append/override by name)
134
+ if (result.data.models && result.data.models.length > 0) {
135
+ if (!config.models) {
136
+ config.models = [];
137
+ }
138
+ // Merge by name - later config overrides earlier ones with same name
139
+ for (const model of result.data.models) {
140
+ const existingIndex = config.models.findIndex((m) => m.name === model.name);
141
+ if (existingIndex >= 0) {
142
+ config.models[existingIndex] = model;
143
+ } else {
144
+ config.models.push(model);
145
+ }
146
+ }
147
+ }
148
+ }
149
+ }
150
+
151
+ // Filter disabled servers
152
+ for (const [name, serverConfig] of Object.entries(config.servers)) {
153
+ if (serverConfig.disabled) {
154
+ log.debug(`Server ${name} is disabled`);
155
+ delete config.servers[name];
156
+ }
157
+ }
158
+
159
+ return config;
160
+ }
161
+
162
+ /**
163
+ * Substitute environment variables in config values
164
+ * Supports ${VAR_NAME} syntax
165
+ */
166
+ export function substituteEnvVars(config: McpsConfig): McpsConfig {
167
+ const result: McpsConfig = { servers: {} };
168
+
169
+ for (const [name, serverConfig] of Object.entries(config.servers)) {
170
+ result.servers[name] = substituteEnvVarsInObject(serverConfig) as ServerConfig;
171
+ }
172
+
173
+ // Process models config
174
+ if (config.models) {
175
+ result.models = substituteEnvVarsInObject(config.models);
176
+ }
177
+
178
+ return result;
179
+ }
180
+
181
+ function substituteEnvVarsInObject<T>(obj: T): T {
182
+ if (typeof obj === 'string') {
183
+ return obj.replace(/\$\{([^}]+)\}/g, (_, varName) => {
184
+ return process.env[varName] ?? '';
185
+ }) as T;
186
+ }
187
+ if (Array.isArray(obj)) {
188
+ return obj.map(substituteEnvVarsInObject) as T;
189
+ }
190
+ if (obj && typeof obj === 'object') {
191
+ const result: Record<string, unknown> = {};
192
+ for (const [key, value] of Object.entries(obj)) {
193
+ result[key] = substituteEnvVarsInObject(value);
194
+ }
195
+ return result as T;
196
+ }
197
+ return obj;
198
+ }
@@ -0,0 +1,115 @@
1
+ import { MikroORM, type Options } from '@mikro-orm/core';
2
+ import { SqliteDriver } from '@mikro-orm/sqlite';
3
+ import { ChatRequestEntity } from '../entities/ChatRequestEntity';
4
+ import { McpRequestEntity } from '../entities/McpRequestEntity';
5
+ import { RequestLogEntity } from '../entities/RequestLogEntity';
6
+ import { ResponseEntity } from '../entities/ResponseEntity';
7
+ import type { DbConfig } from './schema';
8
+
9
+ let orm: MikroORM<SqliteDriver> | null = null;
10
+ let initPromise: Promise<MikroORM<SqliteDriver>> | null = null;
11
+ let storedDbConfig: DbConfig | undefined;
12
+
13
+ /**
14
+ * Get MikroORM configuration
15
+ */
16
+ export function getOrmConfig(dbConfig?: DbConfig): Options<SqliteDriver> {
17
+ const dbPath = dbConfig?.path || '.mcps.db';
18
+
19
+ return {
20
+ driver: SqliteDriver,
21
+ dbName: dbPath,
22
+ entities: [ChatRequestEntity, McpRequestEntity, RequestLogEntity, ResponseEntity],
23
+ // Enable debug in development
24
+ debug: process.env.NODE_ENV === 'development',
25
+ // Allow global context for simpler usage
26
+ allowGlobalContext: true,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Initialize MikroORM and sync schema
32
+ */
33
+ export async function initializeDb(dbConfig?: DbConfig): Promise<MikroORM<SqliteDriver>> {
34
+ if (orm) {
35
+ return orm;
36
+ }
37
+
38
+ // If already initializing, wait for the existing promise
39
+ if (initPromise) {
40
+ return initPromise;
41
+ }
42
+
43
+ storedDbConfig = dbConfig;
44
+
45
+ initPromise = (async () => {
46
+ const config = getOrmConfig(dbConfig);
47
+ orm = await MikroORM.init(config);
48
+
49
+ // Sync schema (create tables if not exist)
50
+ await orm.schema.update();
51
+
52
+ return orm;
53
+ })();
54
+
55
+ try {
56
+ return await initPromise;
57
+ } catch (e) {
58
+ // Reset on failure so retry is possible
59
+ initPromise = null;
60
+ throw e;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Configure DB for lazy initialization (stores config without initializing)
66
+ */
67
+ export function configureDb(dbConfig?: DbConfig): void {
68
+ storedDbConfig = dbConfig;
69
+ }
70
+
71
+ /**
72
+ * Ensure DB is initialized (lazy init on first call)
73
+ * Returns the ORM instance, initializing if needed
74
+ */
75
+ export async function ensureDbInitialized(): Promise<MikroORM<SqliteDriver>> {
76
+ if (orm) {
77
+ return orm;
78
+ }
79
+ return initializeDb(storedDbConfig);
80
+ }
81
+
82
+ /**
83
+ * Get MikroORM instance (must be initialized first)
84
+ */
85
+ export function getOrm(): MikroORM<SqliteDriver> {
86
+ if (!orm) {
87
+ throw new Error('Database not initialized. Call initializeDb() first.');
88
+ }
89
+ return orm;
90
+ }
91
+
92
+ /**
93
+ * Get EntityManager
94
+ */
95
+ export function getEntityManager() {
96
+ return getOrm().em;
97
+ }
98
+
99
+ /**
100
+ * Close database connection
101
+ */
102
+ export async function closeDb(): Promise<void> {
103
+ if (orm) {
104
+ await orm.close();
105
+ orm = null;
106
+ initPromise = null;
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Check if database is initialized
112
+ */
113
+ export function isDbInitialized(): boolean {
114
+ return orm !== null;
115
+ }
@@ -0,0 +1 @@
1
+ export { createServer } from './server';
@@ -0,0 +1,209 @@
1
+ import type { StreamableHTTPTransport } from '@hono/mcp';
2
+ import consola from 'consola';
3
+ import { HeaderNames } from './schema';
4
+
5
+ const mcpLog = consola.withTag('mcp');
6
+
7
+ /**
8
+ * Tool annotation filter options
9
+ */
10
+ export interface ToolFilterOptions {
11
+ /** Only include tools with readOnlyHint: true */
12
+ readonlyOnly?: boolean;
13
+ /** Include patterns (glob) */
14
+ includePatterns?: string[];
15
+ /** Exclude patterns (glob) */
16
+ excludePatterns?: string[];
17
+ }
18
+
19
+ /**
20
+ * Check if a pattern matches a string (simple glob support)
21
+ */
22
+ function matchPattern(pattern: string, value: string): boolean {
23
+ // Convert glob to regex
24
+ const regex = new RegExp(`^${pattern.replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i');
25
+ return regex.test(value);
26
+ }
27
+
28
+ /**
29
+ * Filter tools based on filter options
30
+ */
31
+ export function filterTools(tools: any[], options: ToolFilterOptions): any[] {
32
+ let filtered = tools;
33
+
34
+ // Filter by readonly annotation
35
+ if (options.readonlyOnly) {
36
+ filtered = filtered.filter((tool) => {
37
+ const readOnlyHint = tool.annotations?.readOnlyHint;
38
+ return readOnlyHint === true;
39
+ });
40
+ }
41
+
42
+ // Filter by include patterns
43
+ if (options.includePatterns && options.includePatterns.length > 0) {
44
+ filtered = filtered.filter((tool) => options.includePatterns?.some((pattern) => matchPattern(pattern, tool.name)));
45
+ }
46
+
47
+ // Filter by exclude patterns
48
+ if (options.excludePatterns && options.excludePatterns.length > 0) {
49
+ filtered = filtered.filter((tool) => !options.excludePatterns?.some((pattern) => matchPattern(pattern, tool.name)));
50
+ }
51
+
52
+ return filtered;
53
+ }
54
+
55
+ /**
56
+ * Create a logging and filtering wrapper for MCP transport
57
+ * - Logs tool calls and their results with duration
58
+ * - Filters tools/list response based on X-MCP-Readonly, X-MCP-Include, X-MCP-Exclude headers
59
+ */
60
+ export function createMcpLoggingHandler(transport: StreamableHTTPTransport, serverName: string) {
61
+ const originalHandleRequest = transport.handleRequest.bind(transport);
62
+
63
+ return async (c: Parameters<typeof transport.handleRequest>[0]) => {
64
+ const startTime = Date.now();
65
+
66
+ // Get filter headers
67
+ const readonlyHeader = c.req.header(HeaderNames.MCP_READONLY);
68
+ const includeHeader = c.req.header(HeaderNames.MCP_INCLUDE);
69
+ const excludeHeader = c.req.header(HeaderNames.MCP_EXCLUDE);
70
+
71
+ const filterOptions: ToolFilterOptions = {};
72
+ if (readonlyHeader?.toLowerCase() === 'true') {
73
+ filterOptions.readonlyOnly = true;
74
+ }
75
+ if (includeHeader) {
76
+ filterOptions.includePatterns = includeHeader.split(',').map((p) => p.trim());
77
+ }
78
+ if (excludeHeader) {
79
+ filterOptions.excludePatterns = excludeHeader.split(',').map((p) => p.trim());
80
+ }
81
+
82
+ const needsFiltering = filterOptions.readonlyOnly || filterOptions.includePatterns || filterOptions.excludePatterns;
83
+
84
+ // Log incoming request (clone body to avoid consuming it)
85
+ const contentType = c.req.header('content-type');
86
+ const isPost = c.req.method === 'POST';
87
+ let isToolsList = false;
88
+ let isToolsCall = false;
89
+ let toolName = '';
90
+ let parsedBody: any;
91
+
92
+ if (isPost && contentType?.includes('application/json')) {
93
+ try {
94
+ // Clone the request to read body without consuming it
95
+ const clonedReq = c.req.raw.clone();
96
+ parsedBody = await clonedReq.json();
97
+ // JSON-RPC request
98
+ if (parsedBody.method === 'tools/call' && parsedBody.params) {
99
+ const { name, arguments: args } = parsedBody.params;
100
+ toolName = name;
101
+ isToolsCall = true;
102
+ mcpLog.info(`→ [${serverName}] tools/call: ${name}`, args ? JSON.stringify(args).slice(0, 200) : '');
103
+ } else if (parsedBody.method === 'tools/list') {
104
+ mcpLog.debug(`→ [${serverName}] tools/list`);
105
+ isToolsList = true;
106
+ } else if (parsedBody.method) {
107
+ mcpLog.debug(`→ [${serverName}] ${parsedBody.method}`);
108
+ }
109
+ } catch {
110
+ // Ignore parse errors, let the transport handle them
111
+ }
112
+ }
113
+
114
+ // Call original handler with error handling
115
+ let response: Response | undefined;
116
+ try {
117
+ response = await originalHandleRequest(c);
118
+ } catch (e) {
119
+ const duration = Date.now() - startTime;
120
+ mcpLog.error(`✗ [${serverName}] handler error (${duration}ms):`, e);
121
+ throw e;
122
+ }
123
+
124
+ // For tools/call, we need to wait for the SSE stream to complete to get accurate duration
125
+ // Clone the response to read it without consuming the original
126
+ if (isToolsCall && response instanceof Response) {
127
+ const clonedResponse = response.clone();
128
+ // Read the cloned response in the background to measure actual duration
129
+ clonedResponse
130
+ .text()
131
+ .then(() => {
132
+ const duration = Date.now() - startTime;
133
+ mcpLog.info(`← [${serverName}] tools/call: ${toolName} (${duration}ms)`);
134
+ })
135
+ .catch(() => {
136
+ // Ignore read errors
137
+ });
138
+ }
139
+
140
+ // If this is a tools/list response and we need filtering, intercept and modify
141
+ if (isToolsList && needsFiltering && response instanceof Response) {
142
+ try {
143
+ const responseText = await response.clone().text();
144
+ const contentTypeHeader = response.headers.get('content-type') || '';
145
+
146
+ // Handle SSE format (event: message\ndata: {...})
147
+ if (contentTypeHeader.includes('text/event-stream')) {
148
+ // Parse SSE data - find the data line
149
+ const lines = responseText.split('\n');
150
+ let jsonData = '';
151
+ for (const line of lines) {
152
+ if (line.startsWith('data: ')) {
153
+ jsonData = line.slice(6);
154
+ break;
155
+ }
156
+ }
157
+
158
+ if (jsonData) {
159
+ const responseData = JSON.parse(jsonData);
160
+
161
+ if (responseData.result?.tools && Array.isArray(responseData.result.tools)) {
162
+ const originalCount = responseData.result.tools.length;
163
+ responseData.result.tools = filterTools(responseData.result.tools, filterOptions);
164
+ const filteredCount = responseData.result.tools.length;
165
+
166
+ if (filteredCount !== originalCount) {
167
+ mcpLog.info(
168
+ `← [${serverName}] tools/list: filtered ${originalCount} → ${filteredCount} tools (readonly=${filterOptions.readonlyOnly || false})`,
169
+ );
170
+ }
171
+
172
+ // Return new SSE response with filtered tools
173
+ const newSseData = `event: message\ndata: ${JSON.stringify(responseData)}\n\n`;
174
+ return new Response(newSseData, {
175
+ status: response.status,
176
+ headers: response.headers,
177
+ });
178
+ }
179
+ }
180
+ } else {
181
+ // Handle plain JSON response
182
+ const responseData = JSON.parse(responseText);
183
+
184
+ if (responseData.result?.tools && Array.isArray(responseData.result.tools)) {
185
+ const originalCount = responseData.result.tools.length;
186
+ responseData.result.tools = filterTools(responseData.result.tools, filterOptions);
187
+ const filteredCount = responseData.result.tools.length;
188
+
189
+ if (filteredCount !== originalCount) {
190
+ mcpLog.info(
191
+ `← [${serverName}] tools/list: filtered ${originalCount} → ${filteredCount} tools (readonly=${filterOptions.readonlyOnly || false})`,
192
+ );
193
+ }
194
+
195
+ return new Response(JSON.stringify(responseData), {
196
+ status: response.status,
197
+ headers: response.headers,
198
+ });
199
+ }
200
+ }
201
+ } catch (e) {
202
+ // If we can't parse/modify, return original
203
+ mcpLog.warn(`[${serverName}] Failed to filter tools/list response: ${e instanceof Error ? e.message : e}`);
204
+ }
205
+ }
206
+
207
+ return response;
208
+ };
209
+ }