keystone-cli 0.1.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.
Files changed (46) hide show
  1. package/README.md +136 -0
  2. package/logo.png +0 -0
  3. package/package.json +45 -0
  4. package/src/cli.ts +775 -0
  5. package/src/db/workflow-db.test.ts +99 -0
  6. package/src/db/workflow-db.ts +265 -0
  7. package/src/expression/evaluator.test.ts +247 -0
  8. package/src/expression/evaluator.ts +517 -0
  9. package/src/parser/agent-parser.test.ts +123 -0
  10. package/src/parser/agent-parser.ts +59 -0
  11. package/src/parser/config-schema.ts +54 -0
  12. package/src/parser/schema.ts +157 -0
  13. package/src/parser/workflow-parser.test.ts +212 -0
  14. package/src/parser/workflow-parser.ts +228 -0
  15. package/src/runner/llm-adapter.test.ts +329 -0
  16. package/src/runner/llm-adapter.ts +306 -0
  17. package/src/runner/llm-executor.test.ts +537 -0
  18. package/src/runner/llm-executor.ts +256 -0
  19. package/src/runner/mcp-client.test.ts +122 -0
  20. package/src/runner/mcp-client.ts +123 -0
  21. package/src/runner/mcp-manager.test.ts +143 -0
  22. package/src/runner/mcp-manager.ts +85 -0
  23. package/src/runner/mcp-server.test.ts +242 -0
  24. package/src/runner/mcp-server.ts +436 -0
  25. package/src/runner/retry.test.ts +52 -0
  26. package/src/runner/retry.ts +58 -0
  27. package/src/runner/shell-executor.test.ts +123 -0
  28. package/src/runner/shell-executor.ts +166 -0
  29. package/src/runner/step-executor.test.ts +465 -0
  30. package/src/runner/step-executor.ts +354 -0
  31. package/src/runner/timeout.test.ts +20 -0
  32. package/src/runner/timeout.ts +30 -0
  33. package/src/runner/tool-integration.test.ts +198 -0
  34. package/src/runner/workflow-runner.test.ts +358 -0
  35. package/src/runner/workflow-runner.ts +955 -0
  36. package/src/ui/dashboard.tsx +165 -0
  37. package/src/utils/auth-manager.test.ts +152 -0
  38. package/src/utils/auth-manager.ts +88 -0
  39. package/src/utils/config-loader.test.ts +52 -0
  40. package/src/utils/config-loader.ts +85 -0
  41. package/src/utils/mermaid.test.ts +51 -0
  42. package/src/utils/mermaid.ts +87 -0
  43. package/src/utils/redactor.test.ts +66 -0
  44. package/src/utils/redactor.ts +60 -0
  45. package/src/utils/workflow-registry.test.ts +108 -0
  46. package/src/utils/workflow-registry.ts +121 -0
@@ -0,0 +1,256 @@
1
+ import { join } from 'node:path';
2
+ import type { ExpressionContext } from '../expression/evaluator';
3
+ import { ExpressionEvaluator } from '../expression/evaluator';
4
+ import { parseAgent, resolveAgentPath } from '../parser/agent-parser';
5
+ import type { AgentTool, LlmStep, Step } from '../parser/schema';
6
+ import { type LLMMessage, getAdapter } from './llm-adapter';
7
+ import { MCPClient } from './mcp-client';
8
+ import type { MCPManager } from './mcp-manager';
9
+ import type { StepResult } from './step-executor';
10
+ import type { Logger } from './workflow-runner';
11
+
12
+ interface ToolDefinition {
13
+ name: string;
14
+ description?: string;
15
+ parameters: unknown;
16
+ source: 'agent' | 'step' | 'mcp';
17
+ execution?: Step;
18
+ mcpClient?: MCPClient;
19
+ }
20
+
21
+ export async function executeLlmStep(
22
+ step: LlmStep,
23
+ context: ExpressionContext,
24
+ executeStepFn: (step: Step, context: ExpressionContext) => Promise<StepResult>,
25
+ logger: Logger = console,
26
+ mcpManager?: MCPManager
27
+ ): Promise<StepResult> {
28
+ const agentPath = resolveAgentPath(step.agent);
29
+ const agent = parseAgent(agentPath);
30
+
31
+ const provider = step.provider || agent.provider;
32
+ const model = step.model || agent.model || 'gpt-4o';
33
+ const prompt = ExpressionEvaluator.evaluate(step.prompt, context) as string;
34
+
35
+ const fullModelString = provider ? `${provider}:${model}` : model;
36
+ const { adapter, resolvedModel } = getAdapter(fullModelString);
37
+
38
+ // Inject schema instructions if present
39
+ let systemPrompt = agent.systemPrompt;
40
+ if (step.schema) {
41
+ systemPrompt += `\n\nIMPORTANT: You must output valid JSON that matches the following schema:\n${JSON.stringify(step.schema, null, 2)}`;
42
+ }
43
+
44
+ const messages: LLMMessage[] = [
45
+ { role: 'system', content: systemPrompt },
46
+ { role: 'user', content: prompt },
47
+ ];
48
+
49
+ const localMcpClients: MCPClient[] = [];
50
+ const allTools: ToolDefinition[] = [];
51
+
52
+ try {
53
+ // 1. Add agent tools
54
+ for (const tool of agent.tools) {
55
+ allTools.push({
56
+ name: tool.name,
57
+ description: tool.description,
58
+ parameters: tool.parameters || {
59
+ type: 'object',
60
+ properties: {},
61
+ additionalProperties: true,
62
+ },
63
+ source: 'agent',
64
+ execution: tool.execution,
65
+ });
66
+ }
67
+
68
+ // 2. Add step tools
69
+ if (step.tools) {
70
+ for (const tool of step.tools) {
71
+ allTools.push({
72
+ name: tool.name,
73
+ description: tool.description,
74
+ parameters: tool.parameters || {
75
+ type: 'object',
76
+ properties: {},
77
+ additionalProperties: true,
78
+ },
79
+ source: 'step',
80
+ execution: tool.execution,
81
+ });
82
+ }
83
+ }
84
+
85
+ // 3. Add MCP tools
86
+ const mcpServersToConnect = [...(step.mcpServers || [])];
87
+ if (step.useGlobalMcp && mcpManager) {
88
+ const globalServers = mcpManager.getGlobalServers();
89
+ for (const globalServer of globalServers) {
90
+ // Only add if not already explicitly listed
91
+ const alreadyListed = mcpServersToConnect.some((s) =>
92
+ typeof s === 'string' ? s === globalServer.name : s.name === globalServer.name
93
+ );
94
+ if (!alreadyListed) {
95
+ mcpServersToConnect.push(globalServer);
96
+ }
97
+ }
98
+ }
99
+
100
+ if (mcpServersToConnect.length > 0) {
101
+ for (const server of mcpServersToConnect) {
102
+ let client: MCPClient | undefined;
103
+
104
+ if (mcpManager) {
105
+ client = await mcpManager.getClient(server, logger);
106
+ } else {
107
+ // Fallback if no manager (should not happen in normal workflow run)
108
+ if (typeof server === 'string') {
109
+ logger.error(` ✗ Cannot reference global MCP server '${server}' without MCPManager`);
110
+ continue;
111
+ }
112
+ logger.log(` 🔌 Connecting to MCP server: ${server.name}`);
113
+ client = new MCPClient(server.command, server.args, server.env);
114
+ try {
115
+ await client.initialize();
116
+ localMcpClients.push(client);
117
+ } catch (error) {
118
+ logger.error(
119
+ ` ✗ Failed to connect to MCP server ${server.name}: ${error instanceof Error ? error.message : String(error)}`
120
+ );
121
+ client.stop();
122
+ client = undefined;
123
+ }
124
+ }
125
+
126
+ if (client) {
127
+ const mcpTools = await client.listTools();
128
+ for (const tool of mcpTools) {
129
+ allTools.push({
130
+ name: tool.name,
131
+ description: tool.description,
132
+ parameters: tool.inputSchema,
133
+ source: 'mcp',
134
+ mcpClient: client,
135
+ });
136
+ }
137
+ }
138
+ }
139
+ }
140
+
141
+ const llmTools = allTools.map((t) => ({
142
+ type: 'function',
143
+ function: {
144
+ name: t.name,
145
+ description: t.description,
146
+ parameters: t.parameters,
147
+ },
148
+ }));
149
+
150
+ // ReAct Loop
151
+ let iterations = 0;
152
+ const maxIterations = step.maxIterations || 10;
153
+
154
+ while (iterations < maxIterations) {
155
+ iterations++;
156
+
157
+ const response = await adapter.chat(messages, {
158
+ model: resolvedModel,
159
+ tools: llmTools.length > 0 ? llmTools : undefined,
160
+ });
161
+
162
+ const { message } = response;
163
+ messages.push(message);
164
+
165
+ if (message.content && !step.schema) {
166
+ logger.log(`\n${message.content}`);
167
+ }
168
+
169
+ if (!message.tool_calls || message.tool_calls.length === 0) {
170
+ let output = message.content;
171
+
172
+ // If schema is defined, attempt to parse JSON
173
+ if (step.schema && typeof output === 'string') {
174
+ try {
175
+ // Attempt to extract JSON if wrapped in markdown code blocks or just finding the first {
176
+ const jsonMatch =
177
+ output.match(/```(?:json)?\s*([\s\S]*?)\s*```/i) || output.match(/\{[\s\S]*\}/);
178
+ const jsonStr = jsonMatch ? jsonMatch[1] || jsonMatch[0] : output;
179
+ output = JSON.parse(jsonStr);
180
+ } catch (e) {
181
+ throw new Error(
182
+ `Failed to parse LLM output as JSON matching schema: ${e instanceof Error ? e.message : String(e)}\nOutput: ${output}`
183
+ );
184
+ }
185
+ }
186
+
187
+ return {
188
+ output,
189
+ status: 'success',
190
+ };
191
+ }
192
+
193
+ // Execute tools
194
+ for (const toolCall of message.tool_calls) {
195
+ logger.log(` 🛠️ Tool Call: ${toolCall.function.name}`);
196
+ const toolInfo = allTools.find((t) => t.name === toolCall.function.name);
197
+
198
+ if (!toolInfo) {
199
+ messages.push({
200
+ role: 'tool',
201
+ tool_call_id: toolCall.id,
202
+ name: toolCall.function.name,
203
+ content: `Error: Tool ${toolCall.function.name} not found`,
204
+ });
205
+ continue;
206
+ }
207
+
208
+ const args = JSON.parse(toolCall.function.arguments);
209
+
210
+ if (toolInfo.source === 'mcp' && toolInfo.mcpClient) {
211
+ try {
212
+ const result = await toolInfo.mcpClient.callTool(toolInfo.name, args);
213
+ messages.push({
214
+ role: 'tool',
215
+ tool_call_id: toolCall.id,
216
+ name: toolCall.function.name,
217
+ content: JSON.stringify(result),
218
+ });
219
+ } catch (error) {
220
+ messages.push({
221
+ role: 'tool',
222
+ tool_call_id: toolCall.id,
223
+ name: toolCall.function.name,
224
+ content: `Error: ${error instanceof Error ? error.message : String(error)}`,
225
+ });
226
+ }
227
+ } else if (toolInfo.execution) {
228
+ // Execute the tool as a step
229
+ const toolContext: ExpressionContext = {
230
+ ...context,
231
+ args,
232
+ };
233
+
234
+ const result = await executeStepFn(toolInfo.execution, toolContext);
235
+
236
+ messages.push({
237
+ role: 'tool',
238
+ tool_call_id: toolCall.id,
239
+ name: toolCall.function.name,
240
+ content:
241
+ result.status === 'success'
242
+ ? JSON.stringify(result.output)
243
+ : `Error: ${result.error}`,
244
+ });
245
+ }
246
+ }
247
+ }
248
+
249
+ throw new Error('Max ReAct iterations reached');
250
+ } finally {
251
+ // Cleanup LOCAL MCP clients only. Shared clients are managed by MCPManager.
252
+ for (const client of localMcpClients) {
253
+ client.stop();
254
+ }
255
+ }
256
+ }
@@ -0,0 +1,122 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import { MCPClient } from './mcp-client';
3
+ import * as child_process from 'node:child_process';
4
+ import { EventEmitter } from 'node:events';
5
+ import { Readable, Writable } from 'node:stream';
6
+
7
+ interface MockProcess extends EventEmitter {
8
+ stdout: Readable;
9
+ stdin: Writable;
10
+ kill: ReturnType<typeof mock>;
11
+ }
12
+
13
+ describe('MCPClient', () => {
14
+ let mockProcess: MockProcess;
15
+ let spawnSpy: ReturnType<typeof spyOn>;
16
+
17
+ beforeEach(() => {
18
+ const emitter = new EventEmitter();
19
+ const stdout = new Readable({
20
+ read() {},
21
+ });
22
+ const stdin = new Writable({
23
+ write(_chunk, _encoding, callback) {
24
+ callback();
25
+ },
26
+ });
27
+ const kill = mock(() => {});
28
+
29
+ mockProcess = Object.assign(emitter, { stdout, stdin, kill }) as unknown as MockProcess;
30
+
31
+ spawnSpy = spyOn(child_process, 'spawn').mockReturnValue(
32
+ mockProcess as unknown as child_process.ChildProcess
33
+ );
34
+ });
35
+
36
+ afterEach(() => {
37
+ spawnSpy.mockRestore();
38
+ });
39
+
40
+ it('should initialize correctly', async () => {
41
+ const client = new MCPClient('node', ['server.js']);
42
+ const initPromise = client.initialize();
43
+
44
+ // Simulate server response
45
+ mockProcess.stdout.push(
46
+ `${JSON.stringify({
47
+ jsonrpc: '2.0',
48
+ id: 0,
49
+ result: { protocolVersion: '2024-11-05' },
50
+ })}\n`
51
+ );
52
+
53
+ const response = await initPromise;
54
+ expect(response.result?.protocolVersion).toBe('2024-11-05');
55
+ expect(spawnSpy).toHaveBeenCalledWith('node', ['server.js'], expect.any(Object));
56
+ });
57
+
58
+ it('should list tools', async () => {
59
+ const client = new MCPClient('node');
60
+ const listPromise = client.listTools();
61
+
62
+ mockProcess.stdout.push(
63
+ `${JSON.stringify({
64
+ jsonrpc: '2.0',
65
+ id: 0,
66
+ result: {
67
+ tools: [{ name: 'test-tool', inputSchema: {} }],
68
+ },
69
+ })}\n`
70
+ );
71
+
72
+ const tools = await listPromise;
73
+ expect(tools).toHaveLength(1);
74
+ expect(tools[0].name).toBe('test-tool');
75
+ });
76
+
77
+ it('should call tool', async () => {
78
+ const client = new MCPClient('node');
79
+ const callPromise = client.callTool('my-tool', { arg: 1 });
80
+
81
+ mockProcess.stdout.push(
82
+ `${JSON.stringify({
83
+ jsonrpc: '2.0',
84
+ id: 0,
85
+ result: { content: [{ type: 'text', text: 'success' }] },
86
+ })}\n`
87
+ );
88
+
89
+ const result = await callPromise;
90
+ expect((result as { content: Array<{ text: string }> }).content[0].text).toBe('success');
91
+ });
92
+
93
+ it('should handle tool call error', async () => {
94
+ const client = new MCPClient('node');
95
+ const callPromise = client.callTool('my-tool', {});
96
+
97
+ mockProcess.stdout.push(
98
+ `${JSON.stringify({
99
+ jsonrpc: '2.0',
100
+ id: 0,
101
+ error: { code: -32000, message: 'Tool error' },
102
+ })}\n`
103
+ );
104
+
105
+ await expect(callPromise).rejects.toThrow(/MCP tool call failed/);
106
+ });
107
+
108
+ it('should timeout on request', async () => {
109
+ // Set a very short timeout for testing
110
+ const client = new MCPClient('node', [], {}, 10);
111
+ const requestPromise = client.initialize();
112
+
113
+ // Don't push any response to stdout
114
+ await expect(requestPromise).rejects.toThrow(/MCP request timeout/);
115
+ });
116
+
117
+ it('should stop the process', () => {
118
+ const client = new MCPClient('node');
119
+ client.stop();
120
+ expect(mockProcess.kill).toHaveBeenCalled();
121
+ });
122
+ });
@@ -0,0 +1,123 @@
1
+ import { type ChildProcess, spawn } from 'node:child_process';
2
+ import { type Interface, createInterface } from 'node:readline';
3
+
4
+ interface MCPTool {
5
+ name: string;
6
+ description?: string;
7
+ inputSchema: unknown;
8
+ }
9
+
10
+ interface MCPResponse {
11
+ id?: number;
12
+ result?: {
13
+ tools?: MCPTool[];
14
+ content?: Array<{ type: string; text: string }>;
15
+ [key: string]: unknown;
16
+ };
17
+ error?: {
18
+ code: number;
19
+ message: string;
20
+ data?: unknown;
21
+ };
22
+ }
23
+
24
+ export class MCPClient {
25
+ private process: ChildProcess;
26
+ private rl: Interface;
27
+ private messageId = 0;
28
+ private pendingRequests = new Map<number, (response: MCPResponse) => void>();
29
+ private timeout: number;
30
+
31
+ constructor(
32
+ command: string,
33
+ args: string[] = [],
34
+ env: Record<string, string> = {},
35
+ timeout = 30000
36
+ ) {
37
+ this.timeout = timeout;
38
+ this.process = spawn(command, args, {
39
+ env: { ...process.env, ...env },
40
+ stdio: ['pipe', 'pipe', 'inherit'],
41
+ });
42
+
43
+ if (!this.process.stdout || !this.process.stdin) {
44
+ throw new Error('Failed to start MCP server: stdio not available');
45
+ }
46
+
47
+ this.rl = createInterface({
48
+ input: this.process.stdout,
49
+ });
50
+
51
+ this.rl.on('line', (line) => {
52
+ try {
53
+ const response = JSON.parse(line) as MCPResponse;
54
+ if (response.id !== undefined && this.pendingRequests.has(response.id)) {
55
+ const resolve = this.pendingRequests.get(response.id);
56
+ if (resolve) {
57
+ this.pendingRequests.delete(response.id);
58
+ resolve(response);
59
+ }
60
+ }
61
+ } catch (e) {
62
+ // Ignore non-JSON lines
63
+ }
64
+ });
65
+ }
66
+
67
+ private async request(
68
+ method: string,
69
+ params: Record<string, unknown> = {}
70
+ ): Promise<MCPResponse> {
71
+ const id = this.messageId++;
72
+ const message = {
73
+ jsonrpc: '2.0',
74
+ id,
75
+ method,
76
+ params,
77
+ };
78
+
79
+ return new Promise((resolve, reject) => {
80
+ this.pendingRequests.set(id, resolve);
81
+ this.process.stdin?.write(`${JSON.stringify(message)}\n`);
82
+
83
+ // Add a timeout
84
+ setTimeout(() => {
85
+ if (this.pendingRequests.has(id)) {
86
+ this.pendingRequests.delete(id);
87
+ reject(new Error(`MCP request timeout: ${method}`));
88
+ }
89
+ }, this.timeout);
90
+ });
91
+ }
92
+
93
+ async initialize() {
94
+ return this.request('initialize', {
95
+ protocolVersion: '2024-11-05',
96
+ capabilities: {},
97
+ clientInfo: {
98
+ name: 'keystone-cli',
99
+ version: '0.1.0',
100
+ },
101
+ });
102
+ }
103
+
104
+ async listTools(): Promise<MCPTool[]> {
105
+ const response = await this.request('tools/list');
106
+ return response.result?.tools || [];
107
+ }
108
+
109
+ async callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
110
+ const response = await this.request('tools/call', {
111
+ name,
112
+ arguments: args,
113
+ });
114
+ if (response.error) {
115
+ throw new Error(`MCP tool call failed: ${JSON.stringify(response.error)}`);
116
+ }
117
+ return response.result;
118
+ }
119
+
120
+ stop() {
121
+ this.process.kill();
122
+ }
123
+ }
@@ -0,0 +1,143 @@
1
+ import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import { MCPManager } from './mcp-manager';
3
+ import { MCPClient } from './mcp-client';
4
+ import { ConfigLoader } from '../utils/config-loader';
5
+ import * as child_process from 'node:child_process';
6
+ import { Readable, Writable } from 'node:stream';
7
+ import { EventEmitter } from 'node:events';
8
+
9
+ describe('MCPManager', () => {
10
+ let spawnSpy: ReturnType<typeof spyOn>;
11
+
12
+ beforeEach(() => {
13
+ ConfigLoader.clear();
14
+
15
+ const mockProcess = Object.assign(new EventEmitter(), {
16
+ stdout: new Readable({ read() {} }),
17
+ stdin: new Writable({
18
+ write(_chunk, _encoding, cb) {
19
+ cb();
20
+ },
21
+ }),
22
+ kill: mock(() => {}),
23
+ });
24
+ spawnSpy = spyOn(child_process, 'spawn').mockReturnValue(
25
+ mockProcess as unknown as child_process.ChildProcess
26
+ );
27
+ });
28
+
29
+ afterEach(() => {
30
+ ConfigLoader.clear();
31
+ spawnSpy.mockRestore();
32
+ });
33
+
34
+ it('should load global config on initialization', () => {
35
+ ConfigLoader.setConfig({
36
+ mcp_servers: {
37
+ 'test-server': {
38
+ command: 'node',
39
+ args: ['test.js'],
40
+ env: { FOO: 'bar' },
41
+ },
42
+ },
43
+ providers: {},
44
+ model_mappings: {},
45
+ default_provider: 'openai',
46
+ });
47
+
48
+ const manager = new MCPManager();
49
+ const servers = manager.getGlobalServers();
50
+ expect(servers).toHaveLength(1);
51
+ expect(servers[0].name).toBe('test-server');
52
+ expect(servers[0].command).toBe('node');
53
+ });
54
+
55
+ it('should get client for global server', async () => {
56
+ ConfigLoader.setConfig({
57
+ mcp_servers: {
58
+ 'test-server': {
59
+ command: 'node',
60
+ },
61
+ },
62
+ providers: {},
63
+ model_mappings: {},
64
+ default_provider: 'openai',
65
+ });
66
+
67
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({
68
+ result: { protocolVersion: '1.0' },
69
+ jsonrpc: '2.0',
70
+ id: 0,
71
+ });
72
+ const stopSpy = spyOn(MCPClient.prototype, 'stop').mockReturnValue(undefined);
73
+
74
+ const manager = new MCPManager();
75
+ const client = await manager.getClient('test-server');
76
+
77
+ expect(client).toBeDefined();
78
+ expect(initSpy).toHaveBeenCalled();
79
+
80
+ // Should reuse client
81
+ const client2 = await manager.getClient('test-server');
82
+ expect(client2).toBe(client);
83
+ expect(initSpy).toHaveBeenCalledTimes(1);
84
+
85
+ await manager.stopAll();
86
+ expect(stopSpy).toHaveBeenCalled();
87
+
88
+ initSpy.mockRestore();
89
+ stopSpy.mockRestore();
90
+ });
91
+
92
+ it('should get client for ad-hoc server config', async () => {
93
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockResolvedValue({
94
+ result: { protocolVersion: '1.0' },
95
+ jsonrpc: '2.0',
96
+ id: 0,
97
+ });
98
+
99
+ const manager = new MCPManager();
100
+ const client = await manager.getClient({
101
+ name: 'adhoc',
102
+ command: 'node',
103
+ });
104
+
105
+ expect(client).toBeDefined();
106
+ expect(initSpy).toHaveBeenCalled();
107
+
108
+ initSpy.mockRestore();
109
+ });
110
+
111
+ it('should return undefined if global server not found', async () => {
112
+ const manager = new MCPManager();
113
+ const client = await manager.getClient('non-existent');
114
+ expect(client).toBeUndefined();
115
+ });
116
+
117
+ it('should handle connection failure', async () => {
118
+ ConfigLoader.setConfig({
119
+ mcp_servers: {
120
+ 'fail-server': {
121
+ command: 'fail',
122
+ },
123
+ },
124
+ providers: {},
125
+ model_mappings: {},
126
+ default_provider: 'openai',
127
+ });
128
+
129
+ const initSpy = spyOn(MCPClient.prototype, 'initialize').mockRejectedValue(
130
+ new Error('Connection failed')
131
+ );
132
+ const stopSpy = spyOn(MCPClient.prototype, 'stop').mockReturnValue(undefined);
133
+
134
+ const manager = new MCPManager();
135
+ const client = await manager.getClient('fail-server');
136
+
137
+ expect(client).toBeUndefined();
138
+ expect(stopSpy).toHaveBeenCalled();
139
+
140
+ initSpy.mockRestore();
141
+ stopSpy.mockRestore();
142
+ });
143
+ });