@rayrun/cli 0.1.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.
@@ -0,0 +1,862 @@
1
+ /* eslint-disable node/no-process-env -- execution intentionally reads the invoking user's Rayrun configuration */
2
+ import { clearOAuthSession, connectExecutionMcp, openExternalUrl } from './oauth.js';
3
+ import { atomicWrite, assertNoSymbolicLinkComponents } from './setup.js';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { chmod, link, lstat, mkdir, readFile, readdir, rename, rm, stat } from 'node:fs/promises';
6
+ import os from 'node:os';
7
+ import path from 'node:path';
8
+ import { stripVTControlCharacters } from 'node:util';
9
+
10
+ const DEFAULT_MCP_ENDPOINT = 'https://ray.run/mcp/direct';
11
+ const INPUT_LIMIT_BYTES = 1024 * 1024;
12
+ const MAX_REQUEST_STATE_RETRIES = 10;
13
+ const PENDING_LIFETIME_MILLISECONDS = 10 * 60 * 1_000;
14
+ const PENDING_VERSION = 1;
15
+ const REQUEST_STATE_RETRY_DELAY_MILLISECONDS = 250;
16
+ const EXECUTION_ID_REGEX = /^[a-f\d-]{36}$/u;
17
+
18
+ export const executionUsage = `Execution (OAuth, no API key):
19
+ rayrun tools describe <service.tool> [--endpoint <url>] [--json] [--no-open]
20
+ rayrun call <service.tool> [<arguments-json> | --input <path|->] [--endpoint <url>] [--json] [--no-open]
21
+ rayrun resume --execution-id <id> [--json] [--no-open]
22
+ rayrun logout [--endpoint <url>]
23
+
24
+ Execution environment:
25
+ RAYRUN_MCP_URL Optional Direct Mode endpoint (defaults to https://ray.run/mcp/direct)`;
26
+
27
+ const isFileSystemError = (error, code) => {
28
+ return error && typeof error === 'object' && 'code' in error && error.code === code;
29
+ };
30
+
31
+ const configurationRoot = ({
32
+ environment = process.env,
33
+ homeDirectory = os.homedir(),
34
+ platform = process.platform,
35
+ } = {}) => {
36
+ const configuredRoot = environment.RAYRUN_CONFIG_HOME;
37
+ const root = configuredRoot ?? path.join(homeDirectory, '.rayrun');
38
+ if (!path.isAbsolute(root)) throw new Error('RAYRUN_CONFIG_HOME must be an absolute path.');
39
+ if (platform === 'win32' && configuredRoot !== undefined) {
40
+ const relative = path.relative(homeDirectory, root);
41
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
42
+ throw new Error('On Windows, RAYRUN_CONFIG_HOME must stay inside the current user profile.');
43
+ }
44
+ }
45
+ return root;
46
+ };
47
+
48
+ const pendingDirectory = (options) => path.join(configurationRoot(options), 'execution', 'pending');
49
+
50
+ const pendingPath = (executionId, options) => {
51
+ if (!EXECUTION_ID_REGEX.test(executionId)) throw new Error('Invalid execution id.');
52
+ return path.join(pendingDirectory(options), `${executionId}.json`);
53
+ };
54
+
55
+ const claimedPendingPath = (executionId, options) => {
56
+ if (!EXECUTION_ID_REGEX.test(executionId)) throw new Error('Invalid execution id.');
57
+ return path.join(pendingDirectory(options), `${executionId}.resuming`);
58
+ };
59
+
60
+ const ensurePrivateDirectory = async (directory) => {
61
+ await assertNoSymbolicLinkComponents(directory);
62
+ await mkdir(directory, { mode: 0o700, recursive: true });
63
+ await assertNoSymbolicLinkComponents(directory);
64
+ await chmod(directory, 0o700);
65
+ };
66
+
67
+ const readPrivateJson = async (target, { platform = process.platform } = {}) => {
68
+ await assertNoSymbolicLinkComponents(target);
69
+
70
+ try {
71
+ const metadata = await lstat(target);
72
+ if (!metadata.isFile() || (platform !== 'win32' && (metadata.mode & 0o077) !== 0)) {
73
+ throw new Error(`Refusing to read pending execution without 0600 permissions: ${target}`);
74
+ }
75
+ return JSON.parse(await readFile(target, 'utf8'));
76
+ } catch (error) {
77
+ if (isFileSystemError(error, 'ENOENT')) return null;
78
+ throw error;
79
+ }
80
+ };
81
+
82
+ const assertPendingShape = (pending, executionId) => {
83
+ const valid =
84
+ pending &&
85
+ typeof pending === 'object' &&
86
+ !Array.isArray(pending) &&
87
+ pending.version === PENDING_VERSION &&
88
+ pending.executionId === executionId &&
89
+ typeof pending.endpoint === 'string' &&
90
+ typeof pending.toolName === 'string' &&
91
+ pending.arguments &&
92
+ typeof pending.arguments === 'object' &&
93
+ !Array.isArray(pending.arguments) &&
94
+ pending.toolDefinition &&
95
+ typeof pending.toolDefinition === 'object' &&
96
+ !Array.isArray(pending.toolDefinition) &&
97
+ Array.isArray(pending.nextInputResponseIds) &&
98
+ pending.nextInputResponseIds.every((value) => typeof value === 'string') &&
99
+ pending.inputResponses &&
100
+ typeof pending.inputResponses === 'object' &&
101
+ !Array.isArray(pending.inputResponses) &&
102
+ typeof pending.expiresAt === 'number' &&
103
+ Number.isFinite(pending.expiresAt) &&
104
+ (pending.requestState === undefined || typeof pending.requestState === 'string');
105
+
106
+ if (!valid) {
107
+ throw new Error(`Pending execution ${executionId} is invalid. Remove it and repeat the call.`);
108
+ }
109
+ return pending;
110
+ };
111
+
112
+ const readPending = async (executionId, options, linkFile = link) => {
113
+ const target = pendingPath(executionId, options);
114
+ const claimedTarget = claimedPendingPath(executionId, options);
115
+ const throwClaimState = async () => {
116
+ const claimed = await readPrivateJson(claimedTarget, options);
117
+ if (claimed === null) throw new Error(`Pending execution not found: ${executionId}`);
118
+ const pending = assertPendingShape(claimed, executionId);
119
+ if (pending.expiresAt <= Date.now()) {
120
+ await rm(claimedTarget, { force: true });
121
+ await rm(target, { force: true });
122
+ throw new Error(`Pending execution expired: ${executionId}. Repeat the original call.`);
123
+ }
124
+ throw new Error(`Pending execution is already being resumed: ${executionId}`);
125
+ };
126
+ await assertNoSymbolicLinkComponents(target);
127
+ await assertNoSymbolicLinkComponents(claimedTarget);
128
+ try {
129
+ // A hard link gives this claim create-if-absent semantics; rename would overwrite an active
130
+ // claim during the brief window where a newer continuation has already been written.
131
+ await linkFile(target, claimedTarget);
132
+ } catch (error) {
133
+ if (isFileSystemError(error, 'EEXIST')) {
134
+ await throwClaimState();
135
+ }
136
+ if (
137
+ ['ENOSYS', 'ENOTSUP', 'EOPNOTSUPP', 'EPERM'].some((code) => isFileSystemError(error, code))
138
+ ) {
139
+ throw new Error(
140
+ 'Pending execution storage must support hard links for atomic resume. Move RAYRUN_CONFIG_HOME to a local hard-link-capable filesystem.',
141
+ );
142
+ }
143
+ if (!isFileSystemError(error, 'ENOENT')) throw error;
144
+ await throwClaimState();
145
+ }
146
+ try {
147
+ await rm(target);
148
+ } catch (error) {
149
+ await rm(claimedTarget, { force: true });
150
+ throw error;
151
+ }
152
+
153
+ let pending;
154
+ try {
155
+ pending = assertPendingShape(await readPrivateJson(claimedTarget, options), executionId);
156
+ } catch (error) {
157
+ await rename(claimedTarget, target);
158
+ throw error;
159
+ }
160
+ if (pending.expiresAt <= Date.now()) {
161
+ await rm(claimedTarget, { force: true });
162
+ throw new Error(`Pending execution expired: ${executionId}. Repeat the original call.`);
163
+ }
164
+ return pending;
165
+ };
166
+
167
+ const writePending = async (pending, options) => {
168
+ const target = pendingPath(pending.executionId, options);
169
+ await ensurePrivateDirectory(path.dirname(target));
170
+ await atomicWrite({
171
+ contents: Buffer.from(`${JSON.stringify(pending, null, 2)}\n`),
172
+ mode: 0o600,
173
+ target,
174
+ });
175
+ };
176
+
177
+ const releasePendingClaim = async (executionId, options, completed) => {
178
+ const target = pendingPath(executionId, options);
179
+ const claimedTarget = claimedPendingPath(executionId, options);
180
+ if (completed) {
181
+ await rm(claimedTarget, { force: true });
182
+ return;
183
+ }
184
+
185
+ try {
186
+ await lstat(target);
187
+ // A new continuation superseded the claimed round before browser launching failed.
188
+ await rm(claimedTarget, { force: true });
189
+ } catch (error) {
190
+ if (!isFileSystemError(error, 'ENOENT')) throw error;
191
+ await rename(claimedTarget, target).catch((renameError) => {
192
+ if (!isFileSystemError(renameError, 'ENOENT')) throw renameError;
193
+ });
194
+ }
195
+ };
196
+
197
+ const assertPendingClaimStillExists = async (pending, options) => {
198
+ const claimed = await readPrivateJson(claimedPendingPath(pending.executionId, options), options);
199
+ if (claimed === null) {
200
+ throw new Error(`Pending execution was cancelled before resume: ${pending.executionId}`);
201
+ }
202
+ assertPendingShape(claimed, pending.executionId);
203
+ };
204
+
205
+ const clearPendingForEndpoint = async (endpoint, options) => {
206
+ const directory = pendingDirectory(options);
207
+ await assertNoSymbolicLinkComponents(directory);
208
+ let names;
209
+ try {
210
+ names = await readdir(directory);
211
+ } catch (error) {
212
+ if (isFileSystemError(error, 'ENOENT')) return 0;
213
+ throw error;
214
+ }
215
+
216
+ const removedExecutionIds = new Set();
217
+ for (const name of names) {
218
+ const suffix = name.endsWith('.json')
219
+ ? '.json'
220
+ : name.endsWith('.resuming')
221
+ ? '.resuming'
222
+ : null;
223
+ if (suffix === null) continue;
224
+ const executionId = name.slice(0, -suffix.length);
225
+ if (!EXECUTION_ID_REGEX.test(executionId)) continue;
226
+ let pending;
227
+ try {
228
+ pending = await readPrivateJson(path.join(directory, name), options);
229
+ } catch {
230
+ // A damaged entry must not prevent logout from removing the valid sessions it can identify.
231
+ continue;
232
+ }
233
+ if (pending?.endpoint !== endpoint) continue;
234
+ await rm(path.join(directory, name), { force: true });
235
+ removedExecutionIds.add(executionId);
236
+ }
237
+ return removedExecutionIds.size;
238
+ };
239
+
240
+ const pruneExpiredPending = async (options, preservedExecutionId) => {
241
+ const directory = pendingDirectory(options);
242
+ await assertNoSymbolicLinkComponents(directory);
243
+ let names;
244
+ try {
245
+ names = await readdir(directory);
246
+ } catch (error) {
247
+ if (isFileSystemError(error, 'ENOENT')) return;
248
+ throw error;
249
+ }
250
+
251
+ for (const name of names) {
252
+ if (!name.endsWith('.json') && !name.endsWith('.resuming')) continue;
253
+ if (
254
+ typeof preservedExecutionId === 'string' &&
255
+ (name === `${preservedExecutionId}.json` || name === `${preservedExecutionId}.resuming`)
256
+ ) {
257
+ continue;
258
+ }
259
+ const target = path.join(directory, name);
260
+ let pending;
261
+ try {
262
+ pending = await readPrivateJson(target, options);
263
+ } catch {
264
+ // The explicitly resumed id is validated by readPending; unrelated damage is isolated.
265
+ continue;
266
+ }
267
+ if (typeof pending?.expiresAt === 'number' && pending.expiresAt <= Date.now()) {
268
+ await rm(target, { force: true });
269
+ }
270
+ }
271
+ };
272
+
273
+ const validateWebUrl = (value, label) => {
274
+ let url;
275
+ try {
276
+ url = new URL(value);
277
+ } catch {
278
+ throw new Error(`${label} must be an absolute HTTP or HTTPS URL.`);
279
+ }
280
+ if (!['http:', 'https:'].includes(url.protocol)) {
281
+ throw new Error(`${label} must use HTTP or HTTPS.`);
282
+ }
283
+ if (
284
+ url.protocol === 'http:' &&
285
+ !['127.0.0.1', '::1', '[::1]', 'localhost'].includes(url.hostname)
286
+ ) {
287
+ throw new Error(`${label} may use HTTP only on localhost.`);
288
+ }
289
+ if (url.username || url.password) throw new Error(`${label} cannot contain credentials.`);
290
+ return url;
291
+ };
292
+
293
+ export const validateExecutionEndpoint = (value) => {
294
+ const endpoint = validateWebUrl(value, 'MCP endpoint');
295
+ if (endpoint.search || endpoint.hash) {
296
+ throw new Error('MCP endpoint cannot contain a query string or fragment.');
297
+ }
298
+ endpoint.pathname = endpoint.pathname.replace(/\/$/u, '');
299
+ if (!endpoint.pathname.endsWith('/mcp/direct')) {
300
+ throw new Error('Execution MCP endpoint path must end in /mcp/direct.');
301
+ }
302
+ return endpoint.href;
303
+ };
304
+
305
+ const optionNames = new Map([
306
+ ['--endpoint', 'endpoint'],
307
+ ['--execution-id', 'executionId'],
308
+ ['--input', 'inputPath'],
309
+ ]);
310
+
311
+ const parseCommandArguments = (arguments_, allowedOptions) => {
312
+ const options = { json: false, noOpen: false };
313
+ const positional = [];
314
+
315
+ for (let index = 0; index < arguments_.length; index += 1) {
316
+ const argument = arguments_[index];
317
+ if (argument === '--json' || argument === '--no-open') {
318
+ const key = argument === '--json' ? 'json' : 'noOpen';
319
+ if (!allowedOptions.has(key)) throw new Error(`Unknown option: ${argument}`);
320
+ if (options[key]) throw new Error(`${argument} can only be used once.`);
321
+ options[key] = true;
322
+ continue;
323
+ }
324
+ const optionName = optionNames.get(argument);
325
+ if (optionName) {
326
+ if (!allowedOptions.has(optionName)) throw new Error(`Unknown option: ${argument}`);
327
+ const value = arguments_[index + 1];
328
+ if (!value || (value.startsWith('--') && value !== '-')) {
329
+ throw new Error(`${argument} requires a value.`);
330
+ }
331
+ if (options[optionName] !== undefined) {
332
+ throw new Error(`${argument} can only be used once.`);
333
+ }
334
+ options[optionName] = value;
335
+ index += 1;
336
+ continue;
337
+ }
338
+ if (argument?.startsWith('--')) throw new Error(`Unknown option: ${argument}`);
339
+ if (argument) positional.push(argument);
340
+ }
341
+ return { options, positional };
342
+ };
343
+
344
+ const resolveEndpoint = (options, environment) => {
345
+ return validateExecutionEndpoint(
346
+ options.endpoint ?? environment.RAYRUN_MCP_URL ?? DEFAULT_MCP_ENDPOINT,
347
+ );
348
+ };
349
+
350
+ const parseJsonObject = (contents) => {
351
+ let parsed;
352
+ try {
353
+ parsed = JSON.parse(contents);
354
+ } catch {
355
+ throw new Error('Tool arguments must be valid JSON.');
356
+ }
357
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
358
+ throw new Error('Tool arguments must be a JSON object.');
359
+ }
360
+ return parsed;
361
+ };
362
+
363
+ const readStream = async (input) => {
364
+ const chunks = [];
365
+ let size = 0;
366
+ for await (const chunk of input) {
367
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
368
+ size += buffer.length;
369
+ if (size > INPUT_LIMIT_BYTES) throw new Error('Tool arguments exceed the 1 MiB input limit.');
370
+ chunks.push(buffer);
371
+ }
372
+ return Buffer.concat(chunks).toString('utf8');
373
+ };
374
+
375
+ const readArguments = async ({ input, inputPath, positional }) => {
376
+ if (inputPath !== undefined && positional.length > 1) {
377
+ throw new Error('Use either inline JSON or --input, not both.');
378
+ }
379
+ if (positional.length > 2) throw new Error(executionUsage);
380
+ if (inputPath === undefined) return parseJsonObject(positional[1] ?? '{}');
381
+ if (inputPath === '-') return parseJsonObject(await readStream(input));
382
+
383
+ const metadata = await stat(inputPath);
384
+ if (!metadata.isFile()) throw new Error(`Tool input is not a file: ${inputPath}`);
385
+ if (metadata.size > INPUT_LIMIT_BYTES)
386
+ throw new Error('Tool arguments exceed the 1 MiB input limit.');
387
+ return parseJsonObject(await readFile(inputPath, 'utf8'));
388
+ };
389
+
390
+ const cleanLine = (value) => {
391
+ return stripVTControlCharacters(String(value))
392
+ .replaceAll(/\p{Cc}+/gu, ' ')
393
+ .replaceAll(/\s+/gu, ' ')
394
+ .trim();
395
+ };
396
+
397
+ const cleanText = (value) => {
398
+ return stripVTControlCharacters(String(value))
399
+ .split('\n')
400
+ .map((line) => line.replaceAll(/\p{Cc}+/gu, ''))
401
+ .join('\n');
402
+ };
403
+
404
+ const writeJson = (output, value) => output.write(`${JSON.stringify(value, null, 2)}\n`);
405
+
406
+ const renderTool = ({ json, output, tool }) => {
407
+ if (json) {
408
+ writeJson(output, tool);
409
+ return;
410
+ }
411
+ output.write(`Name: ${cleanLine(tool.name)}\n`);
412
+ if (tool.title) output.write(`Title: ${cleanLine(tool.title)}\n`);
413
+ if (tool.description) output.write(`Description: ${cleanText(tool.description)}\n`);
414
+ output.write(`Input schema:\n${JSON.stringify(tool.inputSchema, null, 2)}\n`);
415
+ if (tool.outputSchema !== undefined) {
416
+ output.write(`Output schema:\n${JSON.stringify(tool.outputSchema, null, 2)}\n`);
417
+ }
418
+ if (tool.annotations !== undefined) {
419
+ output.write(`Annotations:\n${JSON.stringify(tool.annotations, null, 2)}\n`);
420
+ }
421
+ };
422
+
423
+ const renderResult = ({ json, output, result }) => {
424
+ if (json) {
425
+ writeJson(output, result);
426
+ return;
427
+ }
428
+ let wroteContent = false;
429
+ for (const content of result.content ?? []) {
430
+ if (content?.type === 'text' && typeof content.text === 'string') {
431
+ const text = cleanText(content.text);
432
+ output.write(`${text}${text.endsWith('\n') ? '' : '\n'}`);
433
+ } else {
434
+ writeJson(output, content);
435
+ }
436
+ wroteContent = true;
437
+ }
438
+ if (!wroteContent && result.structuredContent !== undefined) {
439
+ writeJson(output, result.structuredContent);
440
+ }
441
+ if (!wroteContent && result.structuredContent === undefined)
442
+ output.write('Tool returned no content.\n');
443
+ };
444
+
445
+ class TerminalToolError extends Error {}
446
+
447
+ const findTool = async (client, name) => {
448
+ const { tools } = await client.listTools();
449
+ const tool = tools.find((candidate) => candidate.name === name);
450
+ if (!tool) throw new Error(`Tool not found: ${name}`);
451
+ return tool;
452
+ };
453
+
454
+ const readUrlContinuations = (result) => {
455
+ if (result?.resultType === 'input_required') {
456
+ const requests = Object.entries(result.inputRequests ?? {});
457
+ if (requests.length === 0) {
458
+ throw new Error('The tool requested unsupported continuation input.');
459
+ }
460
+ return requests.map(([id, request]) => {
461
+ const parameters = request?.params;
462
+ if (
463
+ request?.method !== 'elicitation/create' ||
464
+ parameters?.mode !== 'url' ||
465
+ typeof parameters.message !== 'string' ||
466
+ typeof parameters.url !== 'string'
467
+ ) {
468
+ throw new Error('The Rayrun CLI can resume URL requests only.');
469
+ }
470
+ return {
471
+ id,
472
+ message: parameters.message,
473
+ url: validateWebUrl(parameters.url, 'Continuation URL').href,
474
+ };
475
+ });
476
+ }
477
+
478
+ const approval = result?._meta?.['io.rayrun/approval'];
479
+ if (
480
+ result?.isError === true &&
481
+ approval?.state === 'pending' &&
482
+ typeof approval.approvalUrl === 'string'
483
+ ) {
484
+ return [
485
+ {
486
+ id: null,
487
+ message: 'Review and approve this exact call in Rayrun.',
488
+ url: validateWebUrl(approval.approvalUrl, 'Approval URL').href,
489
+ },
490
+ ];
491
+ }
492
+ return null;
493
+ };
494
+
495
+ const pauseExecution = async ({
496
+ arguments: toolArguments,
497
+ endpoint,
498
+ errorOutput,
499
+ executionId = randomUUID(),
500
+ inputResponses,
501
+ json,
502
+ noOpen,
503
+ openUrl,
504
+ output,
505
+ priorRequestState,
506
+ result,
507
+ storageOptions,
508
+ toolDefinition,
509
+ toolName,
510
+ }) => {
511
+ const requests = readUrlContinuations(result);
512
+ if (requests === null) return null;
513
+ const approvalRequestState = result?._meta?.['io.rayrun/approval']?.requestState;
514
+ const approvalExpiresAt = result?._meta?.['io.rayrun/approval']?.expiresAt;
515
+ const parsedApprovalExpiresAt =
516
+ typeof approvalExpiresAt === 'string' ? Date.parse(approvalExpiresAt) : Number.NaN;
517
+ const nextRequestState =
518
+ result.resultType === 'input_required'
519
+ ? result.requestState
520
+ : typeof approvalRequestState === 'string'
521
+ ? approvalRequestState
522
+ : priorRequestState;
523
+ const pending = {
524
+ arguments: toolArguments,
525
+ endpoint,
526
+ executionId,
527
+ expiresAt: Math.min(
528
+ Date.now() + PENDING_LIFETIME_MILLISECONDS,
529
+ Number.isFinite(parsedApprovalExpiresAt) ? parsedApprovalExpiresAt : Number.POSITIVE_INFINITY,
530
+ ),
531
+ inputResponses,
532
+ nextInputResponseIds: requests.flatMap((request) => (request.id === null ? [] : [request.id])),
533
+ ...(nextRequestState === undefined ? {} : { requestState: nextRequestState }),
534
+ toolDefinition,
535
+ toolName,
536
+ version: PENDING_VERSION,
537
+ };
538
+ await writePending(pending, storageOptions);
539
+
540
+ if (json) {
541
+ writeJson(output, {
542
+ executionId,
543
+ requests: requests.map(({ message, url }) => ({ message, url })),
544
+ status: 'input_required',
545
+ });
546
+ } else {
547
+ output.write(`Execution paused: ${executionId}\n`);
548
+ for (const request of requests) output.write(`${cleanLine(request.message)}\n`);
549
+ output.write(`Resume: rayrun resume --execution-id ${executionId}\n`);
550
+ }
551
+ for (const request of requests) {
552
+ await openUrl(request.url, { errorOutput, noOpen });
553
+ }
554
+ return executionId;
555
+ };
556
+
557
+ const callTool = async ({
558
+ arguments: toolArguments,
559
+ client,
560
+ endpoint,
561
+ errorOutput,
562
+ executionId,
563
+ inputResponses = {},
564
+ json,
565
+ noOpen,
566
+ openUrl,
567
+ output,
568
+ requestState,
569
+ sleep,
570
+ storageOptions,
571
+ toolDefinition,
572
+ toolName,
573
+ }) => {
574
+ // The MCP 2.0 client validates outputSchema before returning input_required, even though
575
+ // continuation legs have no final structuredContent yet. Keep the input schema (including
576
+ // x-mcp-header declarations) while deferring output validation to the authoritative server.
577
+ const { outputSchema: _outputSchema, ...callToolDefinition } = toolDefinition;
578
+ let continuationState = requestState;
579
+ let requestStateRetries = 0;
580
+ let result;
581
+ while (true) {
582
+ result = await client.callTool(
583
+ {
584
+ arguments: toolArguments,
585
+ ...(Object.keys(inputResponses).length === 0 ? {} : { inputResponses }),
586
+ name: toolName,
587
+ ...(continuationState === undefined ? {} : { requestState: continuationState }),
588
+ },
589
+ { allowInputRequired: true, toolDefinition: callToolDefinition },
590
+ );
591
+ const hasInputRequests = Object.keys(result?.inputRequests ?? {}).length > 0;
592
+ if (
593
+ result?.resultType !== 'input_required' ||
594
+ hasInputRequests ||
595
+ typeof result.requestState !== 'string'
596
+ ) {
597
+ break;
598
+ }
599
+ if (requestStateRetries >= MAX_REQUEST_STATE_RETRIES) {
600
+ throw new Error('The tool did not finish after 10 continuation retries.');
601
+ }
602
+ requestStateRetries += 1;
603
+ continuationState = result.requestState;
604
+ await sleep(REQUEST_STATE_RETRY_DELAY_MILLISECONDS);
605
+ }
606
+ const paused = await pauseExecution({
607
+ arguments: toolArguments,
608
+ endpoint,
609
+ errorOutput,
610
+ executionId,
611
+ inputResponses,
612
+ json,
613
+ noOpen,
614
+ openUrl,
615
+ output,
616
+ priorRequestState: continuationState,
617
+ result,
618
+ storageOptions,
619
+ toolDefinition,
620
+ toolName,
621
+ });
622
+ if (paused !== null) return { paused: true };
623
+
624
+ renderResult({ json, output, result });
625
+ if (result.isError) throw new TerminalToolError(`Tool call failed: ${toolName}`);
626
+ return { paused: false };
627
+ };
628
+
629
+ const revokeSession = async ({ errorOutput, fetchImplementation, session }) => {
630
+ const metadata = session.discoveryState?.authorizationServerMetadata;
631
+ const revocationEndpoint = metadata?.revocation_endpoint;
632
+ if (typeof revocationEndpoint !== 'string') return;
633
+ const revocationIssuer = metadata?.issuer ?? session.discoveryState?.authorizationServerUrl;
634
+ let endpoint;
635
+ try {
636
+ endpoint = validateWebUrl(revocationEndpoint, 'OAuth revocation endpoint');
637
+ } catch (error) {
638
+ errorOutput.write(`Warning: ${error.message}\n`);
639
+ return;
640
+ }
641
+
642
+ for (const [issuer, tokens] of Object.entries(session.tokensByIssuer)) {
643
+ if (issuer !== revocationIssuer) {
644
+ errorOutput.write(
645
+ `Warning: skipped token revocation for an older OAuth issuer: ${issuer}.\n`,
646
+ );
647
+ continue;
648
+ }
649
+ const token = tokens?.refresh_token ?? tokens?.access_token;
650
+ if (typeof token !== 'string') continue;
651
+ const body = new URLSearchParams({ token });
652
+ body.set('token_type_hint', tokens.refresh_token ? 'refresh_token' : 'access_token');
653
+ const clientId = session.clientInformationByIssuer[issuer]?.client_id;
654
+ if (typeof clientId === 'string') body.set('client_id', clientId);
655
+ try {
656
+ const response = await fetchImplementation(endpoint, {
657
+ body,
658
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
659
+ method: 'POST',
660
+ signal: AbortSignal.timeout(10_000),
661
+ });
662
+ if (!response.ok) {
663
+ errorOutput.write(
664
+ `Warning: OAuth token revocation returned HTTP ${String(response.status)}.\n`,
665
+ );
666
+ }
667
+ } catch (error) {
668
+ errorOutput.write(
669
+ `Warning: OAuth token revocation failed: ${error instanceof Error ? error.message : String(error)}\n`,
670
+ );
671
+ }
672
+ }
673
+ };
674
+
675
+ export const isExecutionCommand = (arguments_) => {
676
+ return (
677
+ arguments_[0] === 'call' ||
678
+ arguments_[0] === 'resume' ||
679
+ arguments_[0] === 'logout' ||
680
+ (arguments_[0] === 'tools' && arguments_[1] === 'describe')
681
+ );
682
+ };
683
+
684
+ export const runExecutionCommand = async (
685
+ arguments_,
686
+ {
687
+ connect = connectExecutionMcp,
688
+ environment = process.env,
689
+ errorOutput = process.stderr,
690
+ fetchImplementation = globalThis.fetch,
691
+ input = process.stdin,
692
+ linkFile = link,
693
+ openUrl = openExternalUrl,
694
+ output = process.stdout,
695
+ sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)),
696
+ storageOptions = { environment },
697
+ version = '0.0.0',
698
+ } = {},
699
+ ) => {
700
+ const command = arguments_[0];
701
+ const action = arguments_[1];
702
+ const executionIdOptionIndex = arguments_.indexOf('--execution-id');
703
+ await pruneExpiredPending(
704
+ storageOptions,
705
+ command === 'resume' && executionIdOptionIndex >= 0
706
+ ? arguments_[executionIdOptionIndex + 1]
707
+ : undefined,
708
+ );
709
+
710
+ if (command === 'logout') {
711
+ const { options, positional } = parseCommandArguments(
712
+ arguments_.slice(1),
713
+ new Set(['endpoint']),
714
+ );
715
+ if (positional.length > 0) throw new Error(executionUsage);
716
+ const endpoint = resolveEndpoint(options, environment);
717
+ let removed = 0;
718
+ const session = await clearOAuthSession(endpoint, storageOptions, async () => {
719
+ removed = await clearPendingForEndpoint(endpoint, storageOptions);
720
+ });
721
+ await revokeSession({ errorOutput, fetchImplementation, session });
722
+ output.write(
723
+ `Logged out of ${endpoint}.${removed > 0 ? ` Removed ${String(removed)} pending execution(s).` : ''}\n`,
724
+ );
725
+ return;
726
+ }
727
+
728
+ if (command === 'resume') {
729
+ const { options, positional } = parseCommandArguments(
730
+ arguments_.slice(1),
731
+ new Set(['executionId', 'json', 'noOpen']),
732
+ );
733
+ if (positional.length > 0 || !options.executionId) throw new Error(executionUsage);
734
+ const pending = await readPending(options.executionId, storageOptions, linkFile);
735
+ let completed = false;
736
+ try {
737
+ const inputResponses = {
738
+ ...pending.inputResponses,
739
+ ...Object.fromEntries(pending.nextInputResponseIds.map((id) => [id, { action: 'accept' }])),
740
+ };
741
+ const connection = await connect({
742
+ endpoint: pending.endpoint,
743
+ errorOutput,
744
+ noOpen: options.noOpen,
745
+ openUrl,
746
+ storageOptions,
747
+ version,
748
+ });
749
+ try {
750
+ // Logout can win the endpoint lock after this process claims the file but before connect.
751
+ // Rechecking under connect's lock prevents a cancelled exact call from executing afterward.
752
+ await assertPendingClaimStillExists(pending, storageOptions);
753
+ await callTool({
754
+ arguments: pending.arguments,
755
+ client: connection.client,
756
+ endpoint: pending.endpoint,
757
+ errorOutput,
758
+ executionId: pending.executionId,
759
+ inputResponses,
760
+ json: options.json,
761
+ noOpen: options.noOpen,
762
+ openUrl,
763
+ output,
764
+ requestState: pending.requestState,
765
+ sleep,
766
+ storageOptions,
767
+ toolDefinition: pending.toolDefinition,
768
+ toolName: pending.toolName,
769
+ });
770
+ completed = true;
771
+ } catch (error) {
772
+ if (error instanceof TerminalToolError) completed = true;
773
+ throw error;
774
+ } finally {
775
+ try {
776
+ await releasePendingClaim(pending.executionId, storageOptions, completed);
777
+ } finally {
778
+ await connection.close();
779
+ }
780
+ }
781
+ } finally {
782
+ if (!completed) {
783
+ await releasePendingClaim(pending.executionId, storageOptions, false).catch((error) => {
784
+ if (!isFileSystemError(error, 'ENOENT')) throw error;
785
+ });
786
+ }
787
+ }
788
+ return;
789
+ }
790
+
791
+ if (command === 'tools' && action === 'describe') {
792
+ const { options, positional } = parseCommandArguments(
793
+ arguments_.slice(2),
794
+ new Set(['endpoint', 'json', 'noOpen']),
795
+ );
796
+ if (positional.length !== 1) throw new Error(executionUsage);
797
+ const endpoint = resolveEndpoint(options, environment);
798
+ const connection = await connect({
799
+ endpoint,
800
+ errorOutput,
801
+ noOpen: options.noOpen,
802
+ openUrl,
803
+ storageOptions,
804
+ version,
805
+ });
806
+ try {
807
+ renderTool({
808
+ json: options.json,
809
+ output,
810
+ tool: await findTool(connection.client, positional[0]),
811
+ });
812
+ } finally {
813
+ await connection.close();
814
+ }
815
+ return;
816
+ }
817
+
818
+ if (command === 'call') {
819
+ const { options, positional } = parseCommandArguments(
820
+ arguments_.slice(1),
821
+ new Set(['endpoint', 'inputPath', 'json', 'noOpen']),
822
+ );
823
+ if (positional.length === 0) throw new Error(executionUsage);
824
+ const endpoint = resolveEndpoint(options, environment);
825
+ const toolArguments = await readArguments({
826
+ input,
827
+ inputPath: options.inputPath,
828
+ positional,
829
+ });
830
+ const connection = await connect({
831
+ endpoint,
832
+ errorOutput,
833
+ noOpen: options.noOpen,
834
+ openUrl,
835
+ storageOptions,
836
+ version,
837
+ });
838
+ try {
839
+ const tool = await findTool(connection.client, positional[0]);
840
+ await callTool({
841
+ arguments: toolArguments,
842
+ client: connection.client,
843
+ endpoint,
844
+ errorOutput,
845
+ inputResponses: {},
846
+ json: options.json,
847
+ noOpen: options.noOpen,
848
+ openUrl,
849
+ output,
850
+ sleep,
851
+ storageOptions,
852
+ toolDefinition: tool,
853
+ toolName: positional[0],
854
+ });
855
+ } finally {
856
+ await connection.close();
857
+ }
858
+ return;
859
+ }
860
+
861
+ throw new Error(executionUsage);
862
+ };