@kirosnn/mosaic 0.0.91 → 0.73.0
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 +1 -1
- package/README.md +2 -6
- package/package.json +55 -48
- package/src/agent/Agent.ts +353 -131
- package/src/agent/context.ts +4 -4
- package/src/agent/prompts/systemPrompt.ts +209 -70
- package/src/agent/prompts/toolsPrompt.ts +285 -138
- package/src/agent/provider/anthropic.ts +109 -105
- package/src/agent/provider/google.ts +111 -107
- package/src/agent/provider/mistral.ts +95 -95
- package/src/agent/provider/ollama.ts +73 -17
- package/src/agent/provider/openai.ts +146 -102
- package/src/agent/provider/rateLimit.ts +178 -0
- package/src/agent/provider/reasoning.ts +29 -0
- package/src/agent/provider/xai.ts +108 -104
- package/src/agent/tools/definitions.ts +15 -1
- package/src/agent/tools/executor.ts +717 -98
- package/src/agent/tools/exploreExecutor.ts +20 -22
- package/src/agent/tools/fetch.ts +58 -0
- package/src/agent/tools/glob.ts +20 -4
- package/src/agent/tools/grep.ts +64 -9
- package/src/agent/tools/plan.ts +27 -0
- package/src/agent/tools/question.ts +7 -1
- package/src/agent/tools/read.ts +2 -0
- package/src/agent/types.ts +15 -14
- package/src/components/App.tsx +50 -8
- package/src/components/CustomInput.tsx +461 -77
- package/src/components/Main.tsx +1459 -1112
- package/src/components/Setup.tsx +1 -1
- package/src/components/ShortcutsModal.tsx +11 -8
- package/src/components/Welcome.tsx +1 -1
- package/src/components/main/ApprovalPanel.tsx +4 -3
- package/src/components/main/ChatPage.tsx +858 -516
- package/src/components/main/HomePage.tsx +58 -39
- package/src/components/main/QuestionPanel.tsx +52 -7
- package/src/components/main/ThinkingIndicator.tsx +13 -2
- package/src/components/main/types.ts +11 -10
- package/src/index.tsx +53 -25
- package/src/mcp/approvalPolicy.ts +148 -0
- package/src/mcp/cli/add.ts +185 -0
- package/src/mcp/cli/doctor.ts +77 -0
- package/src/mcp/cli/index.ts +85 -0
- package/src/mcp/cli/list.ts +50 -0
- package/src/mcp/cli/logs.ts +24 -0
- package/src/mcp/cli/manage.ts +99 -0
- package/src/mcp/cli/show.ts +53 -0
- package/src/mcp/cli/tools.ts +77 -0
- package/src/mcp/config.ts +223 -0
- package/src/mcp/index.ts +80 -0
- package/src/mcp/processManager.ts +299 -0
- package/src/mcp/rateLimiter.ts +50 -0
- package/src/mcp/registry.ts +151 -0
- package/src/mcp/schemaConverter.ts +100 -0
- package/src/mcp/servers/navigation.ts +854 -0
- package/src/mcp/toolCatalog.ts +169 -0
- package/src/mcp/types.ts +95 -0
- package/src/utils/approvalBridge.ts +45 -12
- package/src/utils/approvalModeBridge.ts +17 -0
- package/src/utils/commands/approvals.ts +48 -0
- package/src/utils/commands/compact.ts +30 -0
- package/src/utils/commands/echo.ts +1 -1
- package/src/utils/commands/image.ts +109 -0
- package/src/utils/commands/index.ts +9 -7
- package/src/utils/commands/new.ts +15 -0
- package/src/utils/commands/types.ts +3 -0
- package/src/utils/config.ts +3 -1
- package/src/utils/diffRendering.tsx +13 -16
- package/src/utils/exploreBridge.ts +10 -0
- package/src/utils/history.ts +82 -40
- package/src/utils/imageBridge.ts +28 -0
- package/src/utils/images.ts +31 -0
- package/src/utils/markdown.tsx +163 -99
- package/src/utils/models.ts +31 -16
- package/src/utils/notificationBridge.ts +23 -0
- package/src/utils/questionBridge.ts +36 -1
- package/src/utils/tokenEstimator.ts +32 -0
- package/src/utils/toolFormatting.ts +428 -48
- package/src/web/app.tsx +65 -5
- package/src/web/assets/css/ChatPage.css +102 -30
- package/src/web/assets/css/MessageItem.css +26 -29
- package/src/web/assets/css/ThinkingIndicator.css +44 -6
- package/src/web/assets/css/ToolMessage.css +36 -14
- package/src/web/components/ChatPage.tsx +228 -105
- package/src/web/components/HomePage.tsx +3 -3
- package/src/web/components/MessageItem.tsx +80 -81
- package/src/web/components/QuestionPanel.tsx +72 -12
- package/src/web/components/Setup.tsx +1 -1
- package/src/web/components/Sidebar.tsx +1 -3
- package/src/web/components/ThinkingIndicator.tsx +41 -21
- package/src/web/router.ts +1 -1
- package/src/web/server.tsx +894 -662
- package/src/web/storage.ts +23 -1
- package/src/web/types.ts +7 -6
- package/src/utils/commands/redo.ts +0 -74
- package/src/utils/commands/sessions.ts +0 -129
- package/src/utils/commands/undo.ts +0 -75
- package/src/utils/undoRedo.ts +0 -429
- package/src/utils/undoRedoBridge.ts +0 -45
- package/src/utils/undoRedoDb.ts +0 -338
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { homedir } from 'os';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
import type { McpServerConfig, McpGlobalConfig } from './types';
|
|
6
|
+
|
|
7
|
+
const MCP_DIR = join(homedir(), '.mosaic', 'mcp');
|
|
8
|
+
const CONFIG_FILE = join(MCP_DIR, 'config.json');
|
|
9
|
+
const SERVERS_DIR = join(MCP_DIR, 'servers');
|
|
10
|
+
|
|
11
|
+
function ensureDirs(): void {
|
|
12
|
+
if (!existsSync(MCP_DIR)) mkdirSync(MCP_DIR, { recursive: true });
|
|
13
|
+
if (!existsSync(SERVERS_DIR)) mkdirSync(SERVERS_DIR, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getDefaultServerConfig(): Partial<McpServerConfig> {
|
|
17
|
+
return {
|
|
18
|
+
enabled: true,
|
|
19
|
+
transport: { type: 'stdio' },
|
|
20
|
+
args: [],
|
|
21
|
+
autostart: 'startup',
|
|
22
|
+
timeouts: {
|
|
23
|
+
initialize: 30000,
|
|
24
|
+
call: 60000,
|
|
25
|
+
},
|
|
26
|
+
limits: {
|
|
27
|
+
maxCallsPerMinute: 60,
|
|
28
|
+
maxPayloadBytes: 1024 * 1024,
|
|
29
|
+
},
|
|
30
|
+
logs: {
|
|
31
|
+
persist: false,
|
|
32
|
+
bufferSize: 200,
|
|
33
|
+
},
|
|
34
|
+
tools: {},
|
|
35
|
+
approval: 'always',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getNavigationServerConfig(): Partial<McpServerConfig> {
|
|
40
|
+
const serverPath = fileURLToPath(new URL('./servers/navigation.ts', import.meta.url));
|
|
41
|
+
return {
|
|
42
|
+
id: 'navigation',
|
|
43
|
+
name: 'Navigation',
|
|
44
|
+
command: 'bun',
|
|
45
|
+
args: ['run', serverPath],
|
|
46
|
+
enabled: true,
|
|
47
|
+
autostart: 'startup',
|
|
48
|
+
approval: 'always',
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateServerConfig(config: Partial<McpServerConfig>): string[] {
|
|
53
|
+
const errors: string[] = [];
|
|
54
|
+
|
|
55
|
+
if (!config.id || typeof config.id !== 'string') {
|
|
56
|
+
errors.push('Server id is required and must be a string');
|
|
57
|
+
} else if (!/^[a-zA-Z0-9_-]+$/.test(config.id)) {
|
|
58
|
+
errors.push('Server id must contain only alphanumeric characters, hyphens, and underscores');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!config.name || typeof config.name !== 'string') {
|
|
62
|
+
errors.push('Server name is required and must be a string');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!config.command || typeof config.command !== 'string') {
|
|
66
|
+
errors.push('Server command is required and must be a string');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (config.args && !Array.isArray(config.args)) {
|
|
70
|
+
errors.push('Server args must be an array of strings');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (config.autostart && !['startup', 'on-demand', 'never'].includes(config.autostart)) {
|
|
74
|
+
errors.push('Server autostart must be "startup", "on-demand", or "never"');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (config.approval && !['always', 'once-per-tool', 'once-per-server', 'never'].includes(config.approval)) {
|
|
78
|
+
errors.push('Server approval must be "always", "once-per-tool", "once-per-server", or "never"');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return errors;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function mergeWithDefaults(partial: Partial<McpServerConfig>): McpServerConfig {
|
|
85
|
+
const defaults = getDefaultServerConfig();
|
|
86
|
+
return {
|
|
87
|
+
id: partial.id!,
|
|
88
|
+
name: partial.name || partial.id!,
|
|
89
|
+
enabled: partial.enabled ?? defaults.enabled!,
|
|
90
|
+
transport: partial.transport || defaults.transport!,
|
|
91
|
+
command: partial.command!,
|
|
92
|
+
args: partial.args || defaults.args!,
|
|
93
|
+
cwd: partial.cwd,
|
|
94
|
+
env: partial.env,
|
|
95
|
+
autostart: partial.autostart || defaults.autostart!,
|
|
96
|
+
timeouts: { ...defaults.timeouts!, ...partial.timeouts },
|
|
97
|
+
limits: { ...defaults.limits!, ...partial.limits },
|
|
98
|
+
logs: { ...defaults.logs!, ...partial.logs },
|
|
99
|
+
tools: { ...defaults.tools, ...partial.tools },
|
|
100
|
+
approval: partial.approval || defaults.approval!,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function loadGlobalConfigFile(): Partial<McpGlobalConfig> {
|
|
105
|
+
if (!existsSync(CONFIG_FILE)) return {};
|
|
106
|
+
try {
|
|
107
|
+
const content = readFileSync(CONFIG_FILE, 'utf-8');
|
|
108
|
+
return JSON.parse(content);
|
|
109
|
+
} catch {
|
|
110
|
+
return {};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function loadServerFiles(): Partial<McpServerConfig>[] {
|
|
115
|
+
if (!existsSync(SERVERS_DIR)) return [];
|
|
116
|
+
const files = readdirSync(SERVERS_DIR).filter(f => f.endsWith('.json'));
|
|
117
|
+
const configs: Partial<McpServerConfig>[] = [];
|
|
118
|
+
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
try {
|
|
121
|
+
const content = readFileSync(join(SERVERS_DIR, file), 'utf-8');
|
|
122
|
+
const parsed = JSON.parse(content);
|
|
123
|
+
if (!parsed.id) {
|
|
124
|
+
parsed.id = file.replace(/\.json$/, '');
|
|
125
|
+
}
|
|
126
|
+
configs.push(parsed);
|
|
127
|
+
} catch {
|
|
128
|
+
// skip invalid files
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return configs;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function loadMcpConfig(): McpServerConfig[] {
|
|
136
|
+
ensureDirs();
|
|
137
|
+
|
|
138
|
+
const globalConfig = loadGlobalConfigFile();
|
|
139
|
+
const serverFiles = loadServerFiles();
|
|
140
|
+
|
|
141
|
+
const configMap = new Map<string, Partial<McpServerConfig>>();
|
|
142
|
+
|
|
143
|
+
if (globalConfig.servers) {
|
|
144
|
+
for (const server of globalConfig.servers) {
|
|
145
|
+
if (server.id) {
|
|
146
|
+
configMap.set(server.id, server);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
for (const server of serverFiles) {
|
|
152
|
+
if (server.id) {
|
|
153
|
+
const existing = configMap.get(server.id);
|
|
154
|
+
if (existing) {
|
|
155
|
+
configMap.set(server.id, { ...existing, ...server });
|
|
156
|
+
} else {
|
|
157
|
+
configMap.set(server.id, server);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!configMap.has('navigation')) {
|
|
163
|
+
configMap.set('navigation', getNavigationServerConfig());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const results: McpServerConfig[] = [];
|
|
167
|
+
for (const [, partial] of configMap) {
|
|
168
|
+
const errors = validateServerConfig(partial);
|
|
169
|
+
if (errors.length === 0) {
|
|
170
|
+
results.push(mergeWithDefaults(partial));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return results;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function saveServerConfig(config: Partial<McpServerConfig>): void {
|
|
178
|
+
ensureDirs();
|
|
179
|
+
const errors = validateServerConfig(config);
|
|
180
|
+
if (errors.length > 0) {
|
|
181
|
+
throw new Error(`Invalid server config: ${errors.join(', ')}`);
|
|
182
|
+
}
|
|
183
|
+
const filePath = join(SERVERS_DIR, `${config.id}.json`);
|
|
184
|
+
writeFileSync(filePath, JSON.stringify(config, null, 2), 'utf-8');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function removeServerConfig(id: string): boolean {
|
|
188
|
+
const filePath = join(SERVERS_DIR, `${id}.json`);
|
|
189
|
+
if (existsSync(filePath)) {
|
|
190
|
+
unlinkSync(filePath);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const globalConfig = loadGlobalConfigFile();
|
|
195
|
+
if (globalConfig.servers) {
|
|
196
|
+
const idx = globalConfig.servers.findIndex(s => s.id === id);
|
|
197
|
+
if (idx !== -1) {
|
|
198
|
+
globalConfig.servers.splice(idx, 1);
|
|
199
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(globalConfig, null, 2), 'utf-8');
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function updateServerConfig(id: string, updates: Partial<McpServerConfig>): McpServerConfig | null {
|
|
208
|
+
const configs = loadMcpConfig();
|
|
209
|
+
const existing = configs.find(c => c.id === id);
|
|
210
|
+
if (!existing) return null;
|
|
211
|
+
|
|
212
|
+
const updated = { ...existing, ...updates, id };
|
|
213
|
+
saveServerConfig(updated);
|
|
214
|
+
return updated;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function getMcpConfigDir(): string {
|
|
218
|
+
return MCP_DIR;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function getServersDir(): string {
|
|
222
|
+
return SERVERS_DIR;
|
|
223
|
+
}
|
package/src/mcp/index.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { loadMcpConfig } from './config';
|
|
2
|
+
import { McpProcessManager } from './processManager';
|
|
3
|
+
import { McpApprovalPolicy } from './approvalPolicy';
|
|
4
|
+
import { McpToolCatalog } from './toolCatalog';
|
|
5
|
+
|
|
6
|
+
export type { McpServerConfig, McpServerState, McpToolInfo, McpRiskHint, McpGlobalConfig } from './types';
|
|
7
|
+
export { toCanonicalId, toSafeId, parseSafeId, parseCanonicalId } from './types';
|
|
8
|
+
|
|
9
|
+
let manager: McpProcessManager | null = null;
|
|
10
|
+
let catalog: McpToolCatalog | null = null;
|
|
11
|
+
let approvalPolicy: McpApprovalPolicy | null = null;
|
|
12
|
+
let initialized = false;
|
|
13
|
+
|
|
14
|
+
export function getMcpManager(): McpProcessManager {
|
|
15
|
+
if (!manager) {
|
|
16
|
+
manager = new McpProcessManager();
|
|
17
|
+
}
|
|
18
|
+
return manager;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getMcpCatalog(): McpToolCatalog {
|
|
22
|
+
if (!catalog) {
|
|
23
|
+
throw new Error('MCP not initialized. Call initializeMcp() first.');
|
|
24
|
+
}
|
|
25
|
+
return catalog;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getMcpApprovalPolicy(): McpApprovalPolicy {
|
|
29
|
+
if (!approvalPolicy) {
|
|
30
|
+
approvalPolicy = new McpApprovalPolicy();
|
|
31
|
+
}
|
|
32
|
+
return approvalPolicy;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function initializeMcp(): Promise<string[]> {
|
|
36
|
+
if (initialized) return [];
|
|
37
|
+
|
|
38
|
+
const configs = loadMcpConfig();
|
|
39
|
+
if (configs.length === 0) {
|
|
40
|
+
initialized = true;
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
manager = new McpProcessManager();
|
|
45
|
+
approvalPolicy = new McpApprovalPolicy();
|
|
46
|
+
catalog = new McpToolCatalog(manager, approvalPolicy, configs);
|
|
47
|
+
|
|
48
|
+
const startupServers = configs.filter(c => c.enabled && c.autostart === 'startup');
|
|
49
|
+
const failedServers: string[] = [];
|
|
50
|
+
|
|
51
|
+
for (const config of startupServers) {
|
|
52
|
+
try {
|
|
53
|
+
await manager.startServer(config);
|
|
54
|
+
} catch {
|
|
55
|
+
failedServers.push(config.id || config.command || 'unknown');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (failedServers.length > 0) {
|
|
60
|
+
console.error(`MCP: failed to start servers: ${failedServers.join(', ')}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
catalog.refreshTools();
|
|
64
|
+
initialized = true;
|
|
65
|
+
return failedServers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function shutdownMcp(): Promise<void> {
|
|
69
|
+
if (manager) {
|
|
70
|
+
await manager.shutdownAll();
|
|
71
|
+
}
|
|
72
|
+
manager = null;
|
|
73
|
+
catalog = null;
|
|
74
|
+
approvalPolicy = null;
|
|
75
|
+
initialized = false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isMcpInitialized(): boolean {
|
|
79
|
+
return initialized;
|
|
80
|
+
}
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
import type { McpServerConfig, McpServerState, McpToolInfo } from './types';
|
|
4
|
+
import { toCanonicalId, toSafeId } from './types';
|
|
5
|
+
import { McpRateLimiter } from './rateLimiter';
|
|
6
|
+
|
|
7
|
+
interface LogEntry {
|
|
8
|
+
timestamp: number;
|
|
9
|
+
level: 'info' | 'error' | 'debug';
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface ServerInstance {
|
|
14
|
+
config: McpServerConfig;
|
|
15
|
+
client: Client;
|
|
16
|
+
transport: StdioClientTransport;
|
|
17
|
+
state: McpServerState;
|
|
18
|
+
tools: McpToolInfo[];
|
|
19
|
+
logBuffer: LogEntry[];
|
|
20
|
+
restartCount: number;
|
|
21
|
+
lastRestartAt: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_RESTART_COUNT = 5;
|
|
25
|
+
const BACKOFF_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];
|
|
26
|
+
|
|
27
|
+
export class McpProcessManager {
|
|
28
|
+
private servers = new Map<string, ServerInstance>();
|
|
29
|
+
private rateLimiter = new McpRateLimiter();
|
|
30
|
+
|
|
31
|
+
async startServer(config: McpServerConfig): Promise<McpServerState> {
|
|
32
|
+
const existing = this.servers.get(config.id);
|
|
33
|
+
if (existing && existing.state.status === 'running') {
|
|
34
|
+
return existing.state;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const state: McpServerState = {
|
|
38
|
+
status: 'starting',
|
|
39
|
+
toolCount: 0,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const logBuffer: LogEntry[] = [];
|
|
43
|
+
|
|
44
|
+
const addLog = (level: LogEntry['level'], message: string) => {
|
|
45
|
+
logBuffer.push({ timestamp: Date.now(), level, message });
|
|
46
|
+
if (logBuffer.length > config.logs.bufferSize) {
|
|
47
|
+
logBuffer.shift();
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
addLog('info', `Starting server ${config.id} (${config.command})`);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const startTime = Date.now();
|
|
55
|
+
|
|
56
|
+
const transport = new StdioClientTransport({
|
|
57
|
+
command: config.command,
|
|
58
|
+
args: config.args,
|
|
59
|
+
env: config.env ? { ...process.env, ...config.env } as Record<string, string> : undefined,
|
|
60
|
+
cwd: config.cwd,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const client = new Client(
|
|
64
|
+
{ name: 'mosaic', version: '1.0.0' },
|
|
65
|
+
{ capabilities: {} }
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
await client.connect(transport);
|
|
69
|
+
|
|
70
|
+
const initLatencyMs = Date.now() - startTime;
|
|
71
|
+
addLog('info', `Connected in ${initLatencyMs}ms`);
|
|
72
|
+
|
|
73
|
+
const toolsResult = await client.listTools();
|
|
74
|
+
const tools: McpToolInfo[] = (toolsResult.tools || []).map(t => ({
|
|
75
|
+
serverId: config.id,
|
|
76
|
+
name: t.name,
|
|
77
|
+
description: t.description || '',
|
|
78
|
+
inputSchema: (t.inputSchema || {}) as Record<string, unknown>,
|
|
79
|
+
canonicalId: toCanonicalId(config.id, t.name),
|
|
80
|
+
safeId: toSafeId(config.id, t.name),
|
|
81
|
+
}));
|
|
82
|
+
|
|
83
|
+
state.status = 'running';
|
|
84
|
+
state.initLatencyMs = initLatencyMs;
|
|
85
|
+
state.toolCount = tools.length;
|
|
86
|
+
|
|
87
|
+
addLog('info', `Listed ${tools.length} tools`);
|
|
88
|
+
|
|
89
|
+
this.rateLimiter.configure(config.id, config.limits.maxCallsPerMinute);
|
|
90
|
+
|
|
91
|
+
const instance: ServerInstance = {
|
|
92
|
+
config,
|
|
93
|
+
client,
|
|
94
|
+
transport,
|
|
95
|
+
state,
|
|
96
|
+
tools,
|
|
97
|
+
logBuffer,
|
|
98
|
+
restartCount: existing?.restartCount ?? 0,
|
|
99
|
+
lastRestartAt: existing?.lastRestartAt ?? 0,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
this.servers.set(config.id, instance);
|
|
103
|
+
|
|
104
|
+
transport.onclose = () => {
|
|
105
|
+
const srv = this.servers.get(config.id);
|
|
106
|
+
if (srv && srv.state.status === 'running') {
|
|
107
|
+
srv.state.status = 'error';
|
|
108
|
+
srv.state.lastError = 'Transport closed unexpectedly';
|
|
109
|
+
addLog('error', 'Transport closed unexpectedly');
|
|
110
|
+
this.attemptRestart(config.id);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
transport.onerror = (error: Error) => {
|
|
115
|
+
addLog('error', `Transport error: ${error.message}`);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return state;
|
|
119
|
+
} catch (error) {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
state.status = 'error';
|
|
122
|
+
state.lastError = message;
|
|
123
|
+
addLog('error', `Failed to start: ${message}`);
|
|
124
|
+
|
|
125
|
+
this.servers.set(config.id, {
|
|
126
|
+
config,
|
|
127
|
+
client: null!,
|
|
128
|
+
transport: null!,
|
|
129
|
+
state,
|
|
130
|
+
tools: [],
|
|
131
|
+
logBuffer,
|
|
132
|
+
restartCount: existing?.restartCount ?? 0,
|
|
133
|
+
lastRestartAt: existing?.lastRestartAt ?? 0,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
return state;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async stopServer(id: string): Promise<void> {
|
|
141
|
+
const instance = this.servers.get(id);
|
|
142
|
+
if (!instance) return;
|
|
143
|
+
|
|
144
|
+
instance.state.status = 'stopped';
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
if (instance.client) {
|
|
148
|
+
await instance.client.close();
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
// best effort
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.rateLimiter.remove(id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async restartServer(id: string): Promise<McpServerState | null> {
|
|
158
|
+
const instance = this.servers.get(id);
|
|
159
|
+
if (!instance) return null;
|
|
160
|
+
|
|
161
|
+
await this.stopServer(id);
|
|
162
|
+
return this.startServer(instance.config);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async callTool(serverId: string, toolName: string, args: Record<string, unknown>): Promise<{ content: string; isError: boolean }> {
|
|
166
|
+
const instance = this.servers.get(serverId);
|
|
167
|
+
if (!instance) {
|
|
168
|
+
return { content: `Server ${serverId} not found`, isError: true };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (instance.state.status !== 'running') {
|
|
172
|
+
return { content: `Server ${serverId} is not running (status: ${instance.state.status})`, isError: true };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const payloadSize = JSON.stringify(args).length;
|
|
176
|
+
if (payloadSize > instance.config.limits.maxPayloadBytes) {
|
|
177
|
+
return {
|
|
178
|
+
content: `Payload too large: ${payloadSize} bytes (max: ${instance.config.limits.maxPayloadBytes})`,
|
|
179
|
+
isError: true,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await this.rateLimiter.acquire(serverId);
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const timeout = instance.config.timeouts.call;
|
|
187
|
+
const controller = new AbortController();
|
|
188
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
189
|
+
|
|
190
|
+
const result = await instance.client.callTool(
|
|
191
|
+
{ name: toolName, arguments: args },
|
|
192
|
+
undefined,
|
|
193
|
+
{ signal: controller.signal }
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
clearTimeout(timer);
|
|
197
|
+
instance.state.lastCallAt = Date.now();
|
|
198
|
+
|
|
199
|
+
const contentParts = result.content as Array<{ type: string; text?: string }>;
|
|
200
|
+
const text = contentParts
|
|
201
|
+
.filter(p => p.type === 'text')
|
|
202
|
+
.map(p => p.text || '')
|
|
203
|
+
.join('\n');
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
content: text || JSON.stringify(result.content),
|
|
207
|
+
isError: result.isError === true,
|
|
208
|
+
};
|
|
209
|
+
} catch (error) {
|
|
210
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
211
|
+
instance.logBuffer.push({
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
level: 'error',
|
|
214
|
+
message: `callTool ${toolName} failed: ${message}`,
|
|
215
|
+
});
|
|
216
|
+
return { content: `Tool call failed: ${message}`, isError: true };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
listTools(serverId: string): McpToolInfo[] {
|
|
221
|
+
const instance = this.servers.get(serverId);
|
|
222
|
+
if (!instance) return [];
|
|
223
|
+
return [...instance.tools];
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
getAllTools(): McpToolInfo[] {
|
|
227
|
+
const all: McpToolInfo[] = [];
|
|
228
|
+
for (const instance of this.servers.values()) {
|
|
229
|
+
if (instance.state.status === 'running') {
|
|
230
|
+
all.push(...instance.tools);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return all;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
getState(serverId: string): McpServerState | null {
|
|
237
|
+
const instance = this.servers.get(serverId);
|
|
238
|
+
return instance ? { ...instance.state } : null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
getAllStates(): Map<string, McpServerState> {
|
|
242
|
+
const states = new Map<string, McpServerState>();
|
|
243
|
+
for (const [id, instance] of this.servers) {
|
|
244
|
+
states.set(id, { ...instance.state });
|
|
245
|
+
}
|
|
246
|
+
return states;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
getLogs(serverId: string): LogEntry[] {
|
|
250
|
+
const instance = this.servers.get(serverId);
|
|
251
|
+
if (!instance) return [];
|
|
252
|
+
return [...instance.logBuffer];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
getConfig(serverId: string): McpServerConfig | null {
|
|
256
|
+
const instance = this.servers.get(serverId);
|
|
257
|
+
return instance ? instance.config : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async shutdownAll(): Promise<void> {
|
|
261
|
+
const promises: Promise<void>[] = [];
|
|
262
|
+
for (const id of this.servers.keys()) {
|
|
263
|
+
promises.push(this.stopServer(id));
|
|
264
|
+
}
|
|
265
|
+
await Promise.allSettled(promises);
|
|
266
|
+
this.servers.clear();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private async attemptRestart(id: string): Promise<void> {
|
|
270
|
+
const instance = this.servers.get(id);
|
|
271
|
+
if (!instance) return;
|
|
272
|
+
|
|
273
|
+
if (instance.restartCount >= MAX_RESTART_COUNT) {
|
|
274
|
+
instance.logBuffer.push({
|
|
275
|
+
timestamp: Date.now(),
|
|
276
|
+
level: 'error',
|
|
277
|
+
message: `Max restart count (${MAX_RESTART_COUNT}) reached, giving up`,
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const delay = BACKOFF_DELAYS[Math.min(instance.restartCount, BACKOFF_DELAYS.length - 1)]!;
|
|
283
|
+
instance.restartCount++;
|
|
284
|
+
instance.lastRestartAt = Date.now();
|
|
285
|
+
|
|
286
|
+
instance.logBuffer.push({
|
|
287
|
+
timestamp: Date.now(),
|
|
288
|
+
level: 'info',
|
|
289
|
+
message: `Scheduling restart #${instance.restartCount} in ${delay}ms`,
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
293
|
+
|
|
294
|
+
const current = this.servers.get(id);
|
|
295
|
+
if (current && current.state.status !== 'running' && current.state.status !== 'stopped') {
|
|
296
|
+
await this.startServer(instance.config);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
interface TokenBucket {
|
|
2
|
+
tokens: number;
|
|
3
|
+
maxTokens: number;
|
|
4
|
+
refillRate: number;
|
|
5
|
+
lastRefill: number;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class McpRateLimiter {
|
|
9
|
+
private buckets = new Map<string, TokenBucket>();
|
|
10
|
+
|
|
11
|
+
configure(serverId: string, maxCallsPerMinute: number): void {
|
|
12
|
+
this.buckets.set(serverId, {
|
|
13
|
+
tokens: maxCallsPerMinute,
|
|
14
|
+
maxTokens: maxCallsPerMinute,
|
|
15
|
+
refillRate: maxCallsPerMinute / 60,
|
|
16
|
+
lastRefill: Date.now(),
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
tryAcquire(serverId: string): boolean {
|
|
21
|
+
const bucket = this.buckets.get(serverId);
|
|
22
|
+
if (!bucket) return true;
|
|
23
|
+
|
|
24
|
+
this.refill(bucket);
|
|
25
|
+
|
|
26
|
+
if (bucket.tokens >= 1) {
|
|
27
|
+
bucket.tokens -= 1;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async acquire(serverId: string): Promise<void> {
|
|
35
|
+
while (!this.tryAcquire(serverId)) {
|
|
36
|
+
await new Promise(resolve => setTimeout(resolve, 100));
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
remove(serverId: string): void {
|
|
41
|
+
this.buckets.delete(serverId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private refill(bucket: TokenBucket): void {
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const elapsed = (now - bucket.lastRefill) / 1000;
|
|
47
|
+
bucket.tokens = Math.min(bucket.maxTokens, bucket.tokens + elapsed * bucket.refillRate);
|
|
48
|
+
bucket.lastRefill = now;
|
|
49
|
+
}
|
|
50
|
+
}
|