keystone-cli 0.2.0 → 0.3.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.
@@ -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();
@@ -374,7 +374,7 @@ describe('step-executor', () => {
374
374
  const step: WorkflowStep = {
375
375
  id: 'w1',
376
376
  type: 'workflow',
377
- workflow: 'child.yaml',
377
+ path: 'child.yaml',
378
378
  };
379
379
  // @ts-ignore
380
380
  const executeWorkflowFn = mock(() =>
@@ -392,7 +392,7 @@ describe('step-executor', () => {
392
392
  const step: WorkflowStep = {
393
393
  id: 'w1',
394
394
  type: 'workflow',
395
- workflow: 'child.yaml',
395
+ path: 'child.yaml',
396
396
  };
397
397
  const result = await executeStep(step, context);
398
398
  expect(result.status).toBe('failed');
@@ -42,7 +42,8 @@ export async function executeStep(
42
42
  context: ExpressionContext,
43
43
  logger: Logger = console,
44
44
  executeWorkflowFn?: (step: WorkflowStep, context: ExpressionContext) => Promise<StepResult>,
45
- mcpManager?: MCPManager
45
+ mcpManager?: MCPManager,
46
+ workflowDir?: string
46
47
  ): Promise<StepResult> {
47
48
  try {
48
49
  let result: StepResult;
@@ -66,9 +67,10 @@ export async function executeStep(
66
67
  result = await executeLlmStep(
67
68
  step,
68
69
  context,
69
- (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager),
70
+ (s, c) => executeStep(s, c, logger, executeWorkflowFn, mcpManager, workflowDir),
70
71
  logger,
71
- mcpManager
72
+ mcpManager,
73
+ workflowDir
72
74
  );
73
75
  break;
74
76
  case 'workflow':
@@ -180,6 +182,13 @@ async function executeFileStep(
180
182
  throw new Error('Content is required for write operation');
181
183
  }
182
184
  const content = ExpressionEvaluator.evaluateString(step.content, context);
185
+
186
+ // Ensure parent directory exists
187
+ const fs = await import('node:fs/promises');
188
+ const pathModule = await import('node:path');
189
+ const dir = pathModule.dirname(path);
190
+ await fs.mkdir(dir, { recursive: true });
191
+
183
192
  const bytes = await Bun.write(path, content);
184
193
  return {
185
194
  output: { path, bytes },
@@ -193,8 +202,13 @@ async function executeFileStep(
193
202
  }
194
203
  const content = ExpressionEvaluator.evaluateString(step.content, context);
195
204
 
196
- // Use Node.js fs for efficient append operation
205
+ // Ensure parent directory exists
197
206
  const fs = await import('node:fs/promises');
207
+ const pathModule = await import('node:path');
208
+ const dir = pathModule.dirname(path);
209
+ await fs.mkdir(dir, { recursive: true });
210
+
211
+ // Use Node.js fs for efficient append operation
198
212
  await fs.appendFile(path, content, 'utf-8');
199
213
 
200
214
  return {
@@ -310,10 +324,10 @@ async function executeHumanStep(
310
324
  try {
311
325
  if (step.inputType === 'confirm') {
312
326
  logger.log(`\n❓ ${message}`);
313
- logger.log('Press Enter to continue, or Ctrl+C to cancel...');
314
- await rl.question('');
327
+ const answer = await rl.question('Confirm? (Y/n): ');
328
+ const isConfirmed = answer.toLowerCase() !== 'n';
315
329
  return {
316
- output: true,
330
+ output: isConfirmed,
317
331
  status: 'success',
318
332
  };
319
333
  }
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { dirname } from 'node:path';
2
3
  import { WorkflowDb } from '../db/workflow-db.ts';
3
4
  import type { ExpressionContext } from '../expression/evaluator.ts';
4
5
  import { ExpressionEvaluator } from '../expression/evaluator.ts';
@@ -46,6 +47,7 @@ export interface RunOptions {
46
47
  logger?: Logger;
47
48
  mcpManager?: MCPManager;
48
49
  preventExit?: boolean; // Defaults to false
50
+ workflowDir?: string;
49
51
  }
50
52
 
51
53
  export interface StepContext {
@@ -456,7 +458,8 @@ export class WorkflowRunner {
456
458
  context,
457
459
  this.logger,
458
460
  this.executeSubWorkflow.bind(this),
459
- this.mcpManager
461
+ this.mcpManager,
462
+ this.options.workflowDir
460
463
  );
461
464
  if (result.status === 'failed') {
462
465
  throw new Error(result.error || 'Step failed');
@@ -700,6 +703,7 @@ export class WorkflowRunner {
700
703
  ): Promise<StepResult> {
701
704
  const workflowPath = WorkflowRegistry.resolvePath(step.path);
702
705
  const workflow = WorkflowParser.loadWorkflow(workflowPath);
706
+ const subWorkflowDir = dirname(workflowPath);
703
707
 
704
708
  // Evaluate inputs for the sub-workflow
705
709
  const inputs: Record<string, unknown> = {};
@@ -716,6 +720,7 @@ export class WorkflowRunner {
716
720
  dbPath: this.db.dbPath,
717
721
  logger: this.logger,
718
722
  mcpManager: this.mcpManager,
723
+ workflowDir: subWorkflowDir,
719
724
  });
720
725
 
721
726
  try {
@@ -874,7 +879,9 @@ export class WorkflowRunner {
874
879
  } finally {
875
880
  this.removeSignalHandlers();
876
881
  await this.runFinally();
877
- await this.mcpManager.stopAll();
882
+ if (!this.options.mcpManager) {
883
+ await this.mcpManager.stopAll();
884
+ }
878
885
  this.db.close();
879
886
  }
880
887
  }
@@ -149,4 +149,90 @@ describe('AuthManager', () => {
149
149
  consoleSpy.mockRestore();
150
150
  });
151
151
  });
152
+
153
+ describe('Device Login', () => {
154
+ it('initGitHubDeviceLogin should return device code data', async () => {
155
+ const mockFetch = mock(() =>
156
+ Promise.resolve(
157
+ new Response(
158
+ JSON.stringify({
159
+ device_code: 'dev_code',
160
+ user_code: 'USER-CODE',
161
+ verification_uri: 'https://github.com/login/device',
162
+ expires_in: 900,
163
+ interval: 5,
164
+ }),
165
+ { status: 200 }
166
+ )
167
+ )
168
+ );
169
+ // @ts-ignore
170
+ global.fetch = mockFetch;
171
+
172
+ const result = await AuthManager.initGitHubDeviceLogin();
173
+ expect(result.device_code).toBe('dev_code');
174
+ expect(result.user_code).toBe('USER-CODE');
175
+ expect(mockFetch).toHaveBeenCalled();
176
+ });
177
+
178
+ it('pollGitHubDeviceLogin should return token when successful', async () => {
179
+ let callCount = 0;
180
+ const mockFetch = mock(() => {
181
+ callCount++;
182
+ if (callCount === 1) {
183
+ return Promise.resolve(
184
+ new Response(
185
+ JSON.stringify({
186
+ error: 'authorization_pending',
187
+ }),
188
+ { status: 200 }
189
+ )
190
+ );
191
+ }
192
+ return Promise.resolve(
193
+ new Response(
194
+ JSON.stringify({
195
+ access_token: 'gh_access_token',
196
+ }),
197
+ { status: 200 }
198
+ )
199
+ );
200
+ });
201
+ // @ts-ignore
202
+ global.fetch = mockFetch;
203
+
204
+ // Mock setTimeout to resolve immediately
205
+ const originalTimeout = global.setTimeout;
206
+ // @ts-ignore
207
+ global.setTimeout = (fn) => fn();
208
+
209
+ try {
210
+ const token = await AuthManager.pollGitHubDeviceLogin('dev_code');
211
+ expect(token).toBe('gh_access_token');
212
+ expect(callCount).toBe(2);
213
+ } finally {
214
+ global.setTimeout = originalTimeout;
215
+ }
216
+ });
217
+
218
+ it('pollGitHubDeviceLogin should throw on other errors', async () => {
219
+ const mockFetch = mock(() =>
220
+ Promise.resolve(
221
+ new Response(
222
+ JSON.stringify({
223
+ error: 'expired_token',
224
+ error_description: 'The device code has expired',
225
+ }),
226
+ { status: 200 }
227
+ )
228
+ )
229
+ );
230
+ // @ts-ignore
231
+ global.fetch = mockFetch;
232
+
233
+ await expect(AuthManager.pollGitHubDeviceLogin('dev_code')).rejects.toThrow(
234
+ 'The device code has expired'
235
+ );
236
+ });
237
+ });
152
238
  });