keystone-cli 0.2.0 → 0.3.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.
@@ -57,12 +57,16 @@ class StdConfigTransport implements MCPTransport {
57
57
  const response = JSON.parse(line) as MCPResponse;
58
58
  callback(response);
59
59
  } catch (e) {
60
- // Ignore non-JSON lines
60
+ // Log non-JSON lines to stderr so they show up in the terminal
61
+ if (line.trim()) {
62
+ process.stderr.write(`[MCP Server Output] ${line}\n`);
63
+ }
61
64
  }
62
65
  });
63
66
  }
64
67
 
65
68
  close(): void {
69
+ this.rl.close();
66
70
  this.process.kill();
67
71
  }
68
72
  }
@@ -70,49 +74,168 @@ class StdConfigTransport implements MCPTransport {
70
74
  class SSETransport implements MCPTransport {
71
75
  private url: string;
72
76
  private headers: Record<string, string>;
73
- private eventSource: EventSource | null = null;
74
77
  private endpoint?: string;
75
78
  private onMessageCallback?: (message: MCPResponse) => void;
79
+ private abortController: AbortController | null = null;
80
+ private sessionId?: string;
76
81
 
77
82
  constructor(url: string, headers: Record<string, string> = {}) {
78
83
  this.url = url;
79
84
  this.headers = headers;
80
85
  }
81
86
 
82
- async connect(): Promise<void> {
83
- return new Promise((resolve, reject) => {
84
- // @ts-ignore - Bun supports EventSource
85
- this.eventSource = new EventSource(this.url, { headers: this.headers });
87
+ async connect(timeout = 60000): Promise<void> {
88
+ this.abortController = new AbortController();
86
89
 
87
- if (!this.eventSource) {
88
- reject(new Error('Failed to create EventSource'));
89
- return;
90
- }
90
+ return new Promise((resolve, reject) => {
91
+ const timeoutId = setTimeout(() => {
92
+ this.close();
93
+ reject(new Error(`SSE connection timeout: ${this.url}`));
94
+ }, timeout);
95
+
96
+ (async () => {
97
+ try {
98
+ let response = await fetch(this.url, {
99
+ headers: {
100
+ Accept: 'application/json, text/event-stream',
101
+ ...this.headers,
102
+ },
103
+ signal: this.abortController?.signal,
104
+ });
105
+
106
+ if (response.status === 405) {
107
+ // Some MCP servers (like GitHub) require POST to start a session
108
+ response = await fetch(this.url, {
109
+ method: 'POST',
110
+ headers: {
111
+ Accept: 'application/json, text/event-stream',
112
+ 'Content-Type': 'application/json',
113
+ ...this.headers,
114
+ },
115
+ body: JSON.stringify({
116
+ jsonrpc: '2.0',
117
+ id: 'ping',
118
+ method: 'ping',
119
+ }),
120
+ signal: this.abortController?.signal,
121
+ });
122
+ }
91
123
 
92
- this.eventSource.addEventListener('endpoint', (event: MessageEvent) => {
93
- this.endpoint = event.data;
94
- if (this.endpoint?.startsWith('/')) {
95
- const urlObj = new URL(this.url);
96
- this.endpoint = `${urlObj.origin}${this.endpoint}`;
97
- }
98
- resolve();
99
- });
124
+ if (!response.ok) {
125
+ clearTimeout(timeoutId);
126
+ reject(new Error(`SSE connection failed: ${response.status} ${response.statusText}`));
127
+ return;
128
+ }
100
129
 
101
- this.eventSource.addEventListener('message', (event: MessageEvent) => {
102
- if (this.onMessageCallback) {
103
- try {
104
- const response = JSON.parse(event.data) as MCPResponse;
105
- this.onMessageCallback(response);
106
- } catch (e) {
107
- // Ignore
130
+ // Check for session ID in headers
131
+ this.sessionId =
132
+ response.headers.get('mcp-session-id') ||
133
+ response.headers.get('Mcp-Session-Id') ||
134
+ undefined;
135
+
136
+ const reader = response.body?.getReader();
137
+ if (!reader) {
138
+ clearTimeout(timeoutId);
139
+ reject(new Error('Failed to get response body reader'));
140
+ return;
108
141
  }
109
- }
110
- });
111
142
 
112
- this.eventSource.onerror = (err) => {
113
- const error = err as ErrorEvent;
114
- reject(new Error(`SSE connection failed: ${error?.message || 'Unknown error'}`));
115
- };
143
+ // Process the stream in the background
144
+ (async () => {
145
+ let buffer = '';
146
+ const decoder = new TextDecoder();
147
+ let currentEvent: { event?: string; data?: string } = {};
148
+ let isResolved = false;
149
+
150
+ const dispatchEvent = () => {
151
+ if (currentEvent.data) {
152
+ if (currentEvent.event === 'endpoint') {
153
+ this.endpoint = currentEvent.data;
154
+ if (this.endpoint) {
155
+ this.endpoint = new URL(this.endpoint, this.url).href;
156
+ }
157
+ if (!isResolved) {
158
+ isResolved = true;
159
+ clearTimeout(timeoutId);
160
+ resolve();
161
+ }
162
+ } else if (!currentEvent.event || currentEvent.event === 'message') {
163
+ // If we get a message before an endpoint, assume the URL itself is the endpoint
164
+ // (Common in some MCP over SSE implementations like GitHub's)
165
+ if (!this.endpoint) {
166
+ this.endpoint = this.url;
167
+ if (!isResolved) {
168
+ isResolved = true;
169
+ clearTimeout(timeoutId);
170
+ resolve();
171
+ }
172
+ }
173
+
174
+ if (this.onMessageCallback && currentEvent.data) {
175
+ try {
176
+ const message = JSON.parse(currentEvent.data) as MCPResponse;
177
+ this.onMessageCallback(message);
178
+ } catch (e) {
179
+ // Ignore parse errors
180
+ }
181
+ }
182
+ }
183
+ }
184
+ currentEvent = {};
185
+ };
186
+
187
+ try {
188
+ while (true) {
189
+ const { done, value } = await reader.read();
190
+ if (done) {
191
+ // Dispatch any remaining data
192
+ dispatchEvent();
193
+ break;
194
+ }
195
+
196
+ buffer += decoder.decode(value, { stream: true });
197
+ const lines = buffer.split(/\r\n|\r|\n/);
198
+ buffer = lines.pop() || '';
199
+
200
+ for (const line of lines) {
201
+ if (line.trim() === '') {
202
+ dispatchEvent();
203
+ continue;
204
+ }
205
+
206
+ if (line.startsWith('event:')) {
207
+ currentEvent.event = line.substring(6).trim();
208
+ } else if (line.startsWith('data:')) {
209
+ const data = line.substring(5).trim();
210
+ currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
211
+ }
212
+ }
213
+ }
214
+
215
+ if (!isResolved) {
216
+ // If the stream ended before we resolved, but we have a session ID, we can try to resolve
217
+ if (this.sessionId && !this.endpoint) {
218
+ this.endpoint = this.url;
219
+ isResolved = true;
220
+ clearTimeout(timeoutId);
221
+ resolve();
222
+ } else {
223
+ clearTimeout(timeoutId);
224
+ reject(new Error('SSE stream ended before connection established'));
225
+ }
226
+ }
227
+ } catch (err) {
228
+ if ((err as Error).name !== 'AbortError' && !isResolved) {
229
+ clearTimeout(timeoutId);
230
+ reject(err);
231
+ }
232
+ }
233
+ })();
234
+ } catch (err) {
235
+ clearTimeout(timeoutId);
236
+ reject(err);
237
+ }
238
+ })();
116
239
  });
117
240
  }
118
241
 
@@ -121,17 +244,87 @@ class SSETransport implements MCPTransport {
121
244
  throw new Error('SSE transport not connected or endpoint not received');
122
245
  }
123
246
 
247
+ const headers = {
248
+ 'Content-Type': 'application/json',
249
+ ...this.headers,
250
+ };
251
+
252
+ if (this.sessionId) {
253
+ headers['mcp-session-id'] = this.sessionId;
254
+ }
255
+
124
256
  const response = await fetch(this.endpoint, {
125
257
  method: 'POST',
126
- headers: {
127
- 'Content-Type': 'application/json',
128
- ...this.headers,
129
- },
258
+ headers,
130
259
  body: JSON.stringify(message),
131
260
  });
132
261
 
133
262
  if (!response.ok) {
134
- throw new Error(`Failed to send message to MCP server: ${response.statusText}`);
263
+ const text = await response.text();
264
+ throw new Error(
265
+ `Failed to send message to MCP server: ${response.status} ${response.statusText}${
266
+ text ? ` - ${text}` : ''
267
+ }`
268
+ );
269
+ }
270
+
271
+ // Some MCP servers (like GitHub) send the response directly in the POST response as SSE
272
+ const contentType = response.headers.get('content-type');
273
+ if (contentType?.includes('text/event-stream')) {
274
+ const reader = response.body?.getReader();
275
+ if (reader) {
276
+ (async () => {
277
+ let buffer = '';
278
+ const decoder = new TextDecoder();
279
+ let currentEvent: { event?: string; data?: string } = {};
280
+
281
+ const dispatchEvent = () => {
282
+ if (
283
+ this.onMessageCallback &&
284
+ currentEvent.data &&
285
+ (!currentEvent.event || currentEvent.event === 'message')
286
+ ) {
287
+ try {
288
+ const message = JSON.parse(currentEvent.data) as MCPResponse;
289
+ this.onMessageCallback(message);
290
+ } catch (e) {
291
+ // Ignore parse errors
292
+ }
293
+ }
294
+ currentEvent = {};
295
+ };
296
+
297
+ try {
298
+ while (true) {
299
+ const { done, value } = await reader.read();
300
+ if (done) {
301
+ dispatchEvent();
302
+ break;
303
+ }
304
+
305
+ buffer += decoder.decode(value, { stream: true });
306
+ const lines = buffer.split(/\r\n|\r|\n/);
307
+ buffer = lines.pop() || '';
308
+
309
+ for (const line of lines) {
310
+ if (line.trim() === '') {
311
+ dispatchEvent();
312
+ continue;
313
+ }
314
+
315
+ if (line.startsWith('event:')) {
316
+ currentEvent.event = line.substring(6).trim();
317
+ } else if (line.startsWith('data:')) {
318
+ const data = line.substring(5).trim();
319
+ currentEvent.data = currentEvent.data ? `${currentEvent.data}\n${data}` : data;
320
+ }
321
+ }
322
+ }
323
+ } catch (e) {
324
+ // Ignore stream errors
325
+ }
326
+ })();
327
+ }
135
328
  }
136
329
  }
137
330
 
@@ -140,7 +333,7 @@ class SSETransport implements MCPTransport {
140
333
  }
141
334
 
142
335
  close(): void {
143
- this.eventSource?.close();
336
+ this.abortController?.abort();
144
337
  }
145
338
  }
146
339
 
@@ -154,14 +347,14 @@ export class MCPClient {
154
347
  transportOrCommand: MCPTransport | string,
155
348
  timeoutOrArgs: number | string[] = [],
156
349
  env: Record<string, string> = {},
157
- timeout = 30000
350
+ timeout = 60000
158
351
  ) {
159
352
  if (typeof transportOrCommand === 'string') {
160
353
  this.transport = new StdConfigTransport(transportOrCommand, timeoutOrArgs as string[], env);
161
354
  this.timeout = timeout;
162
355
  } else {
163
356
  this.transport = transportOrCommand;
164
- this.timeout = (timeoutOrArgs as number) || 30000;
357
+ this.timeout = (timeoutOrArgs as number) || 60000;
165
358
  }
166
359
 
167
360
  this.transport.onMessage((response) => {
@@ -179,7 +372,7 @@ export class MCPClient {
179
372
  command: string,
180
373
  args: string[] = [],
181
374
  env: Record<string, string> = {},
182
- timeout = 30000
375
+ timeout = 60000
183
376
  ): Promise<MCPClient> {
184
377
  const transport = new StdConfigTransport(command, args, env);
185
378
  return new MCPClient(transport, timeout);
@@ -188,10 +381,10 @@ export class MCPClient {
188
381
  static async createRemote(
189
382
  url: string,
190
383
  headers: Record<string, string> = {},
191
- timeout = 30000
384
+ timeout = 60000
192
385
  ): Promise<MCPClient> {
193
386
  const transport = new SSETransport(url, headers);
194
- await transport.connect();
387
+ await transport.connect(timeout);
195
388
  return new MCPClient(transport, timeout);
196
389
  }
197
390
 
@@ -10,6 +10,9 @@ export interface MCPServerConfig {
10
10
  env?: Record<string, string>;
11
11
  url?: string;
12
12
  headers?: Record<string, string>;
13
+ oauth?: {
14
+ scope?: string;
15
+ };
13
16
  }
14
17
 
15
18
  export class MCPManager {
@@ -70,10 +73,47 @@ export class MCPManager {
70
73
  try {
71
74
  if (config.type === 'remote') {
72
75
  if (!config.url) throw new Error('Remote MCP server missing URL');
73
- client = await MCPClient.createRemote(config.url, config.headers || {});
76
+
77
+ const headers = { ...(config.headers || {}) };
78
+
79
+ if (config.oauth) {
80
+ const { AuthManager } = await import('../utils/auth-manager');
81
+ const auth = AuthManager.load();
82
+ const token = auth.mcp_tokens?.[config.name]?.access_token;
83
+
84
+ if (!token) {
85
+ throw new Error(
86
+ `MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
87
+ );
88
+ }
89
+
90
+ headers.Authorization = `Bearer ${token}`;
91
+ }
92
+
93
+ client = await MCPClient.createRemote(config.url, headers);
74
94
  } else {
75
95
  if (!config.command) throw new Error('Local MCP server missing command');
76
- client = await MCPClient.createLocal(config.command, config.args || [], config.env || {});
96
+
97
+ const env = { ...(config.env || {}) };
98
+
99
+ if (config.oauth) {
100
+ const { AuthManager } = await import('../utils/auth-manager');
101
+ const auth = AuthManager.load();
102
+ const token = auth.mcp_tokens?.[config.name]?.access_token;
103
+
104
+ if (!token) {
105
+ throw new Error(
106
+ `MCP server ${config.name} requires OAuth. Please run "keystone mcp login ${config.name}" first.`
107
+ );
108
+ }
109
+
110
+ // Pass token to the local proxy via environment variables
111
+ // Most proxies expect AUTHORIZATION or MCP_TOKEN
112
+ env.AUTHORIZATION = `Bearer ${token}`;
113
+ env.MCP_TOKEN = token;
114
+ }
115
+
116
+ client = await MCPClient.createLocal(config.command, config.args || [], env);
77
117
  }
78
118
 
79
119
  await client.initialize();
@@ -28,7 +28,7 @@ describe('MCPServer', () => {
28
28
  method: 'initialize',
29
29
  });
30
30
 
31
- expect(response.result.serverInfo.name).toBe('keystone-mcp');
31
+ expect(response?.result?.serverInfo?.name).toBe('keystone-mcp');
32
32
  });
33
33
 
34
34
  it('should list tools', async () => {
@@ -38,9 +38,9 @@ describe('MCPServer', () => {
38
38
  method: 'tools/list',
39
39
  });
40
40
 
41
- expect(response.result.tools).toHaveLength(5);
41
+ expect(response?.result?.tools).toHaveLength(5);
42
42
  // @ts-ignore
43
- expect(response.result.tools.map((t) => t.name)).toContain('run_workflow');
43
+ expect(response?.result?.tools?.map((t) => t.name)).toContain('run_workflow');
44
44
  });
45
45
 
46
46
  it('should call list_workflows tool', async () => {
@@ -55,7 +55,7 @@ describe('MCPServer', () => {
55
55
  params: { name: 'list_workflows', arguments: {} },
56
56
  });
57
57
 
58
- expect(response.result.content[0].text).toContain('test-wf');
58
+ expect(response?.result?.content?.[0]?.text).toContain('test-wf');
59
59
  });
60
60
 
61
61
  it('should call run_workflow tool successfully', async () => {
@@ -104,8 +104,8 @@ describe('MCPServer', () => {
104
104
  },
105
105
  });
106
106
 
107
- expect(response.result.isError).toBe(true);
108
- expect(response.result.content[0].text).toContain('Workflow failed');
107
+ expect(response?.result?.isError).toBe(true);
108
+ expect(response?.result?.content?.[0]?.text).toContain('Workflow failed');
109
109
  });
110
110
 
111
111
  it('should handle workflow suspension in run_workflow', async () => {
@@ -130,7 +130,7 @@ describe('MCPServer', () => {
130
130
  },
131
131
  });
132
132
 
133
- const result = JSON.parse(response.result.content[0].text);
133
+ const result = JSON.parse(response?.result?.content?.[0]?.text);
134
134
  expect(result.status).toBe('paused');
135
135
  expect(result.run_id).toBe('run123');
136
136
  expect(result.message).toBe('Input needed');
@@ -187,7 +187,7 @@ describe('MCPServer', () => {
187
187
  params: { name: 'get_run_logs', arguments: { run_id: runId } },
188
188
  });
189
189
 
190
- const summary = JSON.parse(response.result.content[0].text);
190
+ const summary = JSON.parse(response?.result?.content?.[0]?.text);
191
191
  expect(summary.workflow).toBe('test-wf');
192
192
  expect(summary.steps).toHaveLength(1);
193
193
  expect(summary.steps[0].step).toBe('s1');
@@ -202,7 +202,7 @@ describe('MCPServer', () => {
202
202
  params: { name: 'unknown_tool', arguments: {} },
203
203
  });
204
204
 
205
- expect(response.error.message).toContain('Unknown tool');
205
+ expect(response?.error?.message).toContain('Unknown tool');
206
206
  });
207
207
 
208
208
  it('should handle unknown method', async () => {
@@ -212,14 +212,21 @@ describe('MCPServer', () => {
212
212
  method: 'unknown_method',
213
213
  });
214
214
 
215
- expect(response.error.message).toContain('Method not found');
215
+ expect(response?.error?.message).toContain('Method not found');
216
216
  });
217
217
 
218
218
  it('should start and handle messages from stdin', async () => {
219
- const writeSpy = spyOn(process.stdout, 'write').mockImplementation(() => true);
219
+ const { PassThrough } = await import('node:stream');
220
+ const input = new PassThrough();
221
+ const outputStream = new PassThrough();
222
+
223
+ // Create a new server for this test to use the streams
224
+ const testServer = new MCPServer(db, input, outputStream);
225
+
226
+ const writeSpy = spyOn(outputStream, 'write').mockImplementation(() => true);
220
227
  const consoleSpy = spyOn(console, 'error').mockImplementation(() => {});
221
228
 
222
- const startPromise = server.start();
229
+ const startPromise = testServer.start();
223
230
 
224
231
  // Simulate stdin data
225
232
  const message = {
@@ -227,16 +234,16 @@ describe('MCPServer', () => {
227
234
  id: 9,
228
235
  method: 'initialize',
229
236
  };
230
- process.stdin.emit('data', Buffer.from(`${JSON.stringify(message)}\n`));
237
+ input.write(`${JSON.stringify(message)}\n`);
231
238
 
232
239
  // Wait for async processing
233
- await new Promise((resolve) => setTimeout(resolve, 50));
240
+ await new Promise((resolve) => setTimeout(resolve, 100));
234
241
 
235
242
  expect(writeSpy).toHaveBeenCalled();
236
243
  const output = JSON.parse(writeSpy.mock.calls[0][0] as string);
237
244
  expect(output.id).toBe(9);
238
245
 
239
- process.stdin.emit('close');
246
+ input.end();
240
247
  await startPromise;
241
248
 
242
249
  writeSpy.mockRestore();
@@ -1,4 +1,5 @@
1
1
  import * as readline from 'node:readline';
2
+ import type { Readable, Writable } from 'node:stream';
2
3
  import pkg from '../../package.json' with { type: 'json' };
3
4
  import { WorkflowDb } from '../db/workflow-db';
4
5
  import { WorkflowParser } from '../parser/workflow-parser';
@@ -16,14 +17,18 @@ interface MCPMessage {
16
17
 
17
18
  export class MCPServer {
18
19
  private db: WorkflowDb;
20
+ private input: Readable;
21
+ private output: Writable;
19
22
 
20
- constructor(db?: WorkflowDb) {
23
+ constructor(db?: WorkflowDb, input: Readable = process.stdin, output: Writable = process.stdout) {
21
24
  this.db = db || new WorkflowDb();
25
+ this.input = input;
26
+ this.output = output;
22
27
  }
23
28
 
24
29
  async start() {
25
30
  const rl = readline.createInterface({
26
- input: process.stdin,
31
+ input: this.input,
27
32
  terminal: false,
28
33
  });
29
34
 
@@ -35,7 +40,7 @@ export class MCPServer {
35
40
  const message = JSON.parse(line) as MCPMessage;
36
41
  const response = await this.handleMessage(message);
37
42
  if (response) {
38
- process.stdout.write(`${JSON.stringify(response)}\n`);
43
+ this.output.write(`${JSON.stringify(response)}\n`);
39
44
  }
40
45
  } catch (error) {
41
46
  console.error('Error handling MCP message:', error);
@@ -46,6 +51,11 @@ export class MCPServer {
46
51
  this.stop();
47
52
  resolve();
48
53
  });
54
+
55
+ // Handle stream errors
56
+ this.input.on('error', (err: Error) => {
57
+ console.error('stdin error:', err);
58
+ });
49
59
  });
50
60
  }
51
61
 
@@ -333,7 +343,14 @@ export class MCPServer {
333
343
  }
334
344
 
335
345
  // Fulfill the step in the DB
336
- const output = input === 'confirm' ? true : input;
346
+ let output: unknown = input;
347
+ const lowerInput = input.trim().toLowerCase();
348
+ if (lowerInput === 'confirm' || lowerInput === 'y' || lowerInput === 'yes' || lowerInput === '') {
349
+ output = true;
350
+ } else if (lowerInput === 'n' || lowerInput === 'no') {
351
+ output = false;
352
+ }
353
+
337
354
  await this.db.completeStep(pendingStep.id, 'success', output);
338
355
 
339
356
  // Resume the workflow