@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.
- package/LICENSE +21 -0
- package/dist/index.mjs +15 -0
- package/dist/mcps-cli.mjs +174727 -0
- package/lib/chat/agent.js +187 -0
- package/lib/chat/agent.js.map +1 -0
- package/lib/chat/audit.js +238 -0
- package/lib/chat/audit.js.map +1 -0
- package/lib/chat/converters.js +467 -0
- package/lib/chat/converters.js.map +1 -0
- package/lib/chat/handler.js +1068 -0
- package/lib/chat/handler.js.map +1 -0
- package/lib/chat/index.js +12 -0
- package/lib/chat/index.js.map +1 -0
- package/lib/chat/types.js +35 -0
- package/lib/chat/types.js.map +1 -0
- package/lib/contracts/AuditContract.js +85 -0
- package/lib/contracts/AuditContract.js.map +1 -0
- package/lib/contracts/McpsContract.js +113 -0
- package/lib/contracts/McpsContract.js.map +1 -0
- package/lib/contracts/index.js +3 -0
- package/lib/contracts/index.js.map +1 -0
- package/lib/dev.server.js +7 -0
- package/lib/dev.server.js.map +1 -0
- package/lib/entities/ChatRequestEntity.js +318 -0
- package/lib/entities/ChatRequestEntity.js.map +1 -0
- package/lib/entities/McpRequestEntity.js +271 -0
- package/lib/entities/McpRequestEntity.js.map +1 -0
- package/lib/entities/RequestLogEntity.js +177 -0
- package/lib/entities/RequestLogEntity.js.map +1 -0
- package/lib/entities/ResponseEntity.js +150 -0
- package/lib/entities/ResponseEntity.js.map +1 -0
- package/lib/entities/index.js +11 -0
- package/lib/entities/index.js.map +1 -0
- package/lib/entities/types.js +11 -0
- package/lib/entities/types.js.map +1 -0
- package/lib/index.js +3 -0
- package/lib/index.js.map +1 -0
- package/lib/mcps-cli.js +44 -0
- package/lib/mcps-cli.js.map +1 -0
- package/lib/providers/McpServerHandlerDef.js +40 -0
- package/lib/providers/McpServerHandlerDef.js.map +1 -0
- package/lib/providers/findMcpServerDef.js +26 -0
- package/lib/providers/findMcpServerDef.js.map +1 -0
- package/lib/providers/prometheus/def.js +24 -0
- package/lib/providers/prometheus/def.js.map +1 -0
- package/lib/providers/prometheus/index.js +2 -0
- package/lib/providers/prometheus/index.js.map +1 -0
- package/lib/providers/relay/def.js +32 -0
- package/lib/providers/relay/def.js.map +1 -0
- package/lib/providers/relay/index.js +2 -0
- package/lib/providers/relay/index.js.map +1 -0
- package/lib/providers/sql/def.js +31 -0
- package/lib/providers/sql/def.js.map +1 -0
- package/lib/providers/sql/index.js +2 -0
- package/lib/providers/sql/index.js.map +1 -0
- package/lib/providers/tencent-cls/def.js +44 -0
- package/lib/providers/tencent-cls/def.js.map +1 -0
- package/lib/providers/tencent-cls/index.js +2 -0
- package/lib/providers/tencent-cls/index.js.map +1 -0
- package/lib/scripts/bundle.js +90 -0
- package/lib/scripts/bundle.js.map +1 -0
- package/lib/server/api-routes.js +96 -0
- package/lib/server/api-routes.js.map +1 -0
- package/lib/server/audit.js +274 -0
- package/lib/server/audit.js.map +1 -0
- package/lib/server/chat-routes.js +82 -0
- package/lib/server/chat-routes.js.map +1 -0
- package/lib/server/config.js +223 -0
- package/lib/server/config.js.map +1 -0
- package/lib/server/db.js +97 -0
- package/lib/server/db.js.map +1 -0
- package/lib/server/index.js +2 -0
- package/lib/server/index.js.map +1 -0
- package/lib/server/mcp-handler.js +167 -0
- package/lib/server/mcp-handler.js.map +1 -0
- package/lib/server/mcp-routes.js +112 -0
- package/lib/server/mcp-routes.js.map +1 -0
- package/lib/server/mcps-router.js +119 -0
- package/lib/server/mcps-router.js.map +1 -0
- package/lib/server/schema.js +129 -0
- package/lib/server/schema.js.map +1 -0
- package/lib/server/server.js +166 -0
- package/lib/server/server.js.map +1 -0
- package/lib/web/ChatPage.js +827 -0
- package/lib/web/ChatPage.js.map +1 -0
- package/lib/web/McpInspectorPage.js +214 -0
- package/lib/web/McpInspectorPage.js.map +1 -0
- package/lib/web/ServersPage.js +93 -0
- package/lib/web/ServersPage.js.map +1 -0
- package/lib/web/main.js +541 -0
- package/lib/web/main.js.map +1 -0
- package/package.json +83 -0
- package/src/chat/agent.ts +240 -0
- package/src/chat/audit.ts +377 -0
- package/src/chat/converters.test.ts +325 -0
- package/src/chat/converters.ts +459 -0
- package/src/chat/handler.test.ts +137 -0
- package/src/chat/handler.ts +1233 -0
- package/src/chat/index.ts +16 -0
- package/src/chat/types.ts +72 -0
- package/src/contracts/AuditContract.ts +93 -0
- package/src/contracts/McpsContract.ts +141 -0
- package/src/contracts/index.ts +18 -0
- package/src/dev.server.ts +7 -0
- package/src/entities/ChatRequestEntity.ts +157 -0
- package/src/entities/McpRequestEntity.ts +149 -0
- package/src/entities/RequestLogEntity.ts +78 -0
- package/src/entities/ResponseEntity.ts +75 -0
- package/src/entities/index.ts +12 -0
- package/src/entities/types.ts +188 -0
- package/src/index.ts +1 -0
- package/src/mcps-cli.ts +59 -0
- package/src/providers/McpServerHandlerDef.ts +105 -0
- package/src/providers/findMcpServerDef.ts +31 -0
- package/src/providers/prometheus/def.ts +21 -0
- package/src/providers/prometheus/index.ts +1 -0
- package/src/providers/relay/def.ts +31 -0
- package/src/providers/relay/index.ts +1 -0
- package/src/providers/relay/relay.test.ts +47 -0
- package/src/providers/sql/def.ts +33 -0
- package/src/providers/sql/index.ts +1 -0
- package/src/providers/tencent-cls/def.ts +38 -0
- package/src/providers/tencent-cls/index.ts +1 -0
- package/src/scripts/bundle.ts +82 -0
- package/src/server/api-routes.ts +98 -0
- package/src/server/audit.ts +310 -0
- package/src/server/chat-routes.ts +95 -0
- package/src/server/config.test.ts +162 -0
- package/src/server/config.ts +198 -0
- package/src/server/db.ts +115 -0
- package/src/server/index.ts +1 -0
- package/src/server/mcp-handler.ts +209 -0
- package/src/server/mcp-routes.ts +133 -0
- package/src/server/mcps-router.ts +133 -0
- package/src/server/schema.ts +175 -0
- package/src/server/server.ts +163 -0
- package/src/web/ChatPage.tsx +1005 -0
- package/src/web/McpInspectorPage.tsx +254 -0
- package/src/web/ServersPage.tsx +139 -0
- package/src/web/main.tsx +600 -0
- 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
|
+
}
|
package/src/server/db.ts
ADDED
|
@@ -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
|
+
}
|