All files / src toolsets.ts

98.25% Statements 395/402
94.89% Branches 279/294
100% Functions 89/89
98.17% Lines 376/383

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          11x 49x                     22x 25x               61x 61x 785x 785x 57x 728x 5x 723x 10x 10x 5x   10x     10x 10x     10x       10x 5x 5x     10x 10x     713x     61x     11x 373x       102x 237x     155x                           228x 228x 228x 228x   228x         30x 30x       11x 5x     11x 1081x 883x   198x 12x       279x 25x       161x 16x                                                         369x             369x                       369x   369x             369x               369x 369x           369x 448x   9x 6x                 369x                         369x 1x           368x 3x   365x 365x   369x 369x 4x         335x 369x 3x 4x       335x 335x 369x 369x 369x 369x 369x 369x       369x         5x                       24x 24x 24x 24x                     26x 26x 26x 26x 26x 26x                                 2x 2x                                       127x 127x 127x 127x 123x     127x 127x       127x 127x   127x 127x 127x                 17x           5x 2x               3x           122x 122x                   122x   24x 24x                   98x 98x   3x         95x     127x 2x       93x 92x 92x 92x   92x 141x     93x                                       85x 14x       71x 68x 68x 65x     68x   71x       68x 68x 66x   113x   86x 66x 4x 2x       2x 2x   2x       62x 111x   71x     68x 4x       78x 58x 4x       58x 55x   58x                 343x 343x 343x 108x   329x 85x   319x           343x       261x 261x                 304x                                                   304x 388x   388x 388x     304x     304x 304x 304x 304x     14x   5x   304x 38x       266x 266x 5x     261x   261x 256x   261x 261x   261x 254x                                     261x 261x 363x                       528x   261x 237x 230x       261x 189x 189x 173x 173x 173x     72x           68x 261x 261x 261x 161x 161x 123x 123x   38x     261x 4x   64x 64x 30x 30x 30x   64x 64x 64x                 613x 613x                                                                       245x                     327x 327x     327x 327x 304x   280x 280x       618x 558x   60x 60x 60x   613x   280x 6x 22x   280x 13x 61x     280x 280x       47x 45x   2x                                 82x 81x 145x                                                       39x     39x 11x       28x       28x 27x 4x     23x 33x 27x 30x         33x 33x 33x 33x                 23x           22x 22x 22x 31x 26x   5x     22x 2x   20x 2x             20x                                                                       63x 3x       60x 3x   57x 57x 5x         52x 52x 52x 52x               52x 102x 113x 124x   102x 43x 43x               52x 52x     23x     52x 23x   52x 13x 13x 11x 1x         12x 12x 13x 12x 12x 11x                 51x 18x   51x 2x     2x     51x 51x 16x 17x       41x 35x 2x             37x 37x 33x 33x     3x 6x               30x 30x 30x 6x 6x   30x     30x                                                                               20x 20x 2x       18x     18x 2x                 16x 15x     15x 3x         12x 12x             12x 60x 19x     12x                 92x 92x 159x               36x     92x         92x 92x 159x             141x     92x                     8x 4x 4x     4x     1x   3x 6x        
import type { MergeExclusive, SimplifyDeep } from 'type-fest';
import {
	DEFAULT_BASE_URL,
	DEFAULT_TIMEOUT_MS,
	FAILED_ACCOUNT_RETRY_MS,
	MAX_CONCURRENCY,
	MAX_TOP_K,
	SUBMIT_FEEDBACK_TOOL_NAME,
} from './consts';
import { buildRequestHeaders, isSdkOwnedHeader } from './headers';
import {
	type EndUserSource,
	HttpRateLimitError,
	type McpToolDefinition,
	isRateLimitFailure,
	listMcpTools,
	withEndUser,
} from './mcp-client';
import { cloneJson, toolParametersFromInputSchema } from './schema';
import { StackOneMcpTool, type StackOneTool, Tools, warnOnDuplicateNames } from './tool';
import type {
	ActionResult,
	FeedbackCategory,
	FeedbackRating,
	FeedbackSource,
	JsonObject,
	SearchResult,
	StackOneAccount,
	ToolMode,
} from './types';
import { StackOneAPIError } from './utils/error-stackone-api';
import { StackOneError } from './utils/error-stackone';
import { ToolSetConfigError, ToolSetLoadError } from './utils/error-toolset';
import { settleWithConcurrency } from './utils/concurrency';
import { fetchWithRetry, retryTiming } from './utils/fetch-retry';
import { warn } from './utils/logger';
 
/**
 * Configuration with a single account ID
 */
interface SingleAccountConfig {
	/**
	 * Single account ID for StackOne API operations
	 * Use this when working with a single account. Never read from the environment: with no
	 * account configured, the toolset uses every active shared account linked to the API key
	 * (non-shared ones too with `includeNonShared`).
	 */
	accountId: string;
}
 
/**
 * Configuration with multiple account IDs
 */
interface MultipleAccountsConfig {
	/**
	 * Array of account IDs for filtering tools across multiple accounts
	 * When provided, tools will be fetched for all specified accounts. `null` is the same as
	 * leaving it unset.
	 * @example ['account-1', 'account-2']
	 */
	accountIds: string[] | null;
}
 
/**
 * Account configuration options - either single accountId or multiple accountIds, but not both
 */
type AccountConfig = SimplifyDeep<MergeExclusive<SingleAccountConfig, MultipleAccountsConfig>>;
 
/**
 * Execution configuration for the StackOneToolSet constructor.
 * Controls default account scoping for tool execution in tools.
 */
export interface ExecuteToolsConfig {
	/** Account IDs to scope tool discovery and execution. `null` is the same as leaving it unset. */
	accountIds?: string[] | null;
	/** Request timeout in milliseconds. Can also be set as a top-level config param which takes precedence. */
	timeout?: number;
}
 
/**
 * Base configuration for StackOne toolset (without account options)
 */
interface StackOneToolSetBaseConfig {
	/** API key. Defaults to the `STACKONE_API_KEY` environment variable. */
	apiKey?: string;
	/**
	 * Defaults to `STACKONE_BASE_URL`, then `https://api.stackone.com`. An empty value counts as
	 * unset, as in Python, so an empty variable falls through rather than producing hostless URLs.
	 */
	baseUrl?: string;
	/**
	 * Extra HTTP headers sent with every request. `Authorization`, `x-account-id` and
	 * `User-Agent` are always the SDK's own and cannot be set here. `x-end-user-id` can: it is
	 * passed through as given, unless `GET /accounts` reported the account's end user, which
	 * then replaces it.
	 */
	headers?: Record<string, string>;
	/**
	 * Request timeout in milliseconds, applied to every MCP call (listing and `tools/call`) and to
	 * account discovery. Default: 60000 (60s).
	 */
	timeout?: number;
	/**
	 * Execution configuration. Controls default account scoping for tool execution.
	 * Pass `{ accountIds: ['acc-1'] }` to scope tools to specific accounts.
	 */
	execute?: ExecuteToolsConfig;
	/**
	 * How the endpoint lists tools. `'search_execute'` returns two meta tools per connector
	 * instead of one tool per action, keeping the catalog small enough for a model's context.
	 * Defaults to the server's own default (`'individual'`).
	 */
	toolMode?: ToolMode;
	/**
	 * Whether account discovery — used when no account id is passed — includes non-shared accounts
	 * (`shared: false`). Each belongs to a single end user, so by default discovery skips them,
	 * with a warning, rather than put every end user's accounts in one context. Account ids
	 * passed explicitly are always used. Default: `false`.
	 */
	includeNonShared?: boolean;
}
 
/**
 * Configuration for StackOne toolset
 * Accepts either accountId (single) or accountIds (multiple), but not both
 */
export type StackOneToolSetConfig = StackOneToolSetBaseConfig & Partial<AccountConfig>;
 
/**
 * Options for filtering tools when fetching from MCP
 */
export interface FetchToolsOptions {
	/**
	 * The accounts to list tools for. Defaults to the toolset's accounts, then its `accountId`,
	 * then every active shared account linked to the API key (non-shared ones too with
	 * `includeNonShared`). `null` is the same as leaving it unset.
	 */
	accountIds?: string[] | null;
 
	/**
	 * Filter tools by provider names (case-insensitive, matched as a full prefix of the tool
	 * name, so `browser_linkedin` matches `browser_linkedin_search_people`).
	 * @example ['hibob', 'bamboohr']
	 */
	providers?: string[];
 
	/**
	 * Filter tools by action patterns with glob support
	 * Only tools matching these patterns will be returned
	 * @example ['*_list_employees', 'hibob_create_employees']
	 */
	actions?: string[];
 
	/**
	 * Override the toolset's `toolMode` for this call. `null` requests the server default.
	 */
	mode?: ToolMode | null;
}
 
/**
 * Options for {@link StackOneToolSet.search}.
 */
export interface SearchOptions {
	/** Maximum results, 1–50, across every connector searched. Default: 10. */
	topK?: number;
	/**
	 * Restrict to these accounts. Defaults to the toolset's accounts, then every active shared
	 * one (non-shared ones too with `includeNonShared`). `null` is the same as leaving it unset.
	 */
	accountIds?: string[] | null;
}
 
/**
 * Options for {@link StackOneToolSet.execute}.
 */
export interface ExecuteActionOptions {
	/**
	 * The `session_id` a {@link StackOneToolSet.search} hit carries. Passing it links this call to
	 * that search server-side. Sent only when given; `null` behaves the same as leaving it unset.
	 */
	sessionId?: string | null;
	/**
	 * Restrict routing to these accounts. Defaults as for {@link StackOneToolSet.search}; `null` is
	 * the same as leaving it unset.
	 */
	accountIds?: string[] | null;
}
 
/**
 * Options for {@link StackOneToolSet.submitFeedback}.
 */
export interface SubmitFeedbackOptions {
	/** The verdict: `'positive'`, `'negative'` or `'neutral'`. */
	rating: FeedbackRating;
	/** The tools or action ids the feedback is about. */
	toolNames: string[];
	/** An optional one-line reason. */
	feedback?: string;
	/** What the feedback is about, e.g. `'search'` or `'execute'`. */
	category?: FeedbackCategory;
	/**
	 * The session to attach the feedback to — the `session_id` of a search hit. `null` behaves
	 * the same as leaving it unset.
	 */
	sessionId?: string | null;
	/** Who produced the feedback. Default: `'model'`. */
	source?: FeedbackSource;
	/** The action run the feedback is about. Sent as `action_run_id`, only when given. */
	actionRunId?: string;
	/**
	 * The feedback is sent through the lowest of these account ids. Defaults as for
	 * {@link StackOneToolSet.fetchTools}. `null` is the same as leaving it unset.
	 */
	accountIds?: string[] | null;
}
 
/** One served tool, with the account it was listed for. What the catalog cache holds. */
interface CatalogEntry {
	definition: McpToolDefinition;
	accountId: string;
	endpoint: string;
}
 
/** A cached catalog: each healthy account's listing, and when and why each failing one failed. */
interface CachedCatalog {
	listings: ReadonlyMap<string, readonly CatalogEntry[]>;
	/** `at` is on {@link retryTiming}'s clock. */
	failed: ReadonlyMap<string, { at: number; message: string }>;
}
 
/** The catalog of an account scope: its tools, and each account left out because it failed. */
interface ScopedCatalog {
	entries: CatalogEntry[];
	/** In ascending account id order, with each failure's message and when it failed. */
	failed: [accountId: string, message: string, at: number][];
}
 
const describeError = (error: unknown): string =>
	error instanceof Error ? error.message : String(error);
 
/**
 * Whether a tool belongs to one of the given providers (case-insensitive).
 *
 * Matched as a full prefix rather than on the first underscore-separated token: splitting on
 * "_" reads `browser_linkedin_search_people` as provider `browser`, so asking for
 * `browser_linkedin` returned nothing at all — silently, since an empty result is
 * indistinguishable from a provider with no tools.
 */
function matchesProvider(toolName: string, providers: readonly string[]): boolean {
	const lowered = toolName.toLowerCase();
	return providers.some((provider) => lowered.startsWith(`${provider.toLowerCase()}_`));
}
 
/**
 * Whether a tool name matches a glob pattern, with the semantics of Python's `fnmatch`: `*` any
 * run, `?` one character, `[seq]` / `[!seq]` a character class. Everything else is literal.
 */
function matchGlob(value: string, pattern: string): boolean {
	let source = '';
	for (let index = 0; index < pattern.length; index++) {
		const char = pattern[index] as string;
		if (char === '*') {
			source += '.*';
		} else if (char === '?') {
			source += '.';
		} else if (char === '[') {
			let end = index + 1;
			if (pattern[end] === '!') {
				end++;
			}
			Iif (pattern[end] === ']') {
				end++;
			}
			end = pattern.indexOf(']', end);
			Iif (end === -1) {
				source += '\\[';
			} else {
				let body = pattern
					.slice(index + 1, end)
					.replaceAll('\\', '\\\\')
					.replaceAll(']', '\\]');
				if (body.startsWith('!')) {
					body = `^${body.slice(1)}`;
				I} else if (body.startsWith('^')) {
					body = `\\${body}`;
				}
				source += `[${body}]`;
				index = end;
			}
		} else {
			source += char.replace(/[.+^${}()|[\]\\/]/g, '\\$&');
		}
	}
	return new RegExp(`^${source}$`, 's').test(value);
}
 
const isPlainObject = (value: unknown): value is JsonObject =>
	typeof value === 'object' && value !== null && !Array.isArray(value);
 
/** The longest of `connectors` that prefixes `actionId` (both lowercase), as execute() routes. */
function longestConnector(actionId: string, connectors: string[]): string | undefined {
	return connectors
		.filter((connector) => actionId.startsWith(`${connector}_`))
		.reduce<string | undefined>(
			(longest, connector) =>
				longest === undefined || connector.length > longest.length ? connector : longest,
			undefined,
		);
}
 
/**
 * The connector a meta tool belongs to: its name minus the account id and the suffix.
 *
 * The account id is stripped by identity, not by splitting on the last underscore. Account ids
 * are nanoid-shaped and nanoid's alphabet includes `_`, so splitting turned
 * `mock_acc_1_execute_action` into connector `mock_acc` and made every action on that account
 * unroutable — with an error blaming the caller's action id.
 */
function connectorOf(tool: StackOneTool, suffix: string): string {
	let stem = tool.name.endsWith(suffix) ? tool.name.slice(0, -suffix.length) : tool.name;
	const account = tool.getAccountId();
	Eif (account && stem.endsWith(`_${account}`)) {
		stem = stem.slice(0, -(account.length + 1));
	}
	return stem.toLowerCase();
}
 
/** A search hit's score, or 0 for one without a numeric score, so it sorts last. */
function scoreOf(action: SearchResult): number {
	const score = action.similarity_score;
	return typeof score === 'number' && !Number.isNaN(score) ? score : 0;
}
 
/** The JSON type of a value, as the messages shared with the Python SDK name it. */
const jsonType = (value: unknown): string =>
	value === null ? 'null' : Array.isArray(value) ? 'array' : typeof value;
 
/** Refuse an account-id list the toolset cannot use. `null`, like `undefined`, is not given. */
const assertAccountIdList = (accountIds: unknown, parameter: string): void => {
	if (accountIds == null) {
		return;
	}
	if (typeof accountIds === 'string') {
		throw new ToolSetConfigError(
			`${parameter} must be a list of account ids, not a string. Did you mean [${JSON.stringify(accountIds)}]?`,
		);
	}
	if (!Array.isArray(accountIds) || accountIds.some((id) => typeof id !== 'string')) {
		throw new ToolSetConfigError(`${parameter} must be a list of account id strings`);
	}
	// An empty id would be sent with no x-account-id, so reject it rather than let the server
	// answer for an account nobody chose.
	if (accountIds.includes('')) {
		throw new ToolSetConfigError(`${parameter} must not contain an empty account id`);
	}
};
 
/**
 * The StackOne toolset: lists the served tool catalog and exposes it to agent frameworks.
 *
 * A thin client over the MCP endpoint. Tools are listed from it, per account, and executed over
 * its `tools/call`; the only other request is `GET /accounts`, to discover accounts. Schemas and
 * arguments are passed through as served, never rewritten, filtered or invented.
 *
 * An API key is enough: with no account configured, the toolset lists every active shared
 * account linked to the key, and non-shared ones too with `includeNonShared`.
 */
export class StackOneToolSet {
	readonly #apiKey: string;
	readonly #baseUrl: string;
	readonly #headers: Record<string, string>;
	readonly #timeout: number;
	readonly #toolMode: ToolMode | undefined;
	readonly #includeNonShared: boolean;
	readonly #accountId: string | undefined;
	#accountIds: string[];
 
	/**
	 * The listing per account scope, not the Tools built from it. Tools are mutable
	 * (`setAccountId` rebinds one), so handing the same instances back on a cache hit let one
	 * caller silently rescope every later caller's tools.
	 */
	readonly #catalogCache = new Map<string, CachedCatalog>();
	/**
	 * Listings in flight, keyed the same as {@link #catalogCache}. Concurrent calls for the same
	 * scope share this promise instead of each listing and storing independently — two callers
	 * racing to `store()` would let whichever finished last overwrite the other's catalog, which
	 * could hide a healthy account's tools behind a partial one.
	 */
	readonly #catalogInFlight = new Map<string, Promise<CachedCatalog>>();
	#discoveredAccountIds: string[] | undefined;
	#discovering: Promise<string[]> | undefined;
	/** The `GET /accounts` in flight, if any, so an end-user lookup can share it. */
	#fetchingAccounts: Promise<StackOneAccount[]> | undefined;
	/**
	 * The end user of each non-shared account, as the last recorded `GET /accounts` reported it,
	 * sent as `x-end-user-id` on every MCP request for that account: the API refuses a non-shared
	 * account's request without it. Replaced whole, with {@link #providers}, by each successful
	 * `GET /accounts`, and kept by {@link clearCatalogCache} — it describes the accounts, not the
	 * catalog.
	 */
	#endUserIds: ReadonlyMap<string, string> = new Map();
	/** The provider of each account, recorded with {@link #endUserIds}. */
	#providers: ReadonlyMap<string, string> = new Map();
	/**
	 * When {@link execute}'s provider lookup last failed to name each failed account, on
	 * {@link retryTiming}'s clock. For {@link FAILED_ACCOUNT_RETRY_MS} after, `execute()` neither
	 * looks the account up nor lists it again early, so a key that cannot name it does not cost
	 * every call a `GET /accounts` and a listing. Forgotten by {@link clearCatalogCache}.
	 */
	readonly #providerMisses = new Map<string, number>();
	/**
	 * Bumped at the start of every {@link fetchAccounts} call. Overlapping calls (it is public, and
	 * not deduplicated the way {@link #discoverAccountIds} is) can resolve out of order, so each
	 * records only if it started after the call whose result was last recorded
	 * ({@link #accountsRecorded}): a stale response never replaces a newer one, and a newer call
	 * that fails does not stop an older one from recording.
	 */
	#accountsStarted = 0;
	#accountsRecorded = 0;
 
	/**
	 * Where tools and listings find an account's end user: as recorded, or by looking it up with a
	 * `GET /accounts` — the one in flight, if there is one.
	 */
	readonly #endUsers: EndUserSource = {
		recorded: (accountId) => this.#endUserIds.get(accountId),
		lookUp: async (accountId) => {
			await (this.#fetchingAccounts ?? this.fetchAccounts());
			return this.#endUserIds.get(accountId);
		},
	};
	/**
	 * Bumped by {@link clearCatalogCache}. A listing already in flight when the cache is cleared
	 * captured the generation it started under, and refuses to write back if it has moved —
	 * otherwise the stale catalog would land after the clear and be served for the life of the
	 * process, which is the one thing the clear exists to prevent.
	 */
	#cacheGeneration = 0;
 
	/**
	 * Falls back to `STACKONE_API_KEY` and `STACKONE_BASE_URL`, but never reads an account id from
	 * the environment: `accountId` / `accountIds` must be passed, or every active shared account is
	 * used (non-shared ones too with `includeNonShared`).
	 * When `STACKONE_ACCOUNT_ID` is set and no account is passed, that is warned about, since 2.x
	 * read it.
	 *
	 * @throws ToolSetConfigError If no API key is given or found in `STACKONE_API_KEY`, or both
	 *   `accountId` and `accountIds` are given, or `accountId` is an empty string.
	 */
	constructor(config: StackOneToolSetConfig = {}) {
		if (config.accountId != null && config.accountIds != null) {
			throw new ToolSetConfigError(
				'Cannot provide both accountId and accountIds. Use accountId for a single account or accountIds for multiple accounts.',
			);
		}
		// An empty accountId is usually an unset variable, and treating it as unset would silently
		// widen every call to every discovered account.
		if (config.accountId === '') {
			throw new ToolSetConfigError('accountId must not be an empty string');
		}
		assertAccountIdList(config.accountIds, 'accountIds');
		assertAccountIdList(config.execute?.accountIds, 'execute.accountIds');
 
		const apiKey = config.apiKey || process.env.STACKONE_API_KEY;
		if (!apiKey) {
			throw new ToolSetConfigError(
				'An API key must be provided, either to the toolset or in the STACKONE_API_KEY environment variable',
			);
		}
 
		const ignoredHeaders = Object.keys(config.headers ?? {}).filter(isSdkOwnedHeader);
		if (ignoredHeaders.length > 0) {
			warn(
				`Ignoring headers ${ignoredHeaders.map((name) => `"${name}"`).join(', ')}: the SDK sets them itself. Pass the API key and account ids through their own options instead.`,
			);
		}
 
		this.#apiKey = apiKey;
		this.#baseUrl = config.baseUrl || process.env.STACKONE_BASE_URL || DEFAULT_BASE_URL;
		this.#headers = { ...config.headers };
		this.#timeout = config.timeout ?? config.execute?.timeout ?? DEFAULT_TIMEOUT_MS;
		this.#toolMode = config.toolMode;
		this.#includeNonShared = config.includeNonShared ?? false;
		this.#accountId = config.accountId;
		this.#accountIds = [...(config.accountIds ?? config.execute?.accountIds ?? [])];
 
		// 2.x read STACKONE_ACCOUNT_ID. An upgrade that keeps relying on it would silently widen to
		// every account on the key — other end users' accounts included — so say so, once.
		if (
			process.env.STACKONE_ACCOUNT_ID &&
			this.#accountId == null &&
			this.#accountIds.length === 0
		) {
			warn(
				'STACKONE_ACCOUNT_ID is set, but the SDK does not read it: with no account id passed, every active shared account on this API key is used. Pass an account id to scope the toolset.',
			);
		}
	}
 
	/**
	 * Set account IDs for filtering tools
	 * @param accountIds Array of account IDs to filter tools by. `null` clears them, as `[]` does.
	 * @returns This toolset instance for chaining
	 */
	setAccounts(accountIds: string[] | null): this {
		assertAccountIdList(accountIds, 'accountIds');
		this.#accountIds = [...(accountIds ?? [])];
		this.clearCatalogCache();
		return this;
	}
 
	/**
	 * Invalidate the cached tool catalog and discovered accounts.
	 *
	 * Call when linked accounts change outside of {@link setAccounts} or when you need to force a
	 * fresh fetch from the StackOne MCP endpoint. A listing already in flight will not write its
	 * result back into the cache, and no later call joins it.
	 */
	clearCatalogCache(): void {
		this.#cacheGeneration += 1;
		this.#catalogCache.clear();
		this.#catalogInFlight.clear();
		this.#discoveredAccountIds = undefined;
		this.#discovering = undefined;
		this.#providerMisses.clear();
	}
 
	/**
	 * Get tools in OpenAI function calling format.
	 *
	 * @param options - Options
	 * @param options.accountIds - Account IDs to scope tools. Defaults to the toolset's accounts.
	 * @returns List of tool definitions in OpenAI function format.
	 *
	 * @example
	 * ```typescript
	 * const toolset = new StackOneToolSet();
	 * const tools = await toolset.openai();
	 * ```
	 */
	async openai(options?: { accountIds?: string[] | null }): Promise<ReturnType<Tools['toOpenAI']>> {
		const tools = await this.fetchTools({ accountIds: options?.accountIds });
		return tools.toOpenAI();
	}
 
	/**
	 * List the accounts linked to this API key.
	 *
	 * Each entry carries at least `id`, `provider` and `status`. Only accounts with
	 * `status === 'active'` can serve tools.
	 *
	 * Also records the end user of every non-shared account (`shared: false`, with an
	 * `origin_username`), which the toolset then sends as `x-end-user-id` on that account's MCP
	 * requests, and every account's provider. Overlapping calls record in the order they started:
	 * a call's result is recorded unless one started after it has already been.
	 *
	 * @throws StackOneAPIError If the API answers with an error status, including a 429 that
	 *   outlasted its retries or timed out while being retried.
	 * @throws ToolSetLoadError If the API cannot be reached, or answers with something that is
	 *   not a JSON list (including a body that is not valid UTF-8).
	 */
	async fetchAccounts(): Promise<StackOneAccount[]> {
		const fetching = this.#requestAccounts();
		this.#fetchingAccounts = fetching;
		const settled = (): void => {
			if (this.#fetchingAccounts === fetching) {
				this.#fetchingAccounts = undefined;
			}
		};
		fetching.then(settled, settled);
		return fetching;
	}
 
	async #requestAccounts(): Promise<StackOneAccount[]> {
		const url = `${this.#baseUrl.replace(/\/+$/, '')}/accounts`;
		const started = ++this.#accountsStarted;
		let response: Response;
		let rateLimited = false;
		try {
			response = await fetchWithRetry(
				url,
				{
					headers: buildRequestHeaders({ apiKey: this.#apiKey, extraHeaders: this.#headers }),
					signal: AbortSignal.timeout(this.#timeout),
				},
				{
					deadline: retryTiming.now() + this.#timeout,
					onRetry: () => {
						rateLimited = true;
					},
				},
			);
		} catch (error) {
			// Timing out while a 429 is retried is still the rate limit, as it is over MCP.
			if (rateLimited && error instanceof DOMException && error.name === 'TimeoutError') {
				throw new StackOneAPIError(
					`Listing accounts at ${url} was rate limited (429) and timed out after ${this.#timeout / 1000}s while retrying`,
					429,
					null,
					undefined,
					{ cause: error },
				);
			}
			throw new ToolSetLoadError(`Could not reach ${url}: ${describeError(error)}`, {
				cause: error,
			});
		}
 
		let bytes: ArrayBuffer;
		try {
			bytes = await response.arrayBuffer();
		} catch (error) {
			throw new ToolSetLoadError(
				`Could not read the response from ${url}: ${describeError(error)}`,
				{
					cause: error,
				},
			);
		}
 
		if (!response.ok) {
			// Carry the status, so a caller can tell a 401 from a 429.
			const text = new TextDecoder().decode(bytes).trim();
			throw new StackOneAPIError(
				`${`Listing accounts at ${url} failed with ${response.status} ${response.statusText}`.trimEnd()}: ${text}`,
				response.status,
				text,
				undefined,
				response.status === 429 ? { cause: new HttpRateLimitError(url) } : undefined,
			);
		}
 
		let body: unknown;
		try {
			body = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
		} catch (error) {
			throw new ToolSetLoadError(`Invalid JSON returned by ${url}: ${describeError(error)}`, {
				cause: error,
			});
		}
		const accounts =
			typeof body === 'object' && body !== null && !Array.isArray(body) && 'data' in body
				? (body as { data: unknown }).data
				: body;
		if (!Array.isArray(accounts)) {
			throw new ToolSetLoadError(
				`Unexpected /accounts response shape: expected a list, got ${jsonType(accounts)}`,
			);
		}
		if (started > this.#accountsRecorded) {
			this.#accountsRecorded = started;
			this.#endUserIds = endUserIdsOf(accounts);
			this.#providers = providersOf(accounts);
			// An account this response names is no longer one the lookup missed.
			for (const accountId of this.#providers.keys()) {
				this.#providerMisses.delete(accountId);
			}
		}
		return accounts as StackOneAccount[];
	}
 
	/**
	 * The active accounts linked to this API key.
	 *
	 * The MCP endpoint requires an `x-account-id` on every request, so an API key on its own is
	 * not enough to list tools. Rather than make every caller supply one, ask the API which
	 * accounts the key has.
	 *
	 * For organisations with many linked accounts, discovery lists the catalog of every one of
	 * them: pass explicit `accountIds` to avoid the round trips and the context they cost.
	 *
	 * Non-shared accounts are skipped, with a warning, unless `includeNonShared` is set: each
	 * belongs to a single end user, and one context should not mix every end user's accounts.
	 *
	 * @throws ToolSetConfigError If the key has no accounts, none are active, or every active one is
	 *   non-shared and `includeNonShared` is not set.
	 */
	async #discoverAccountIds(): Promise<string[]> {
		if (this.#discoveredAccountIds) {
			return this.#discoveredAccountIds;
		}
		// Shared while in flight, so concurrent search() and fetchTools() calls on a fresh toolset
		// make one GET /accounts between them rather than one each.
		if (!this.#discovering) {
			const discovering = this.#fetchActiveAccountIds().finally(() => {
				if (this.#discovering === discovering) {
					this.#discovering = undefined;
				}
			});
			this.#discovering = discovering;
		}
		return this.#discovering;
	}
 
	async #fetchActiveAccountIds(): Promise<string[]> {
		const generation = this.#cacheGeneration;
		const accounts = await this.fetchAccounts();
		const active = accounts
			.filter(
				(account) => account?.status === 'active' && typeof account.id === 'string' && account.id,
			)
			.map((account) => account.id);
		if (active.length === 0) {
			if (accounts.length === 0) {
				throw new ToolSetConfigError(
					'This API key has no linked accounts. Link one in the StackOne dashboard, or pass an account id explicitly.',
				);
			}
			const listed = accounts
				.map((account) => `${String(account?.provider)} (${String(account?.status)})`)
				.join(', ');
			throw new ToolSetConfigError(
				`None of this API key's ${accounts.length} linked accounts are active: ${listed}. Re-link them in the StackOne dashboard, or pass an account id explicitly.`,
			);
		}
		const nonShared = new Set(
			accounts.filter((account) => account?.shared === false).map((account) => account.id),
		);
		const usable = this.#includeNonShared ? active : active.filter((id) => !nonShared.has(id));
		// A key with nothing usable fails, as one with no active accounts does, rather than leave
		// fetchTools() empty and execute() pointing at search().
		if (usable.length === 0) {
			throw new ToolSetConfigError(
				`None of this API key's ${active.length} active account(s) are shared: each belongs to a single end user. Pass their account ids, or opt in to non-shared accounts, to use them.`,
			);
		}
		const skipped = active.filter((id) => !usable.includes(id)).sort();
		if (skipped.length > 0) {
			warn(
				`Discovery skipped ${skipped.length} non-shared account(s) (${skipped.join(', ')}): each belongs to a single end user. Pass their account ids, or opt in to non-shared accounts, to use them.`,
			);
		}
		if (generation === this.#cacheGeneration) {
			this.#discoveredAccountIds = usable;
		}
		return usable;
	}
 
	/**
	 * The accounts a call is scoped to, in the order they were given: the call's own, then the
	 * toolset's, then its single account, then every discovered account — active and shared, or
	 * non-shared too with `includeNonShared` — in `GET /accounts` order.
	 */
	async #accountsInOrder(accountIds: string[] | null | undefined): Promise<string[]> {
		assertAccountIdList(accountIds, 'accountIds');
		let scope = accountIds?.length ? accountIds : this.#accountIds;
		if (scope.length === 0 && this.#accountId) {
			scope = [this.#accountId];
		}
		if (scope.length === 0) {
			scope = await this.#discoverAccountIds();
		}
		return scope;
	}
 
	async #resolveAccountScope(accountIds: string[] | null | undefined): Promise<string[]> {
		// Sorted and deduplicated: the listing order, and the cache key, must not depend on the
		// order the caller happened to name the accounts in.
		return [...new Set(await this.#accountsInOrder(accountIds))].sort();
	}
 
	#endpoint(mode: ToolMode | undefined): string {
		const endpoint = `${this.#baseUrl.replace(/\/+$/, '')}/mcp`;
		return mode ? `${endpoint}?tool-mode=${mode}` : endpoint;
	}
 
	/**
	 * Keyed on what was fetched, not on how it is filtered: providers and actions narrow the list
	 * in memory, so they must not force a refetch. The base URL and API key belong in it, so a
	 * catalog is never served for a host or key other than the one it was listed from.
	 */
	#cacheKey(scope: readonly string[], mode: ToolMode | undefined): string {
		return JSON.stringify([scope, mode ?? null, this.#baseUrl, this.#apiKey]);
	}
 
	/**
	 * The catalog of every scoped account, from the cache where it can be.
	 *
	 * One unusable account must not cost the caller every other account's tools, so a failing
	 * account is skipped with a warning. The healthy accounts' listings are cached all the same,
	 * and the failed account is left out — silently — until {@link FAILED_ACCOUNT_RETRY_MS} has
	 * passed, when the next call lists it again. Not caching at all made every call re-list every
	 * account, and wait out the full timeout on one that hangs.
	 *
	 * A 429 that outlasted its retries is not the account's fault but the key's, so it fails the
	 * whole listing instead: skipping it would hand back a catalog missing whichever accounts
	 * happened to be throttled. When every account fails, see {@link allAccountsFailed}.
	 *
	 * The failed accounts in `retry` are listed again now, due or not. A listing already in flight
	 * is joined either way, and its result stands even if it was not listing them. A scope with no
	 * accounts has an empty catalog.
	 */
	async #catalog(
		scope: readonly string[],
		mode: ToolMode | undefined,
		generation: number,
		retry: ReadonlySet<string> = new Set(),
	): Promise<ScopedCatalog> {
		const inScope = ({ listings, failed }: CachedCatalog): ScopedCatalog => ({
			entries: scope.flatMap((accountId) => listings.get(accountId) ?? []),
			failed: scope.flatMap((accountId): ScopedCatalog['failed'] => {
				const failure = failed.get(accountId);
				return failure ? [[accountId, failure.message, failure.at]] : [];
			}),
		});
		Iif (scope.length === 0) {
			return { entries: [], failed: [] };
		}
		const key = this.#cacheKey(scope, mode);
		const cached = this.#catalogCache.get(key);
		const now = retryTiming.now();
		const due = cached
			? [...cached.failed]
					.filter(
						([accountId, { at }]) => retry.has(accountId) || now - at >= FAILED_ACCOUNT_RETRY_MS,
					)
					.map(([accountId]) => accountId)
			: scope;
		if (cached && due.length === 0) {
			return inScope(cached);
		}
 
		// A listing that started before a clear is not joined: it belongs to the old generation.
		const inFlight = this.#catalogInFlight.get(key);
		if (inFlight && generation === this.#cacheGeneration) {
			return inScope(await inFlight);
		}
 
		const listing = this.#listCatalog(key, scope, due, cached, mode, generation);
		// Only a current listing is shared, so no later call can join one from before a clear.
		if (generation === this.#cacheGeneration) {
			this.#catalogInFlight.set(key, listing);
		}
		try {
			return inScope(await listing);
		} finally {
			if (this.#catalogInFlight.get(key) === listing) {
				this.#catalogInFlight.delete(key);
			}
		}
	}
 
	/**
	 * Lists every due account and stores the merged result under `key`, run at most once
	 * concurrently per key — see {@link #catalogInFlight}. Returns the listings it computed
	 * regardless of whether {@link #cacheGeneration} let them be cached, so a clear mid-flight
	 * cannot make a coalesced caller read back nothing.
	 */
	async #listCatalog(
		key: string,
		scope: readonly string[],
		due: readonly string[],
		cached: CachedCatalog | undefined,
		mode: ToolMode | undefined,
		generation: number,
	): Promise<CachedCatalog> {
		const endpoint = this.#endpoint(mode);
		const listAccount = async (accountId: string): Promise<CatalogEntry[]> => {
			const definitions = await withEndUser(accountId, this.#endUsers, (endUserId) =>
				listMcpTools({
					endpoint,
					headers: buildRequestHeaders({
						apiKey: this.#apiKey,
						accountId,
						endUserId,
						extraHeaders: this.#headers,
					}),
					timeout: this.#timeout,
				}),
			);
			return definitions.map((definition) => ({ definition, accountId, endpoint }));
		};
		const store = (catalog: CachedCatalog): void => {
			if (generation === this.#cacheGeneration) {
				this.#catalogCache.set(key, catalog);
			}
		};
 
		if (scope.length === 1) {
			const accountId = scope[0] as string;
			const listings = new Map([[accountId, await listAccount(accountId)]]);
			const catalog = { listings, failed: new Map() };
			store(catalog);
			return catalog;
		}
 
		const settled = await settleWithConcurrency(
			due,
			MAX_CONCURRENCY,
			listAccount,
			isRateLimitFailure,
		);
		const listings = new Map(cached?.listings);
		const failed = new Map(cached?.failed);
		const failures: [accountId: string, reason: unknown][] = [];
		settled.forEach((outcome, index) => {
			const accountId = due[index] as string;
			if (outcome.status === 'fulfilled') {
				listings.set(accountId, outcome.value);
				failed.delete(accountId);
			} else {
				failures.push([accountId, outcome.reason]);
			}
		});
		if (listings.size === 0) {
			throw allAccountsFailed(failures);
		}
		const failedNow = retryTiming.now();
		for (const [accountId, reason] of failures) {
			const message = describeError(reason);
			warn(`Skipping account that failed to list tools — ${accountId}: ${message}`);
			failed.set(accountId, { at: failedNow, message });
		}
		const catalog = { listings, failed };
		store(catalog);
		return catalog;
	}
 
	/**
	 * Build an executable tool from a served catalog entry, on a deep copy of its schema. Every
	 * tool — per-action, meta or feedback — executes over `tools/call` on the endpoint and account
	 * that listed it.
	 */
	#createTool(entry: CatalogEntry): StackOneTool {
		const { definition, accountId, endpoint } = entry;
		return new StackOneMcpTool({
			name: definition.name,
			description: definition.description ?? '',
			parameters: toolParametersFromInputSchema(cloneJson(definition.inputSchema)),
			endpoint,
			apiKey: this.#apiKey,
			accountId,
			timeout: this.#timeout,
			extraHeaders: this.#headers,
			endUsers: this.#endUsers,
		});
	}
 
	/**
	 * Fetch tools with optional filtering by account IDs, providers, and actions.
	 *
	 * The listing is cached per account scope and mode; filters are applied in memory, and every
	 * call builds fresh tool instances, so mutating one caller's tools never affects another's.
	 *
	 * `stackone_submit_feedback` is served once per account listing; it is returned once.
	 *
	 * Rate limits: a request answered 429 is retried up to 3 times, after the server's
	 * `Retry-After` (capped at 30s) or a 1s/2s/4s backoff, unless that wait would outlast the
	 * `timeout`. One still rate limited after that — or that times out while waiting to retry —
	 * fails the whole call: an account that fails any other way is skipped with a warning, and
	 * left out for the next {@link FAILED_ACCOUNT_RETRY_MS}, but a 429 never yields a partial
	 * catalog.
	 *
	 * @throws ToolSetConfigError If no account is configured and none can be discovered.
	 * @throws StackOneAPIError With status 429 if any account is still rate limited after the
	 *   retries; or with the API's status when every account fails with that same status, so a
	 *   caller can tell a revoked key's 401 from a 429.
	 * @throws ToolSetLoadError If the catalog cannot be loaded. When every account fails for
	 *   differing reasons, its `cause` is an `AggregateError` of each account's error.
	 */
	async fetchTools(options: FetchToolsOptions = {}): Promise<Tools> {
		return (await this.#fetchTools(options)).tools;
	}
 
	/**
	 * {@link fetchTools}, also returning the accounts left out because they failed to list. Those in
	 * `retry` are listed again first — see {@link #catalog}.
	 */
	async #fetchTools(
		options: FetchToolsOptions,
		retry?: ReadonlySet<string>,
	): Promise<{ tools: Tools; failed: ScopedCatalog['failed'] }> {
		try {
			const mode = options.mode === undefined ? this.#toolMode : (options.mode ?? undefined);
			// Taken before discovery: a clear while GET /accounts is out must stop this call's
			// listing being cached, as it was scoped by accounts discovered before the clear.
			const generation = this.#cacheGeneration;
			const scope = await this.#resolveAccountScope(options.accountIds);
			const { entries, failed } = await this.#catalog(scope, mode, generation, retry);
 
			let seenFeedbackTool = false;
			let tools = entries
				.filter(({ definition }) => {
					// Global rather than account-scoped, so every account's listing carries an
					// identical copy. Keep the first.
					if (definition.name !== SUBMIT_FEEDBACK_TOOL_NAME) {
						return true;
					}
					const first = !seenFeedbackTool;
					seenFeedbackTool = true;
					return first;
				})
				.map((entry) => this.#createTool(entry));
 
			if (options.providers?.length) {
				const providers = options.providers;
				tools = tools.filter((tool) => matchesProvider(tool.name, providers));
			}
			if (options.actions?.length) {
				const actions = options.actions;
				tools = tools.filter((tool) => actions.some((pattern) => matchGlob(tool.name, pattern)));
			}
 
			warnOnDuplicateNames(tools);
			return { tools: new Tools(tools), failed };
		} catch (error) {
			// StackOneAPIError carries the HTTP status. Re-wrapping it would throw that away, so a
			// caller could not tell a 401 from a 429.
			if (error instanceof StackOneError) {
				throw error;
			}
			throw new ToolSetLoadError(`Error fetching tools: ${describeError(error)}`, {
				cause: error,
			});
		}
	}
 
	/**
	 * The server's per-connector meta tools, whatever this toolset's own mode.
	 *
	 * The mode is passed down rather than switched on the instance, so a concurrent
	 * `fetchTools()` can never read the switched mode and cache meta tools under the wrong key.
	 */
	async #metaTools(
		suffix: string,
		accountIds: string[] | null | undefined,
		retry?: ReadonlySet<string>,
	): Promise<{ tools: StackOneTool[]; failed: ScopedCatalog['failed'] }> {
		const { tools, failed } = await this.#fetchTools({ accountIds, mode: 'search_execute' }, retry);
		return {
			tools: tools.getStackOneTools().filter((tool) => tool.name.endsWith(suffix)),
			failed,
		};
	}
 
	/**
	 * Find actions matching a natural-language query.
	 *
	 * Searches every linked connector and ranks the results together, so a catalog of hundreds of
	 * tools never has to fit in a model's context. A connector that fails to search is skipped
	 * with a warning, unless they all fail.
	 *
	 * Rate limits: a request answered 429 is retried up to 3 times, after the server's
	 * `Retry-After` (capped at 30s) or a 1s/2s/4s backoff, unless that wait would outlast the
	 * `timeout`. One still rate limited after that fails the whole search rather than being
	 * skipped.
	 *
	 * @param query What you want to do, e.g. "list recent comments".
	 * @returns At most `topK` actions, best first across every connector, each carrying
	 *   `action_id`, the `account_id` of the account whose connector found it, and the
	 *   `session_id` of the search when the server issued one. The same action linked on two
	 *   accounts is two hits. Pass `session_id` to {@link execute} and {@link submitFeedback} to
	 *   link the calls, and `account_id` in `accountIds` to run the action on that account.
	 * @throws ToolSetConfigError If `topK` is not an integer between 1 and 50.
	 * @throws StackOneAPIError With status 429 if a request is still rate limited after retries.
	 * @throws ToolSetLoadError If every connector fails.
	 */
	async search(query: string, options: SearchOptions = {}): Promise<SearchResult[]> {
		const topK = options.topK === undefined ? 10 : options.topK;
		// The server rejects anything outside 1..50, but only after a round trip per connector —
		// and that reads like an outage rather than a typo. Fail here, where the caller can see why.
		if (typeof topK !== 'number' || !Number.isInteger(topK) || topK < 1 || topK > MAX_TOP_K) {
			throw new ToolSetConfigError(
				`topK must be an integer between 1 and ${MAX_TOP_K}, got ${JSON.stringify(topK) ?? String(topK)}`,
			);
		}
		Iif (typeof query !== 'string') {
			throw new ToolSetConfigError(`query must be a string, got ${typeof query}`);
		}
 
		const { tools } = await this.#metaTools('_search_actions', options.accountIds);
		if (tools.length === 0) {
			return [];
		}
 
		const searchOne = async (tool: StackOneTool): Promise<SearchResult[]> => {
			const found = await tool.execute({ query, top_k: topK });
			const actions = (Array.isArray(found.actions) ? found.actions : []).filter(
				(action): action is SearchResult => isPlainObject(action),
			);
			// The server returns session_id once per search, beside the actions. Results from every
			// connector are merged and re-ranked below, so this is the last point at which a hit can
			// still be traced to the search, and the account, that produced it.
			const sessionId = found.session_id;
			const traced = typeof sessionId === 'string' && sessionId ? { session_id: sessionId } : {};
			const accountId = tool.getAccountId();
			return actions.map((action) => ({
				...action,
				...traced,
				...(accountId ? { account_id: accountId } : {}),
			}));
		};
 
		// Fan out the way fetchTools() does: serially, a dozen connectors would cost the sum of
		// their latencies on the headline call.
		const settled = await settleWithConcurrency(
			tools,
			MAX_CONCURRENCY,
			searchOne,
			isRateLimitFailure,
		);
		const results: SearchResult[] = [];
		const failures: string[] = [];
		settled.forEach((outcome, index) => {
			if (outcome.status === 'fulfilled') {
				results.push(...outcome.value);
			} else {
				failures.push(`${tools[index]?.name}: ${describeError(outcome.reason)}`);
			}
		});
		if (failures.length > 0 && results.length === 0) {
			throw new ToolSetLoadError(`No connector returned results. ${failures.join(' | ')}`);
		}
		for (const failure of failures) {
			warn(`Skipping connector that failed to search — ${failure}`);
		}
 
		// Concatenating per-connector results would leave them grouped by connector, so results[0]
		// would be the best hit of whichever connector answered first rather than the best hit
		// overall. The server scores every action on the same scale, so rank globally, then cut to
		// topK: each connector was asked for topK, so the merged list can hold many more.
		return results.sort((left, right) => scoreOf(right) - scoreOf(left)).slice(0, topK);
	}
 
	/**
	 * Execute an action by id, as returned by {@link search}.
	 *
	 * Always runs through the connector's `*_execute_action` meta tool, so `args` is the nested
	 * envelope every action's `example_request` shows — `{ query: {...}, path: {...}, body: {...} }`.
	 * A `fetchTools()` tool takes the keys its own served schema names instead; routing by whether
	 * an id happened to be in the catalog would make the argument shape depend on something the
	 * caller cannot see. The connector is the longest one whose name prefixes `actionId`, and `actionId` is
	 * pinned last, so a model-supplied `action_id` in `args` cannot replace it.
	 *
	 * `args.headers` is forwarded to the action: `*_execute_action` serves `headers` as an open
	 * object, so any header name is declared — except `Authorization`, `x-account-id`,
	 * `User-Agent` and `x-end-user-id`, which the SDK sets itself and drops here with a warning.
	 *
	 * @param actionId The action to run, e.g. `linear_list_issues`.
	 * @param args The action's arguments.
	 * @param options.sessionId The `session_id` a search hit carries, to link this call to it.
	 * @returns The action's result as the server wrote it: `{ isError: false, result, … }`.
	 * @param options.accountIds Restrict routing to these accounts. Pass a search hit's
	 *   `account_id` to run the action on the account that found it.
	 * @throws ToolSetConfigError If the arguments are malformed, or the action's connector is
	 *   linked on more than one account and the call names none.
	 * @throws ToolSetLoadError If no linked connector serves the action, or an account in scope
	 *   failed to list — after being listed again now, unless the provider lookup failed to name
	 *   it in the last 30 seconds — and its provider is the action's connector or unknown.
	 * @throws StackOneAPIError If the action fails, or the lookup of a failed account's provider
	 *   is rate limited.
	 */
	async execute(
		actionId: string,
		args?: JsonObject,
		options: ExecuteActionOptions = {},
	): Promise<ActionResult> {
		if (typeof actionId !== 'string' || !actionId) {
			throw new ToolSetConfigError(
				`actionId must be a non-empty string, got ${JSON.stringify(actionId) ?? String(actionId)}`,
			);
		}
		if (args !== undefined && !isPlainObject(args)) {
			throw new ToolSetConfigError(`arguments must be a JSON object, got ${jsonType(args)}`);
		}
		const { sessionId } = options;
		if (sessionId != null && (typeof sessionId !== 'string' || !sessionId)) {
			throw new ToolSetConfigError(
				`sessionId must be a non-empty string, got ${JSON.stringify(sessionId)}`,
			);
		}
 
		const suffix = '_execute_action';
		const lowered = actionId.toLowerCase();
		const started = retryTiming.now();
		let { tools, failed } = await this.#metaTools(suffix, options.accountIds);
 
		// An account that failed to list may serve this action too: its own connector's, or one we
		// cannot tell. Running on whichever account did list would pick for the caller — possibly
		// another end user's account — so refuse until it lists, or the caller names an account.
		// The action's connector is the longest prefix among those listed and the providers
		// `GET /accounts` named, as execute() routes: a failed `browser` account cannot serve
		// `browser_linkedin_search`.
		const inReach = (): ScopedCatalog['failed'] => {
			const connector = longestConnector(lowered, [
				...tools.map((tool) => connectorOf(tool, suffix)),
				...[...this.#providers.values()].map((provider) => provider.toLowerCase()),
			]);
			return failed.filter(([accountId]) => {
				const provider = this.#providers.get(accountId);
				return provider === undefined || provider.toLowerCase() === connector;
			});
		};
		// With explicit account ids no GET /accounts has named the failed accounts' providers, so
		// one dead account would refuse every action. Ask once — joining a lookup in flight — and
		// treat the provider as unknown still if that fails too, unless it was rate limited: the
		// key's 429 is fatal, as it is to the end-user lookup. An account the lookup recently
		// failed to name is not asked about again until its window is up.
		const failedIds = failed.map(([accountId]) => accountId);
		const missedRecently = new Set(
			failedIds.filter(
				(accountId) =>
					started - (this.#providerMisses.get(accountId) ?? -Infinity) < FAILED_ACCOUNT_RETRY_MS,
			),
		);
		const unknown = failedIds.filter(
			(accountId) => !this.#providers.has(accountId) && !missedRecently.has(accountId),
		);
		if (unknown.length > 0) {
			const generation = this.#cacheGeneration;
			await (this.#fetchingAccounts ?? this.fetchAccounts()).catch((error: unknown) => {
				if (isRateLimitFailure(error)) {
					throw error;
				}
			});
			// A miss is this call's to act on whatever happens, but only recorded for later calls
			// if no clearCatalogCache() came in between, which promises to forget misses.
			const missedAt = retryTiming.now();
			for (const accountId of unknown) {
				if (!this.#providers.has(accountId)) {
					missedRecently.add(accountId);
					if (generation === this.#cacheGeneration) {
						this.#providerMisses.set(accountId, missedAt);
					}
				}
			}
		}
		// Listed again now rather than when due, but only those that could serve the action, did
		// not just fail for this call, and were not recently missed by the lookup: re-listing an
		// account on another provider would make this call wait out its timeout, and re-listing one
		// the lookup cannot name would make every call wait it out.
		const retry = inReach().filter(
			([accountId, , at]) => at < started && !missedRecently.has(accountId),
		);
		if (retry.length > 0) {
			({ tools, failed } = await this.#metaTools(
				suffix,
				options.accountIds,
				new Set(retry.map(([accountId]) => accountId)),
			));
		}
		const unlisted = inReach();
		if (unlisted.length > 0) {
			throw new ToolSetLoadError(
				`${JSON.stringify(actionId)} may be served by an account that failed to list (${unlisted.map(([accountId, message]) => `${accountId}: ${message}`).join('; ')}). Pass the account id to use, such as a search hit's account_id.`,
			);
		}
 
		const matches = tools.filter((tool) => lowered.startsWith(`${connectorOf(tool, suffix)}_`));
		if (matches.length === 0) {
			throw new ToolSetLoadError(
				`No connector found for ${JSON.stringify(actionId)}. Use search() to discover valid action ids.`,
			);
		}
 
		// Longest connector wins: with both `browser` and `browser_linkedin` linked, the first
		// token alone would route every browser_linkedin action to browser.
		const longest = Math.max(...matches.map((tool) => connectorOf(tool, suffix).length));
		const finalists = matches.filter((tool) => connectorOf(tool, suffix).length === longest);
		const [tool] = finalists as [StackOneTool, ...StackOneTool[]];
		if (finalists.length > 1) {
			// The same provider linked twice, which discovery makes common. Picking one would run the
			// action against an account the caller never chose — another end user's, possibly.
			throw new ToolSetConfigError(
				`${JSON.stringify(actionId)} matches ${finalists.length} connectors on different accounts (${finalists.map((t) => `${t.name} on ${t.getAccountId()}`).join(', ')}). Pass the account id to use, such as a search hit's account_id.`,
			);
		}
 
		// action_id LAST, deleted first so it is last in key order too. Spreading the arguments
		// over it would let a model-supplied "action_id" replace the action the caller pinned — the
		// exact thing a host app pins it for. session_id only when given: the served schema makes
		// it an optional string, so an absent key is valid and a null is not.
		const callArguments: JsonObject = { ...args };
		delete callArguments.action_id;
		if (sessionId != null) {
			delete callArguments.session_id;
			callArguments.session_id = sessionId;
		}
		callArguments.action_id = actionId;
 
		// `*_execute_action` answers with the action's result wrapper; see ActionResult.
		return (await tool.execute(callArguments)) as ActionResult;
	}
 
	/**
	 * Record a verdict on how well the tools served this session, through the server's
	 * `stackone_submit_feedback` tool.
	 *
	 * The tool is found in the served catalog, never built here: the server serves it only when
	 * feedback is enabled for the project, and a client-side stand-in would report success for
	 * feedback that went nowhere. Unset optional fields are omitted, never sent as null.
	 *
	 * Makes exactly one `tools/call`, on the account with the lowest id among those the call is
	 * scoped to: `accountIds` when given, otherwise the toolset's, otherwise every active shared
	 * one (non-shared ones too with `includeNonShared`).
	 *
	 * @example
	 * ```typescript
	 * const [hit] = await toolset.search('list recent comments');
	 * if (!hit) throw new Error('No action matched');
	 * await toolset.execute(hit.action_id, {}, { sessionId: hit.session_id });
	 * await toolset.submitFeedback({
	 *   rating: 'positive',
	 *   toolNames: [hit.action_id],
	 *   sessionId: hit.session_id,
	 * });
	 * ```
	 *
	 * @throws ToolSetConfigError If `toolNames` is not a list, `sessionId` is empty or not a string,
	 *   or `accountIds` holds an empty id.
	 * @throws ToolSetLoadError If feedback is not enabled for this project.
	 */
	async submitFeedback(options: SubmitFeedbackOptions): Promise<ActionResult> {
		const {
			rating,
			toolNames,
			feedback,
			category,
			sessionId,
			actionRunId,
			source = 'model',
		} = options;
		if (typeof (toolNames as unknown) === 'string') {
			throw new ToolSetConfigError(
				`toolNames must be a list of tool names, not a string. Did you mean [${JSON.stringify(toolNames)}]?`,
			);
		}
		Iif (!Array.isArray(toolNames)) {
			throw new ToolSetConfigError('toolNames must be an array of tool names');
		}
		if (sessionId != null && (typeof sessionId !== 'string' || !sessionId)) {
			throw new ToolSetConfigError(
				`sessionId must be a non-empty string, got ${JSON.stringify(sessionId)}`,
			);
		}
 
		// One account, one tools/call: the tool is global, so every account's copy records the same
		// feedback, and calling each would record it once per account. The lowest id is the one a
		// caller can predict, whatever order GET /accounts lists them in. search_execute lists two
		// meta tools per connector where individual mode lists every action.
		const accountIds = (await this.#resolveAccountScope(options.accountIds)).slice(0, 1);
		const tool = (await this.fetchTools({ accountIds, mode: 'search_execute' })).getTool(
			SUBMIT_FEEDBACK_TOOL_NAME,
		);
		if (!tool) {
			throw new ToolSetLoadError(
				`The server did not serve ${SUBMIT_FEEDBACK_TOOL_NAME}: feedback is not enabled for this project.`,
			);
		}
 
		const args: JsonObject = { rating, tool_names: [...toolNames] };
		const optional = {
			feedback,
			category,
			session_id: sessionId,
			action_run_id: actionRunId,
			source,
		};
		for (const [key, value] of Object.entries(optional)) {
			if (value !== undefined && value !== null) {
				args[key] = value;
			}
		}
		return (await tool.execute(args)) as ActionResult;
	}
}
 
/**
 * The end user of each non-shared account in a `GET /accounts` listing: one with `shared: false`
 * and a non-empty `origin_username`. A shared account has no single end user to send.
 */
function endUserIdsOf(accounts: readonly unknown[]): Map<string, string> {
	const endUserIds = new Map<string, string>();
	for (const account of accounts) {
		if (
			isPlainObject(account) &&
			typeof account.id === 'string' &&
			account.id &&
			account.shared === false &&
			typeof account.origin_username === 'string' &&
			account.origin_username
		) {
			endUserIds.set(account.id, account.origin_username);
		}
	}
	return endUserIds;
}
 
/** The provider of each account in a `GET /accounts` listing that names one. */
function providersOf(accounts: readonly unknown[]): Map<string, string> {
	const providers = new Map<string, string>();
	for (const account of accounts) {
		if (
			isPlainObject(account) &&
			typeof account.id === 'string' &&
			account.id &&
			typeof account.provider === 'string' &&
			account.provider
		) {
			providers.set(account.id, account.provider);
		}
	}
	return providers;
}
 
/**
 * The error for a listing in which every account failed.
 *
 * When they all failed with one HTTP status, that error is rethrown as it came, so a revoked key
 * is a 401 however many accounts it has, as it is with one. Otherwise the summary keeps every
 * account's own error as its `cause`.
 */
function allAccountsFailed(failures: readonly [accountId: string, reason: unknown][]): Error {
	const reasons = failures.map(([, reason]) => reason);
	const [first] = reasons;
	if (
		first instanceof StackOneAPIError &&
		reasons.every(
			(reason) => reason instanceof StackOneAPIError && reason.statusCode === first.statusCode,
		)
	) {
		return first;
	}
	return new ToolSetLoadError(
		`Every account failed to list tools: ${failures.map(([accountId, reason]) => `${accountId}: ${describeError(reason)}`).join('; ')}`,
		{ cause: new AggregateError(reasons, 'Every account failed to list tools') },
	);
}