@kirosnn/mosaic 0.73.0 → 0.74.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.
@@ -1,299 +1,304 @@
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
- }
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
+ let timedOut = false;
189
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, timeout);
190
+
191
+ const result = await instance.client.callTool(
192
+ { name: toolName, arguments: args },
193
+ undefined,
194
+ { signal: controller.signal, timeout: timeout }
195
+ );
196
+
197
+ clearTimeout(timer);
198
+ instance.state.lastCallAt = Date.now();
199
+
200
+ const contentParts = result.content as Array<{ type: string; text?: string }>;
201
+ const text = contentParts
202
+ .filter(p => p.type === 'text')
203
+ .map(p => p.text || '')
204
+ .join('\n');
205
+
206
+ return {
207
+ content: text || JSON.stringify(result.content),
208
+ isError: result.isError === true,
209
+ };
210
+ } catch (error) {
211
+ const raw = error instanceof Error ? error.message : String(error);
212
+ const isAbort = raw.includes('AbortError') || raw.includes('aborted');
213
+ const message = isAbort
214
+ ? `Tool call timed out after ${Math.round(instance.config.timeouts.call / 1000)}s: ${toolName}`
215
+ : raw;
216
+ instance.logBuffer.push({
217
+ timestamp: Date.now(),
218
+ level: 'error',
219
+ message: `callTool ${toolName} failed: ${message}`,
220
+ });
221
+ return { content: `Tool call failed: ${message}`, isError: true };
222
+ }
223
+ }
224
+
225
+ listTools(serverId: string): McpToolInfo[] {
226
+ const instance = this.servers.get(serverId);
227
+ if (!instance) return [];
228
+ return [...instance.tools];
229
+ }
230
+
231
+ getAllTools(): McpToolInfo[] {
232
+ const all: McpToolInfo[] = [];
233
+ for (const instance of this.servers.values()) {
234
+ if (instance.state.status === 'running') {
235
+ all.push(...instance.tools);
236
+ }
237
+ }
238
+ return all;
239
+ }
240
+
241
+ getState(serverId: string): McpServerState | null {
242
+ const instance = this.servers.get(serverId);
243
+ return instance ? { ...instance.state } : null;
244
+ }
245
+
246
+ getAllStates(): Map<string, McpServerState> {
247
+ const states = new Map<string, McpServerState>();
248
+ for (const [id, instance] of this.servers) {
249
+ states.set(id, { ...instance.state });
250
+ }
251
+ return states;
252
+ }
253
+
254
+ getLogs(serverId: string): LogEntry[] {
255
+ const instance = this.servers.get(serverId);
256
+ if (!instance) return [];
257
+ return [...instance.logBuffer];
258
+ }
259
+
260
+ getConfig(serverId: string): McpServerConfig | null {
261
+ const instance = this.servers.get(serverId);
262
+ return instance ? instance.config : null;
263
+ }
264
+
265
+ async shutdownAll(): Promise<void> {
266
+ const promises: Promise<void>[] = [];
267
+ for (const id of this.servers.keys()) {
268
+ promises.push(this.stopServer(id));
269
+ }
270
+ await Promise.allSettled(promises);
271
+ this.servers.clear();
272
+ }
273
+
274
+ private async attemptRestart(id: string): Promise<void> {
275
+ const instance = this.servers.get(id);
276
+ if (!instance) return;
277
+
278
+ if (instance.restartCount >= MAX_RESTART_COUNT) {
279
+ instance.logBuffer.push({
280
+ timestamp: Date.now(),
281
+ level: 'error',
282
+ message: `Max restart count (${MAX_RESTART_COUNT}) reached, giving up`,
283
+ });
284
+ return;
285
+ }
286
+
287
+ const delay = BACKOFF_DELAYS[Math.min(instance.restartCount, BACKOFF_DELAYS.length - 1)]!;
288
+ instance.restartCount++;
289
+ instance.lastRestartAt = Date.now();
290
+
291
+ instance.logBuffer.push({
292
+ timestamp: Date.now(),
293
+ level: 'info',
294
+ message: `Scheduling restart #${instance.restartCount} in ${delay}ms`,
295
+ });
296
+
297
+ await new Promise(resolve => setTimeout(resolve, delay));
298
+
299
+ const current = this.servers.get(id);
300
+ if (current && current.state.status !== 'running' && current.state.status !== 'stopped') {
301
+ await this.startServer(instance.config);
302
+ }
303
+ }
299
304
  }