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.
- package/README.md +136 -0
- package/logo.png +0 -0
- package/package.json +45 -0
- package/src/cli.ts +775 -0
- package/src/db/workflow-db.test.ts +99 -0
- package/src/db/workflow-db.ts +265 -0
- package/src/expression/evaluator.test.ts +247 -0
- package/src/expression/evaluator.ts +517 -0
- package/src/parser/agent-parser.test.ts +123 -0
- package/src/parser/agent-parser.ts +59 -0
- package/src/parser/config-schema.ts +54 -0
- package/src/parser/schema.ts +157 -0
- package/src/parser/workflow-parser.test.ts +212 -0
- package/src/parser/workflow-parser.ts +228 -0
- package/src/runner/llm-adapter.test.ts +329 -0
- package/src/runner/llm-adapter.ts +306 -0
- package/src/runner/llm-executor.test.ts +537 -0
- package/src/runner/llm-executor.ts +256 -0
- package/src/runner/mcp-client.test.ts +122 -0
- package/src/runner/mcp-client.ts +123 -0
- package/src/runner/mcp-manager.test.ts +143 -0
- package/src/runner/mcp-manager.ts +85 -0
- package/src/runner/mcp-server.test.ts +242 -0
- package/src/runner/mcp-server.ts +436 -0
- package/src/runner/retry.test.ts +52 -0
- package/src/runner/retry.ts +58 -0
- package/src/runner/shell-executor.test.ts +123 -0
- package/src/runner/shell-executor.ts +166 -0
- package/src/runner/step-executor.test.ts +465 -0
- package/src/runner/step-executor.ts +354 -0
- package/src/runner/timeout.test.ts +20 -0
- package/src/runner/timeout.ts +30 -0
- package/src/runner/tool-integration.test.ts +198 -0
- package/src/runner/workflow-runner.test.ts +358 -0
- package/src/runner/workflow-runner.ts +955 -0
- package/src/ui/dashboard.tsx +165 -0
- package/src/utils/auth-manager.test.ts +152 -0
- package/src/utils/auth-manager.ts +88 -0
- package/src/utils/config-loader.test.ts +52 -0
- package/src/utils/config-loader.ts +85 -0
- package/src/utils/mermaid.test.ts +51 -0
- package/src/utils/mermaid.ts +87 -0
- package/src/utils/redactor.test.ts +66 -0
- package/src/utils/redactor.ts +60 -0
- package/src/utils/workflow-registry.test.ts +108 -0
- package/src/utils/workflow-registry.ts +121 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { MCPClient } from './mcp-client';
|
|
2
|
+
import { ConfigLoader } from '../utils/config-loader';
|
|
3
|
+
import type { Logger } from './workflow-runner';
|
|
4
|
+
|
|
5
|
+
export interface MCPServerConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
command: string;
|
|
8
|
+
args?: string[];
|
|
9
|
+
env?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class MCPManager {
|
|
13
|
+
private clients: Map<string, MCPClient> = new Map();
|
|
14
|
+
private sharedServers: Map<string, MCPServerConfig> = new Map();
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
this.loadGlobalConfig();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
private loadGlobalConfig() {
|
|
21
|
+
const config = ConfigLoader.load();
|
|
22
|
+
if (config.mcp_servers) {
|
|
23
|
+
for (const [name, server] of Object.entries(config.mcp_servers)) {
|
|
24
|
+
this.sharedServers.set(name, {
|
|
25
|
+
name,
|
|
26
|
+
command: server.command,
|
|
27
|
+
args: server.args,
|
|
28
|
+
env: server.env,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async getClient(
|
|
35
|
+
serverRef: string | MCPServerConfig,
|
|
36
|
+
logger: Logger = console
|
|
37
|
+
): Promise<MCPClient | undefined> {
|
|
38
|
+
let config: MCPServerConfig;
|
|
39
|
+
|
|
40
|
+
if (typeof serverRef === 'string') {
|
|
41
|
+
const shared = this.sharedServers.get(serverRef);
|
|
42
|
+
if (!shared) {
|
|
43
|
+
logger.error(` ✗ Global MCP server not found: ${serverRef}`);
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
config = shared;
|
|
47
|
+
} else {
|
|
48
|
+
config = serverRef;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const key = this.getServerKey(config);
|
|
52
|
+
if (this.clients.has(key)) {
|
|
53
|
+
return this.clients.get(key);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
logger.log(` 🔌 Connecting to MCP server: ${config.name}`);
|
|
57
|
+
const client = new MCPClient(config.command, config.args || [], config.env || {});
|
|
58
|
+
try {
|
|
59
|
+
await client.initialize();
|
|
60
|
+
this.clients.set(key, client);
|
|
61
|
+
return client;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
logger.error(
|
|
64
|
+
` ✗ Failed to connect to MCP server ${config.name}: ${error instanceof Error ? error.message : String(error)}`
|
|
65
|
+
);
|
|
66
|
+
client.stop();
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private getServerKey(config: MCPServerConfig): string {
|
|
72
|
+
return config.name;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getGlobalServers(): MCPServerConfig[] {
|
|
76
|
+
return Array.from(this.sharedServers.values());
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async stopAll() {
|
|
80
|
+
for (const client of this.clients.values()) {
|
|
81
|
+
client.stop();
|
|
82
|
+
}
|
|
83
|
+
this.clients.clear();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
|
|
2
|
+
import { WorkflowDb } from '../db/workflow-db';
|
|
3
|
+
import { WorkflowParser } from '../parser/workflow-parser';
|
|
4
|
+
import { WorkflowRegistry } from '../utils/workflow-registry';
|
|
5
|
+
import { MCPServer } from './mcp-server';
|
|
6
|
+
import { WorkflowSuspendedError } from './step-executor';
|
|
7
|
+
import { WorkflowRunner } from './workflow-runner';
|
|
8
|
+
|
|
9
|
+
describe('MCPServer', () => {
|
|
10
|
+
let db: WorkflowDb;
|
|
11
|
+
let server: MCPServer;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
db = new WorkflowDb(':memory:');
|
|
15
|
+
server = new MCPServer(db);
|
|
16
|
+
mock.restore();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const handleMessage = (msg: unknown) => {
|
|
20
|
+
// @ts-ignore
|
|
21
|
+
return server.handleMessage(msg);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
it('should handle initialize request', async () => {
|
|
25
|
+
const response = await handleMessage({
|
|
26
|
+
jsonrpc: '2.0',
|
|
27
|
+
id: 1,
|
|
28
|
+
method: 'initialize',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(response.result.serverInfo.name).toBe('keystone-mcp');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('should list tools', async () => {
|
|
35
|
+
const response = await handleMessage({
|
|
36
|
+
jsonrpc: '2.0',
|
|
37
|
+
id: 2,
|
|
38
|
+
method: 'tools/list',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(response.result.tools).toHaveLength(5);
|
|
42
|
+
// @ts-ignore
|
|
43
|
+
expect(response.result.tools.map((t) => t.name)).toContain('run_workflow');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should call list_workflows tool', async () => {
|
|
47
|
+
spyOn(WorkflowRegistry, 'listWorkflows').mockReturnValue([
|
|
48
|
+
{ name: 'test-wf', description: 'Test Workflow' },
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const response = await handleMessage({
|
|
52
|
+
jsonrpc: '2.0',
|
|
53
|
+
id: 3,
|
|
54
|
+
method: 'tools/call',
|
|
55
|
+
params: { name: 'list_workflows', arguments: {} },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(response.result.content[0].text).toContain('test-wf');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should call run_workflow tool successfully', async () => {
|
|
62
|
+
spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
|
|
65
|
+
name: 'test-wf',
|
|
66
|
+
steps: [],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Mock WorkflowRunner
|
|
70
|
+
const mockRun = mock(() => Promise.resolve({ result: 'ok' }));
|
|
71
|
+
// @ts-ignore
|
|
72
|
+
spyOn(WorkflowRunner.prototype, 'run').mockImplementation(mockRun);
|
|
73
|
+
|
|
74
|
+
const response = await handleMessage({
|
|
75
|
+
jsonrpc: '2.0',
|
|
76
|
+
id: 4,
|
|
77
|
+
method: 'tools/call',
|
|
78
|
+
params: {
|
|
79
|
+
name: 'run_workflow',
|
|
80
|
+
arguments: { workflow_name: 'test-wf', inputs: {} },
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
expect(JSON.parse(response.result.content[0].text).status).toBe('success');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should handle run_workflow failure', async () => {
|
|
88
|
+
spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
|
|
89
|
+
// @ts-ignore
|
|
90
|
+
spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
|
|
91
|
+
name: 'test-wf',
|
|
92
|
+
steps: [],
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
spyOn(WorkflowRunner.prototype, 'run').mockRejectedValue(new Error('workflow failed'));
|
|
96
|
+
|
|
97
|
+
const response = await handleMessage({
|
|
98
|
+
jsonrpc: '2.0',
|
|
99
|
+
id: 5,
|
|
100
|
+
method: 'tools/call',
|
|
101
|
+
params: {
|
|
102
|
+
name: 'run_workflow',
|
|
103
|
+
arguments: { workflow_name: 'test-wf' },
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(response.result.isError).toBe(true);
|
|
108
|
+
expect(response.result.content[0].text).toContain('Workflow failed');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should handle workflow suspension in run_workflow', async () => {
|
|
112
|
+
spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
|
|
113
|
+
// @ts-ignore
|
|
114
|
+
spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
|
|
115
|
+
name: 'test-wf',
|
|
116
|
+
steps: [],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const suspendedError = new WorkflowSuspendedError('Input needed', 'step1', 'text');
|
|
120
|
+
spyOn(WorkflowRunner.prototype, 'run').mockRejectedValue(suspendedError);
|
|
121
|
+
spyOn(WorkflowRunner.prototype, 'getRunId').mockReturnValue('run123');
|
|
122
|
+
|
|
123
|
+
const response = await handleMessage({
|
|
124
|
+
jsonrpc: '2.0',
|
|
125
|
+
id: 6,
|
|
126
|
+
method: 'tools/call',
|
|
127
|
+
params: {
|
|
128
|
+
name: 'run_workflow',
|
|
129
|
+
arguments: { workflow_name: 'test-wf' },
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const result = JSON.parse(response.result.content[0].text);
|
|
134
|
+
expect(result.status).toBe('paused');
|
|
135
|
+
expect(result.run_id).toBe('run123');
|
|
136
|
+
expect(result.message).toBe('Input needed');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('should handle answer_human_input and resume', async () => {
|
|
140
|
+
const runId = 'run-to-resume';
|
|
141
|
+
await db.createRun(runId, 'test-wf', {});
|
|
142
|
+
await db.updateRunStatus(runId, 'paused');
|
|
143
|
+
await db.createStep('step-exec-1', runId, 's1');
|
|
144
|
+
|
|
145
|
+
spyOn(WorkflowRegistry, 'resolvePath').mockReturnValue('test.yaml');
|
|
146
|
+
// @ts-ignore
|
|
147
|
+
spyOn(WorkflowParser, 'loadWorkflow').mockReturnValue({
|
|
148
|
+
name: 'test-wf',
|
|
149
|
+
steps: [{ id: 's1', type: 'human' }],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
const mockRun = mock(() => Promise.resolve({ result: 'resumed' }));
|
|
153
|
+
// @ts-ignore
|
|
154
|
+
spyOn(WorkflowRunner.prototype, 'run').mockImplementation(mockRun);
|
|
155
|
+
|
|
156
|
+
const response = await handleMessage({
|
|
157
|
+
jsonrpc: '2.0',
|
|
158
|
+
id: 7,
|
|
159
|
+
method: 'tools/call',
|
|
160
|
+
params: {
|
|
161
|
+
name: 'answer_human_input',
|
|
162
|
+
arguments: { run_id: runId, input: 'my response' },
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(JSON.parse(response.result.content[0].text).status).toBe('success');
|
|
167
|
+
|
|
168
|
+
// Verify DB was updated
|
|
169
|
+
const steps = db.getStepsByRun(runId);
|
|
170
|
+
expect(steps[0].status).toBe('success');
|
|
171
|
+
expect(steps[0].output).toBeDefined();
|
|
172
|
+
if (steps[0].output) {
|
|
173
|
+
expect(JSON.parse(steps[0].output)).toBe('my response');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should call get_run_logs tool with steps', async () => {
|
|
178
|
+
const runId = 'test-run-with-steps';
|
|
179
|
+
await db.createRun(runId, 'test-wf', {});
|
|
180
|
+
await db.createStep('step-1', runId, 's1');
|
|
181
|
+
await db.completeStep('step-1', 'success', { ok: true });
|
|
182
|
+
|
|
183
|
+
const response = await handleMessage({
|
|
184
|
+
jsonrpc: '2.0',
|
|
185
|
+
id: 6,
|
|
186
|
+
method: 'tools/call',
|
|
187
|
+
params: { name: 'get_run_logs', arguments: { run_id: runId } },
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
const summary = JSON.parse(response.result.content[0].text);
|
|
191
|
+
expect(summary.workflow).toBe('test-wf');
|
|
192
|
+
expect(summary.steps).toHaveLength(1);
|
|
193
|
+
expect(summary.steps[0].step).toBe('s1');
|
|
194
|
+
expect(summary.steps[0].output).toEqual({ ok: true });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should handle unknown tool', async () => {
|
|
198
|
+
const response = await handleMessage({
|
|
199
|
+
jsonrpc: '2.0',
|
|
200
|
+
id: 7,
|
|
201
|
+
method: 'tools/call',
|
|
202
|
+
params: { name: 'unknown_tool', arguments: {} },
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
expect(response.error.message).toContain('Unknown tool');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('should handle unknown method', async () => {
|
|
209
|
+
const response = await handleMessage({
|
|
210
|
+
jsonrpc: '2.0',
|
|
211
|
+
id: 8,
|
|
212
|
+
method: 'unknown_method',
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(response.error.message).toContain('Method not found');
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should start and handle messages from stdin', async () => {
|
|
219
|
+
const writeSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
220
|
+
const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
|
|
221
|
+
|
|
222
|
+
await server.start();
|
|
223
|
+
|
|
224
|
+
// Simulate stdin data
|
|
225
|
+
const message = {
|
|
226
|
+
jsonrpc: '2.0' as const,
|
|
227
|
+
id: 9,
|
|
228
|
+
method: 'initialize',
|
|
229
|
+
};
|
|
230
|
+
process.stdin.emit('data', Buffer.from(`${JSON.stringify(message)}\n`));
|
|
231
|
+
|
|
232
|
+
// Wait for async processing
|
|
233
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
234
|
+
|
|
235
|
+
expect(writeSpy).toHaveBeenCalled();
|
|
236
|
+
const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
|
|
237
|
+
expect(output.id).toBe(9);
|
|
238
|
+
|
|
239
|
+
writeSpy.mockRestore();
|
|
240
|
+
consoleSpy.mockRestore();
|
|
241
|
+
});
|
|
242
|
+
});
|