shortcutxl 0.3.90 → 0.3.91

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 (72) hide show
  1. package/BINARY-INVENTORY.json +13 -13
  2. package/CHANGELOG.md +5 -0
  3. package/dist/ai/types.d.ts +2 -0
  4. package/dist/app/connectors/secret-store.d.ts +8 -0
  5. package/dist/app/connectors/secret-store.js +73 -1
  6. package/dist/app/mcp/auth-status.d.ts +2 -0
  7. package/dist/app/mcp/auth-status.js +5 -1
  8. package/dist/app/mcp/connection.d.ts +32 -2
  9. package/dist/app/mcp/connection.js +179 -83
  10. package/dist/app/mcp/index.js +44 -9
  11. package/dist/app/mcp/types.d.ts +3 -1
  12. package/dist/app/modes/ask/prompt.js +1 -1
  13. package/dist/app/providers/model-registry.d.ts +1 -0
  14. package/dist/app/providers/model-registry.js +1 -0
  15. package/dist/app/session/session-models.js +1 -0
  16. package/dist/cli.js +836 -286
  17. package/dist/contracts/agent-api.d.ts +4 -4
  18. package/dist/contracts/agent-api.js +4 -4
  19. package/dist/core/prompts/agent-guidelines.d.ts +1 -1
  20. package/dist/core/prompts/agent-guidelines.js +1 -1
  21. package/dist/core/session/compaction/compaction.d.ts +1 -1
  22. package/dist/core/session/compaction/compaction.js +2 -2
  23. package/dist/core/session/compaction/constants.d.ts +4 -3
  24. package/dist/core/session/compaction/constants.js +5 -4
  25. package/dist/core/session/session-compaction.js +6 -4
  26. package/dist/embedded-agent/agent-event-funnel.d.ts +0 -2
  27. package/dist/embedded-agent/agent-event-funnel.js +0 -3
  28. package/dist/embedded-agent/compaction-wiring.d.ts +2 -2
  29. package/dist/embedded-agent/compaction-wiring.js +44 -22
  30. package/dist/embedded-agent/compose.js +1 -1
  31. package/dist/embedded-agent/host-tools/mcp/contract.d.ts +12 -0
  32. package/dist/embedded-agent/host-tools/mcp/contract.js +20 -1
  33. package/dist/embedded-agent/host-tools/mcp/index.d.ts +1 -1
  34. package/dist/embedded-agent/host-tools/mcp/index.js +1 -1
  35. package/dist/embedded-agent/model-registry.d.ts +1 -0
  36. package/dist/embedded-agent/model-registry.js +1 -0
  37. package/dist/embedded-agent/prompt/build.d.ts +0 -1
  38. package/dist/embedded-agent/prompt/build.js +0 -1
  39. package/dist/embedded-agent/provider-fast-mode.js +7 -4
  40. package/dist/embedded-agent/recovery-wiring.d.ts +2 -0
  41. package/dist/embedded-agent/recovery-wiring.js +1 -13
  42. package/dist/main.js +29 -0
  43. package/dist/model-display.js +1 -1
  44. package/dist/model-ids.d.ts +3 -1
  45. package/dist/model-ids.js +4 -1
  46. package/dist/rpc/rpc-host.d.ts +3 -1
  47. package/dist/rpc/rpc-host.js +4 -1
  48. package/dist/rpc/rpc-mode.d.ts +1 -1
  49. package/dist/rpc/rpc-types.d.ts +11 -0
  50. package/dist/shortcut-model-catalog.d.ts +2 -1
  51. package/dist/shortcut-model-catalog.js +37 -4
  52. package/dist/utils/api-reference.d.ts +0 -2
  53. package/dist/utils/api-reference.js +21 -5
  54. package/dist/utils/mog-headless-api-reference.js +0 -1
  55. package/native-app/main/index.js +2 -2
  56. package/native-app/package.json +1 -1
  57. package/native-app/renderer/assets/{index-Dep1w85F.js → index-B4oy3UFe.js} +90 -24
  58. package/native-app/renderer/index.html +1 -1
  59. package/package.json +6 -1
  60. package/skills/mcp/SKILL.md +2 -2
  61. package/user-docs/dist/index.html +2 -2
  62. package/xll/python/Lib/site-packages/httpx-0.28.1.dist-info/RECORD +1 -1
  63. package/xll/python/Lib/site-packages/idna-3.18.dist-info/RECORD +1 -1
  64. package/xll/python/Lib/site-packages/pip-26.2.dist-info/RECORD +3 -3
  65. package/xll/python/Lib/site-packages/pywin32-311.dist-info/RECORD +2 -2
  66. package/xll/python/Scripts/httpx.exe +0 -0
  67. package/xll/python/Scripts/idna.exe +0 -0
  68. package/xll/python/Scripts/pip.exe +0 -0
  69. package/xll/python/Scripts/pip3.13.exe +0 -0
  70. package/xll/python/Scripts/pip3.exe +0 -0
  71. package/xll/python/Scripts/pywin32_postinstall.exe +0 -0
  72. package/xll/python/Scripts/pywin32_testall.exe +0 -0
@@ -1,17 +1,17 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "generatedAt": "2026-09-02T05:35:16.912Z",
3
+ "generatedAt": "2026-09-10T05:20:31.240Z",
4
4
  "package": "shortcutxl",
5
5
  "build": {
6
6
  "builder": "github-actions",
7
7
  "repository": "lyfegame/shortcut",
8
- "sourceRef": "refs/heads/codex/excel-preexecution-approval",
9
- "sourceCommit": "2520273120cc4b4024d877790eacf6f41fefdd7c",
10
- "workflowRef": "lyfegame/shortcut/.github/workflows/release-shortcut-cli-npm-package.yml@refs/heads/codex/excel-preexecution-approval",
11
- "runId": "33595155271",
8
+ "sourceRef": "refs/heads/mcp-startup-budget",
9
+ "sourceCommit": "0905764a93d06c695b77bf413eaa2003d6bbd83f",
10
+ "workflowRef": "lyfegame/shortcut/.github/workflows/release-shortcut-cli-npm-package.yml@refs/heads/mcp-startup-budget",
11
+ "runId": "34440542822",
12
12
  "runnerOs": "Windows",
13
13
  "runnerImage": "win25-vs2026",
14
- "runnerImageVersion": "20260824.214.3"
14
+ "runnerImageVersion": "20260907.229.1"
15
15
  },
16
16
  "binaryExtensions": [
17
17
  ".dll",
@@ -550,7 +550,7 @@
550
550
  },
551
551
  {
552
552
  "path": "xll/python/Scripts/httpx.exe",
553
- "sha256": "a7f8d80085b1d17fdf993cd7246d55f05726e3eb624859a8ba3b69b76368bd4c",
553
+ "sha256": "a022dc159b79a3312afeed6f1733da09a8e85523163566f0df9da427319a0040",
554
554
  "source": "httpx console launcher installed into embedded Python",
555
555
  "version": "see packaged httpx distribution",
556
556
  "builtBy": "third-party",
@@ -558,7 +558,7 @@
558
558
  },
559
559
  {
560
560
  "path": "xll/python/Scripts/idna.exe",
561
- "sha256": "75e2f1c65f561084f603823794d6ad5877ef0a5b1ef0b1fffff096603479642a",
561
+ "sha256": "8cd6a6078a64ae8e4c826fde0c179ee719401389df7d73832baf0dd3c582ab38",
562
562
  "source": "Python package console launcher installed into embedded Python",
563
563
  "version": "see owning Python package metadata in site-packages",
564
564
  "builtBy": "third-party",
@@ -566,7 +566,7 @@
566
566
  },
567
567
  {
568
568
  "path": "xll/python/Scripts/pip.exe",
569
- "sha256": "f68ea8e144f74e0f9748750a5fc21b73e11d63486c905730aa474e28f3bab8e7",
569
+ "sha256": "4a130513690433dd5e1e502566a7fd2084fc629eb85d08f66b0aad591a012c23",
570
570
  "source": "pip console launcher installed into embedded Python",
571
571
  "version": "see packaged pip distribution",
572
572
  "builtBy": "third-party",
@@ -574,7 +574,7 @@
574
574
  },
575
575
  {
576
576
  "path": "xll/python/Scripts/pip3.13.exe",
577
- "sha256": "f68ea8e144f74e0f9748750a5fc21b73e11d63486c905730aa474e28f3bab8e7",
577
+ "sha256": "4a130513690433dd5e1e502566a7fd2084fc629eb85d08f66b0aad591a012c23",
578
578
  "source": "pip console launcher installed into embedded Python",
579
579
  "version": "see packaged pip distribution",
580
580
  "builtBy": "third-party",
@@ -582,7 +582,7 @@
582
582
  },
583
583
  {
584
584
  "path": "xll/python/Scripts/pip3.exe",
585
- "sha256": "f68ea8e144f74e0f9748750a5fc21b73e11d63486c905730aa474e28f3bab8e7",
585
+ "sha256": "4a130513690433dd5e1e502566a7fd2084fc629eb85d08f66b0aad591a012c23",
586
586
  "source": "pip console launcher installed into embedded Python",
587
587
  "version": "see packaged pip distribution",
588
588
  "builtBy": "third-party",
@@ -590,7 +590,7 @@
590
590
  },
591
591
  {
592
592
  "path": "xll/python/Scripts/pywin32_postinstall.exe",
593
- "sha256": "3d5142d3d301b5b22ec9f9ba2d29c9f8a4a73bad069389a73dafbd61e2b37696",
593
+ "sha256": "1e121893a273808b93c23160b171d2055aa4ec72ddb72c92a3d7ebb274115f1d",
594
594
  "source": "Python package console launcher installed into embedded Python",
595
595
  "version": "see owning Python package metadata in site-packages",
596
596
  "builtBy": "third-party",
@@ -598,7 +598,7 @@
598
598
  },
599
599
  {
600
600
  "path": "xll/python/Scripts/pywin32_testall.exe",
601
- "sha256": "494be3de764340437d1fb12aaaefaf8f47c5a482f14834da3a17b080c71d6e16",
601
+ "sha256": "d92feb62593764e35c01fa841192e2cbab59bd51b4d33f88f14197527c48ffa2",
602
602
  "source": "Python package console launcher installed into embedded Python",
603
603
  "version": "see owning Python package metadata in site-packages",
604
604
  "builtBy": "third-party",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.91]
4
+
5
+ - **GPT-6 Astra** - GPT-6 Astra is now available in the model selector.
6
+ - **Faster, more reliable MCP startup** - Shortcut now starts MCP integrations faster, retries temporary connection failures, and clearly reports servers that take too long to start.
7
+
3
8
  ## [0.3.90]
4
9
 
5
10
  - **Skill Forge** - A recursive self-improvement loop for skills. Create or improve personal skills by giving Skill Forge tasks and a grading bar to train, evaluate, and refine against.
@@ -296,6 +296,8 @@ export interface Model<TApi extends Api> {
296
296
  cacheWrite: number;
297
297
  };
298
298
  contextWindow: number;
299
+ /** Optional auto-compaction threshold. Defaults to the runtime-wide threshold policy. */
300
+ compactionThresholdTokens?: number;
299
301
  maxTokens: number;
300
302
  capabilities?: ModelCapabilities;
301
303
  headers?: Record<string, string>;
@@ -12,12 +12,20 @@
12
12
  export declare function setSecret(name: string, payload: Record<string, string>): void;
13
13
  /** Retrieve a secret payload for a connection. Returns undefined if not found. */
14
14
  export declare function getSecret(name: string): Record<string, string> | undefined;
15
+ /**
16
+ * Retrieve several connection secrets without serially blocking the Node event loop.
17
+ * Windows uses one PowerShell process for the whole batch so the P/Invoke helper is
18
+ * compiled once. Other platforms perform their native lookups concurrently.
19
+ */
20
+ export declare function getSecrets(names: readonly string[], timeoutMs?: number): Promise<Map<string, Record<string, string> | undefined>>;
15
21
  /** Remove a secret for a connection. Returns true if it existed. */
16
22
  export declare function removeSecret(name: string): boolean;
17
23
  /** Store a JSON payload in the OS keychain under a custom service namespace. */
18
24
  export declare function setNamespacedSecret(service: string, name: string, payload: object): void;
19
25
  /** Retrieve a JSON payload from the OS keychain for a custom service namespace. */
20
26
  export declare function getNamespacedSecret<T extends object>(service: string, name: string): T | undefined;
27
+ /** Retrieve several secrets through a single asynchronous batch operation where possible. */
28
+ export declare function getNamespacedSecrets<T extends object>(service: string, names: readonly string[], timeoutMs?: number): Promise<Map<string, T | undefined>>;
21
29
  /** Remove a JSON payload from the OS keychain for a custom service namespace. */
22
30
  export declare function removeNamespacedSecret(service: string, name: string): boolean;
23
31
  /**
@@ -8,7 +8,7 @@
8
8
  *
9
9
  * Imports: none from internal layers. Pure shell-out utility.
10
10
  */
11
- import { execFileSync } from 'child_process';
11
+ import { execFile, execFileSync } from 'child_process';
12
12
  const CONNECTION_SERVICE = 'shortcut.connection';
13
13
  // =============================================================================
14
14
  // Public API
@@ -21,6 +21,14 @@ export function setSecret(name, payload) {
21
21
  export function getSecret(name) {
22
22
  return getNamespacedSecret(CONNECTION_SERVICE, name);
23
23
  }
24
+ /**
25
+ * Retrieve several connection secrets without serially blocking the Node event loop.
26
+ * Windows uses one PowerShell process for the whole batch so the P/Invoke helper is
27
+ * compiled once. Other platforms perform their native lookups concurrently.
28
+ */
29
+ export function getSecrets(names, timeoutMs = 10_000) {
30
+ return getNamespacedSecrets(CONNECTION_SERVICE, names, timeoutMs);
31
+ }
24
32
  /** Remove a secret for a connection. Returns true if it existed. */
25
33
  export function removeSecret(name) {
26
34
  return removeNamespacedSecret(CONNECTION_SERVICE, name);
@@ -61,6 +69,45 @@ export function getNamespacedSecret(service, name) {
61
69
  }
62
70
  return undefined;
63
71
  }
72
+ /** Retrieve several secrets through a single asynchronous batch operation where possible. */
73
+ export async function getNamespacedSecrets(service, names, timeoutMs = 10_000) {
74
+ const uniqueNames = [...new Set(names)];
75
+ const result = new Map();
76
+ if (uniqueNames.length === 0)
77
+ return result;
78
+ if (process.platform === 'win32') {
79
+ const targets = uniqueNames.map((name) => `${service}/${name}`);
80
+ const psTargets = targets.map((target) => `'${psEsc(target)}'`).join(', ');
81
+ const ps = `${WIN_CRED_PREAMBLE}
82
+ $result = @{}
83
+ foreach ($target in @(${psTargets})) {
84
+ $value = [CredManager]::Read($target)
85
+ if ($null -ne $value) { $result[$target] = $value }
86
+ }
87
+ $result | ConvertTo-Json -Compress`;
88
+ const raw = await execFileUtf8('powershell', ['-NoProfile', '-EncodedCommand', psEncode(ps)], timeoutMs);
89
+ const values = raw.trim() ? JSON.parse(raw) : {};
90
+ for (const [index, name] of uniqueNames.entries()) {
91
+ result.set(name, parseSecretJson(values[targets[index]]));
92
+ }
93
+ return result;
94
+ }
95
+ if (process.platform === 'darwin') {
96
+ await Promise.all(uniqueNames.map(async (name) => {
97
+ try {
98
+ const raw = await execFileUtf8('security', ['find-generic-password', '-a', name, '-s', service, '-w'], timeoutMs);
99
+ result.set(name, parseSecretJson(raw));
100
+ }
101
+ catch {
102
+ result.set(name, undefined);
103
+ }
104
+ }));
105
+ return result;
106
+ }
107
+ for (const name of uniqueNames)
108
+ result.set(name, undefined);
109
+ return result;
110
+ }
64
111
  /** Remove a JSON payload from the OS keychain for a custom service namespace. */
65
112
  export function removeNamespacedSecret(service, name) {
66
113
  try {
@@ -115,7 +162,11 @@ export function buildConnectionEnv(connectionNames) {
115
162
  * PowerShell script that uses P/Invoke to call CredRead/CredWrite/CredDelete
116
163
  * from advapi32.dll. Works on every Windows version. No external modules needed.
117
164
  */
165
+ // Secrets are read back as .NET strings but serialized to stdout with the
166
+ // console's OEM codepage, which varies per machine and locale. Force BOM-less
167
+ // UTF-8 so Node decodes non-ASCII values correctly on every host.
118
168
  const WIN_CRED_PREAMBLE = `
169
+ [Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false
119
170
  Add-Type -TypeDefinition @"
120
171
  using System;
121
172
  using System.Runtime.InteropServices;
@@ -161,6 +212,27 @@ public class CredManager {
161
212
  function psEncode(script) {
162
213
  return Buffer.from(script, 'utf16le').toString('base64');
163
214
  }
215
+ function execFileUtf8(command, args, timeout) {
216
+ return new Promise((resolve, reject) => {
217
+ execFile(command, args, { encoding: 'utf-8', timeout, windowsHide: true }, (error, stdout) => {
218
+ if (error) {
219
+ reject(error);
220
+ return;
221
+ }
222
+ resolve(stdout);
223
+ });
224
+ });
225
+ }
226
+ function parseSecretJson(raw) {
227
+ if (!raw)
228
+ return undefined;
229
+ try {
230
+ return JSON.parse(raw.trim());
231
+ }
232
+ catch {
233
+ return undefined;
234
+ }
235
+ }
164
236
  function winCredWrite(target, secret) {
165
237
  const ps = `${WIN_CRED_PREAMBLE}\n[CredManager]::Write('${psEsc(target)}', '${psEsc(secret)}')`;
166
238
  execFileSync('powershell', ['-NoProfile', '-EncodedCommand', psEncode(ps)], {
@@ -6,6 +6,8 @@ export interface ResolveMcpAuthStatusOptions {
6
6
  authStorage?: AuthStorage;
7
7
  mcpOAuthStore?: McpOAuthStore;
8
8
  discovery?: McpAuthDiscoveryMode;
9
+ /** Pre-fetched connection secrets by name; when absent the keychain is read directly. */
10
+ connectionSecrets?: ReadonlyMap<string, Record<string, string> | undefined>;
9
11
  }
10
12
  export declare function resolveMcpAuthStatus(serverName: string, config: McpServerConfig, options?: ResolveMcpAuthStatusOptions): Promise<McpAuthStatus>;
11
13
  export declare function getMcpAuthStatus(serverName: string, config: McpServerConfig, authStorage?: AuthStorage, mcpOAuthStore?: McpOAuthStore): Promise<McpAuthStatus>;
@@ -10,7 +10,11 @@ export async function resolveMcpAuthStatus(serverName, config, options = {}) {
10
10
  return 'unsupported';
11
11
  }
12
12
  const staticHeaders = { ...(config.headers ?? {}) };
13
- const secretHeaders = config.connection ? getSecret(config.connection) : undefined;
13
+ const secretHeaders = !config.connection
14
+ ? undefined
15
+ : options.connectionSecrets
16
+ ? options.connectionSecrets.get(config.connection)
17
+ : getSecret(config.connection);
14
18
  if (hasConfiguredHeaders(staticHeaders) || hasConfiguredHeaders(secretHeaders)) {
15
19
  return 'configured';
16
20
  }
@@ -4,17 +4,47 @@
4
4
  import type { AuthStorage } from '../auth/auth-storage.js';
5
5
  import type { McpOAuthStore } from './oauth-state-store.js';
6
6
  import { type McpConnection, type McpFailureKind, type McpRequestPolicy, type McpServerConfig } from './types.js';
7
+ /** Abort signal plus absolute deadline that bounds one server's whole startup. */
8
+ export interface McpStartupBudget {
9
+ signal: AbortSignal;
10
+ deadlineAt: number;
11
+ }
12
+ export interface McpConnectOptions {
13
+ /**
14
+ * Connection secrets already read from the keychain, keyed by connection
15
+ * name. When absent, the server reads its own secret synchronously.
16
+ */
17
+ connectionSecrets?: ReadonlyMap<string, Record<string, string> | undefined>;
18
+ /** Budget shared across the whole startup. Direct callers get one from the config's startup timeout. */
19
+ budget?: McpStartupBudget;
20
+ }
7
21
  export declare class McpConnectionError extends Error {
8
22
  readonly failureKind: McpFailureKind;
9
23
  constructor(failureKind: McpFailureKind, message: string);
10
24
  }
11
25
  export declare function resolveStartupTimeoutMs(config: McpServerConfig): number;
12
26
  export declare function buildRequestPolicy(config: McpServerConfig): McpRequestPolicy;
27
+ /**
28
+ * Run one server's startup under a single deadline. The budget's signal is
29
+ * threaded into every SDK request so a stalled initialize or tools/list is
30
+ * cancelled, and the race guarantees the deadline even for steps that cannot
31
+ * observe the signal (process spawn, keychain reads).
32
+ */
33
+ export declare function withStartupBudget<T>(name: string, timeoutMs: number, operation: (budget: McpStartupBudget) => Promise<T>): Promise<T>;
34
+ /** SDK request options for initialize and tools/list under a startup budget. */
35
+ export declare function requestOptions(budget: McpStartupBudget): {
36
+ signal: AbortSignal;
37
+ timeout: number;
38
+ };
39
+ /** Whether a failed hosted startup is safe to repeat with a fresh MCP session. */
40
+ export declare function isTransientHostedStartupError(error: unknown): boolean;
13
41
  export declare function createHostedMcpLoggingFetch(serverName: string, fetchImpl?: typeof fetch): typeof fetch;
14
42
  /**
15
- * Connect a server, list tools, and return a live MCP connection.
43
+ * Connect a server, list tools, and return a live MCP connection. The whole
44
+ * sequence runs under `options.budget`, or under a fresh budget from the
45
+ * config's startup timeout when the caller did not supply one.
16
46
  */
17
- export declare function connectServer(name: string, config: McpServerConfig, defaultCwd: string, authStorage?: AuthStorage, mcpOAuthStore?: McpOAuthStore): Promise<McpConnection>;
47
+ export declare function connectServer(name: string, config: McpServerConfig, defaultCwd: string, authStorage?: AuthStorage, mcpOAuthStore?: McpOAuthStore, options?: McpConnectOptions): Promise<McpConnection>;
18
48
  /**
19
49
  * Call a tool on a connected server. Returns the result text or throws.
20
50
  */
@@ -10,16 +10,40 @@ import { mkdir, writeFile } from 'fs/promises';
10
10
  import { basename, extname, join } from 'path';
11
11
  import { getAgentTempDir } from '../../config.js';
12
12
  import { log } from '../../shared/logging/agent-log.js';
13
+ import { sleep } from '../../utils/sleep.js';
13
14
  import { getSecret } from '../connectors/secret-store.js';
14
15
  import { getHostedMcpOAuthProviderId } from './hosted-oauth-id.js';
15
16
  import { createHostedMcpRuntimeAuthProvider } from './hosted-oauth-provider.js';
16
17
  import { resolveHostedMcpOAuthTimeoutMs, supportsHostedMcpOAuth } from './oauth-discovery.js';
17
18
  import { withHostedMcpProxyFetchOptions } from './proxy-fetch.js';
18
19
  import { isHostedServerConfig } from './types.js';
19
- const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
20
+ /**
21
+ * Default budget for one server's whole startup: credential lookup, connect and
22
+ * initialize, OAuth refresh, and tool discovery. Managed Windows fleets behind
23
+ * proxies and EDR routinely take 60-90s here, so the budget is generous while
24
+ * still sitting under the 180s RPC readiness budget in `rpc-client.ts`.
25
+ */
26
+ const DEFAULT_STARTUP_TIMEOUT_MS = 120_000;
20
27
  const DEFAULT_TOOL_CALL_TIMEOUT_MS = 600_000;
21
28
  const DEFAULT_RESET_TIMEOUT_ON_PROGRESS = true;
22
29
  const MCP_OUTPUT_DIR_NAME = 'mcp';
30
+ const HOSTED_STARTUP_RETRY_DELAY_MS = 250;
31
+ const TRANSIENT_NETWORK_ERROR_CODES = new Set([
32
+ 'EAI_AGAIN',
33
+ 'ECONNABORTED',
34
+ 'ECONNREFUSED',
35
+ 'ECONNRESET',
36
+ 'EHOSTUNREACH',
37
+ 'ENETUNREACH',
38
+ 'ENOTFOUND',
39
+ 'EPIPE',
40
+ 'ETIMEDOUT',
41
+ 'UND_ERR_BODY_TIMEOUT',
42
+ 'UND_ERR_CONNECT_TIMEOUT',
43
+ 'UND_ERR_HEADERS_TIMEOUT',
44
+ 'UND_ERR_SOCKET'
45
+ ]);
46
+ const TRANSIENT_HTTP_STATUS_CODES = new Set([408, 425, 429, 500, 502, 503, 504]);
23
47
  function isRecord(value) {
24
48
  return typeof value === 'object' && value !== null;
25
49
  }
@@ -231,14 +255,68 @@ export function buildRequestPolicy(config) {
231
255
  : {})
232
256
  };
233
257
  }
234
- async function buildHostedHeaders(name, config) {
258
+ /**
259
+ * Run one server's startup under a single deadline. The budget's signal is
260
+ * threaded into every SDK request so a stalled initialize or tools/list is
261
+ * cancelled, and the race guarantees the deadline even for steps that cannot
262
+ * observe the signal (process spawn, keychain reads).
263
+ */
264
+ export async function withStartupBudget(name, timeoutMs, operation) {
265
+ const controller = new AbortController();
266
+ const timeoutError = new McpConnectionError('startup_timeout', `MCP server '${name}' did not finish connection and tool discovery within ${timeoutMs / 1000}s.`);
267
+ const expired = new Promise((_, reject) => {
268
+ controller.signal.addEventListener('abort', () => reject(timeoutError), { once: true });
269
+ });
270
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
271
+ if (typeof timer === 'object' && 'unref' in timer)
272
+ timer.unref();
273
+ try {
274
+ return await Promise.race([
275
+ operation({ signal: controller.signal, deadlineAt: Date.now() + timeoutMs }),
276
+ expired
277
+ ]);
278
+ }
279
+ catch (error) {
280
+ // The SDK surfaces our abort as its own error; report the budget instead.
281
+ throw controller.signal.aborted ? timeoutError : error;
282
+ }
283
+ finally {
284
+ clearTimeout(timer);
285
+ }
286
+ }
287
+ /**
288
+ * The SDK arms its own per-request timer (60s when none is given), so it cannot
289
+ * be the budget's timekeeper: two timers set to the same instant race, and the
290
+ * SDK's raw "Request timed out" would sometimes win. The budget's abort signal
291
+ * is the single source of truth; the SDK timeout only sits above it so the
292
+ * default 60s never cuts a longer budget short.
293
+ */
294
+ const SDK_TIMEOUT_SLACK_MS = 1_000;
295
+ /** SDK request options for initialize and tools/list under a startup budget. */
296
+ export function requestOptions(budget) {
297
+ return {
298
+ signal: budget.signal,
299
+ timeout: Math.max(1, budget.deadlineAt - Date.now()) + SDK_TIMEOUT_SLACK_MS
300
+ };
301
+ }
302
+ /** Secret for `config.connection`, from the pre-fetched map when the manager supplied one. */
303
+ function resolveConnectionSecret(name, connection, options) {
304
+ const secret = options.connectionSecrets
305
+ ? options.connectionSecrets.get(connection)
306
+ : getSecret(connection);
307
+ if (!secret) {
308
+ throw new Error(`MCP server '${name}' references connection '${connection}' but no credentials found in keychain. Run /connect to set it up.`);
309
+ }
310
+ return secret;
311
+ }
312
+ /** Best-effort teardown after a failed startup; a stdio close waits on the child, so never block on it. */
313
+ function discardClient(client) {
314
+ void client.close().catch(() => { });
315
+ }
316
+ function buildHostedHeaders(name, config, options) {
235
317
  const headers = { ...(config.headers ?? {}) };
236
318
  if (config.connection) {
237
- const secrets = getSecret(config.connection);
238
- if (!secrets) {
239
- throw new Error(`MCP server '${name}' references connection '${config.connection}' but no credentials found in keychain. Run /connect to set it up.`);
240
- }
241
- Object.assign(headers, secrets);
319
+ Object.assign(headers, resolveConnectionSecret(name, config.connection, options));
242
320
  }
243
321
  return headers;
244
322
  }
@@ -354,6 +432,32 @@ function summarizeErrorForLog(error, depth = 0) {
354
432
  }
355
433
  return details;
356
434
  }
435
+ /** Whether a failed hosted startup is safe to repeat with a fresh MCP session. */
436
+ export function isTransientHostedStartupError(error) {
437
+ if (error instanceof UnauthorizedError || error instanceof McpConnectionError) {
438
+ return false;
439
+ }
440
+ if (error instanceof StreamableHTTPError &&
441
+ typeof error.code === 'number' &&
442
+ TRANSIENT_HTTP_STATUS_CODES.has(error.code)) {
443
+ return true;
444
+ }
445
+ let current = error;
446
+ for (let depth = 0; depth < 4; depth += 1) {
447
+ if (current === null || (typeof current !== 'object' && typeof current !== 'function')) {
448
+ return false;
449
+ }
450
+ const record = current;
451
+ if (record.name === 'AbortError')
452
+ return false;
453
+ if (typeof record.code === 'string' &&
454
+ TRANSIENT_NETWORK_ERROR_CODES.has(record.code.toUpperCase())) {
455
+ return true;
456
+ }
457
+ current = record.cause;
458
+ }
459
+ return false;
460
+ }
357
461
  async function readResponseSnippet(response) {
358
462
  try {
359
463
  const text = await response.clone().text();
@@ -383,10 +487,33 @@ export function createHostedMcpLoggingFetch(serverName, fetchImpl = fetch) {
383
487
  return response;
384
488
  };
385
489
  }
386
- async function connectHostedServer(name, config, authStorage, mcpOAuthStore, retriedAfterOAuthRefresh = false) {
387
- const startupTimeoutMs = resolveStartupTimeoutMs(config);
490
+ /** Discover the server's tools inside the budget, releasing the client if discovery fails. */
491
+ async function discoverTools(name, client, budget) {
492
+ let toolsResult;
493
+ try {
494
+ budget.signal.throwIfAborted();
495
+ toolsResult = await client.listTools(undefined, requestOptions(budget));
496
+ }
497
+ catch (error) {
498
+ discardClient(client);
499
+ throw error;
500
+ }
501
+ const tools = (toolsResult.tools ?? []).map((t) => ({
502
+ name: t.name,
503
+ description: t.description,
504
+ parameterNames: t.inputSchema?.properties
505
+ ? Object.keys(t.inputSchema.properties)
506
+ : [],
507
+ inputSchema: (t.inputSchema ?? {})
508
+ }));
509
+ if (tools.length === 0) {
510
+ log.info('mcp', `MCP server '${name}' connected but exposed 0 tools.`);
511
+ }
512
+ return tools;
513
+ }
514
+ async function connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget, retriedAfterOAuthRefresh = false) {
388
515
  const requestPolicy = buildRequestPolicy(config);
389
- const headers = await buildHostedHeaders(name, config);
516
+ const headers = buildHostedHeaders(name, config, options);
390
517
  const authProvider = authStorage &&
391
518
  mcpOAuthStore &&
392
519
  !hasAuthorizationHeader(headers) &&
@@ -399,25 +526,12 @@ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, ret
399
526
  authProvider
400
527
  });
401
528
  const client = new Client({ name: 'shortcut-agent', version: '1.0.0' }, { capabilities: {} });
402
- let timer;
403
- const connectPromise = client.connect(transport);
404
- const timeoutPromise = new Promise((_, reject) => {
405
- timer = setTimeout(() => reject(new Error(`did not respond within ${startupTimeoutMs / 1000}s`)), startupTimeoutMs);
406
- if (typeof timer === 'object' && 'unref' in timer)
407
- timer.unref();
408
- });
409
529
  try {
410
- await Promise.race([connectPromise, timeoutPromise]);
411
- if (timer)
412
- clearTimeout(timer);
530
+ await client.connect(transport, requestOptions(budget));
413
531
  }
414
532
  catch (err) {
415
- try {
416
- await client.close();
417
- }
418
- catch {
419
- // Ignore close errors during cleanup.
420
- }
533
+ discardClient(client);
534
+ budget.signal.throwIfAborted();
421
535
  if (err instanceof UnauthorizedError ||
422
536
  (err instanceof StreamableHTTPError && err.code === 401)) {
423
537
  const providerId = getHostedMcpOAuthProviderId(name, config);
@@ -426,12 +540,13 @@ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, ret
426
540
  !retriedAfterOAuthRefresh &&
427
541
  authStorage.get(providerId)?.type === 'oauth') {
428
542
  const refreshedToken = await authStorage.getApiKey(providerId, { forceRefresh: true });
543
+ budget.signal.throwIfAborted();
429
544
  if (refreshedToken) {
430
- return connectHostedServer(name, config, authStorage, mcpOAuthStore, true);
545
+ return connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget, true);
431
546
  }
432
547
  }
433
548
  const supportsOAuth = await supportsHostedMcpOAuth(name, config, mcpOAuthStore, {
434
- timeoutMs: resolveHostedMcpOAuthTimeoutMs(config)
549
+ timeoutMs: Math.min(resolveHostedMcpOAuthTimeoutMs(config), requestOptions(budget).timeout)
435
550
  }).catch(() => !!config.oauth);
436
551
  const providerName = config.oauth?.providerName ?? `MCP: ${name}`;
437
552
  throw new McpConnectionError('auth_required', supportsOAuth
@@ -440,31 +555,32 @@ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, ret
440
555
  }
441
556
  throw err;
442
557
  }
443
- const toolsResult = await client.listTools();
444
- const tools = (toolsResult.tools ?? []).map((t) => ({
445
- name: t.name,
446
- description: t.description,
447
- parameterNames: t.inputSchema?.properties
448
- ? Object.keys(t.inputSchema.properties)
449
- : [],
450
- inputSchema: (t.inputSchema ?? {})
451
- }));
452
- if (tools.length === 0) {
453
- log.info('mcp', `Hosted MCP server '${name}' connected but exposed 0 tools.`);
454
- }
558
+ const tools = await discoverTools(name, client, budget);
455
559
  return { name, client, transport, tools, requestPolicy };
456
560
  }
457
- async function connectStdioServer(name, config, defaultCwd) {
458
- const startupTimeoutMs = resolveStartupTimeoutMs(config);
459
- const requestPolicy = buildRequestPolicy(config);
460
- let serverEnv = { ...process.env, ...(config.env ?? {}) };
461
- if (config.connection) {
462
- const secrets = getSecret(config.connection);
463
- if (!secrets) {
464
- throw new Error(`MCP server '${name}' references connection '${config.connection}' but no credentials found in keychain. Run /connect to set it up.`);
561
+ /** Retry transient hosted startup failures with a fresh client and transport each time. */
562
+ async function connectHostedServer(name, config, authStorage, mcpOAuthStore, options, budget) {
563
+ try {
564
+ return await connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget);
565
+ }
566
+ catch (error) {
567
+ budget.signal.throwIfAborted();
568
+ if (!isTransientHostedStartupError(error) ||
569
+ Date.now() + HOSTED_STARTUP_RETRY_DELAY_MS >= budget.deadlineAt) {
570
+ throw error;
465
571
  }
466
- serverEnv = { ...serverEnv, ...secrets };
572
+ log.warn('mcp', `MCP server '${name}' had a transient startup failure; retrying once with a fresh session. error=${JSON.stringify(summarizeErrorForLog(error))}`);
573
+ await sleep(HOSTED_STARTUP_RETRY_DELAY_MS, budget.signal);
574
+ return connectHostedServerAttempt(name, config, authStorage, mcpOAuthStore, options, budget);
467
575
  }
576
+ }
577
+ async function connectStdioServer(name, config, defaultCwd, options, budget) {
578
+ const requestPolicy = buildRequestPolicy(config);
579
+ const serverEnv = {
580
+ ...process.env,
581
+ ...(config.env ?? {}),
582
+ ...(config.connection ? resolveConnectionSecret(name, config.connection, options) : {})
583
+ };
468
584
  const transport = new StdioClientTransport({
469
585
  command: config.command,
470
586
  args: config.args,
@@ -480,52 +596,32 @@ async function connectStdioServer(name, config, defaultCwd) {
480
596
  }
481
597
  });
482
598
  const client = new Client({ name: 'shortcut-agent', version: '1.0.0' }, { capabilities: {} });
483
- const connectPromise = client.connect(transport);
484
- let timer;
485
- const timeoutPromise = new Promise((_, reject) => {
486
- timer = setTimeout(() => reject(new Error(`did not respond within ${startupTimeoutMs / 1000}s`)), startupTimeoutMs);
487
- if (typeof timer === 'object' && 'unref' in timer)
488
- timer.unref();
489
- });
490
599
  try {
491
- await Promise.race([connectPromise, timeoutPromise]);
492
- if (timer)
493
- clearTimeout(timer);
600
+ await client.connect(transport, requestOptions(budget));
494
601
  }
495
602
  catch (err) {
496
- try {
497
- await client.close();
498
- }
499
- catch {
500
- // Ignore close errors during cleanup.
501
- }
603
+ discardClient(client);
604
+ budget.signal.throwIfAborted();
502
605
  const stderr = stderrBuf.trim();
503
606
  const base = err instanceof Error ? err.message : String(err);
504
607
  const detail = stderr ? `${base}\nServer stderr: ${stderr}` : base;
505
608
  throw new McpConnectionError('connect_failed', detail);
506
609
  }
507
- const toolsResult = await client.listTools();
508
- const tools = (toolsResult.tools ?? []).map((t) => ({
509
- name: t.name,
510
- description: t.description,
511
- parameterNames: t.inputSchema?.properties
512
- ? Object.keys(t.inputSchema.properties)
513
- : [],
514
- inputSchema: (t.inputSchema ?? {})
515
- }));
516
- if (tools.length === 0) {
517
- log.info('mcp', `MCP server '${name}' connected but exposed 0 tools.`);
518
- }
610
+ const tools = await discoverTools(name, client, budget);
519
611
  return { name, client, transport, tools, requestPolicy };
520
612
  }
521
613
  /**
522
- * Connect a server, list tools, and return a live MCP connection.
614
+ * Connect a server, list tools, and return a live MCP connection. The whole
615
+ * sequence runs under `options.budget`, or under a fresh budget from the
616
+ * config's startup timeout when the caller did not supply one.
523
617
  */
524
- export async function connectServer(name, config, defaultCwd, authStorage, mcpOAuthStore) {
525
- if (isHostedServerConfig(config)) {
526
- return connectHostedServer(name, config, authStorage, mcpOAuthStore);
527
- }
528
- return connectStdioServer(name, config, defaultCwd);
618
+ export async function connectServer(name, config, defaultCwd, authStorage, mcpOAuthStore, options = {}) {
619
+ const connect = (budget) => isHostedServerConfig(config)
620
+ ? connectHostedServer(name, config, authStorage, mcpOAuthStore, options, budget)
621
+ : connectStdioServer(name, config, defaultCwd, options, budget);
622
+ return options.budget
623
+ ? connect(options.budget)
624
+ : withStartupBudget(name, resolveStartupTimeoutMs(config), connect);
529
625
  }
530
626
  /**
531
627
  * Call a tool on a connected server. Returns the result text or throws.