fdic-mcp-server 1.0.7 → 1.0.8

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 (3) hide show
  1. package/dist/index.js +112 -31
  2. package/dist/server.js +112 -31
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
30
30
  var import_express = __toESM(require("express"));
31
31
 
32
32
  // src/constants.ts
33
- var VERSION = true ? "1.0.7" : process.env.npm_package_version ?? "0.0.0-dev";
33
+ var VERSION = true ? "1.0.8" : process.env.npm_package_version ?? "0.0.0-dev";
34
34
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
35
35
  var CHARACTER_LIMIT = 5e4;
36
36
  var ENDPOINTS = {
@@ -56,6 +56,13 @@ var apiClient = import_axios.default.create({
56
56
  });
57
57
  var QUERY_CACHE_TTL_MS = 6e4;
58
58
  var queryCache = /* @__PURE__ */ new Map();
59
+ function pruneExpiredQueryCache(now) {
60
+ for (const [key, entry] of queryCache.entries()) {
61
+ if (entry.expiresAt <= now) {
62
+ queryCache.delete(key);
63
+ }
64
+ }
65
+ }
59
66
  function getCacheKey(endpoint, params) {
60
67
  return JSON.stringify([
61
68
  endpoint,
@@ -67,10 +74,15 @@ function getCacheKey(endpoint, params) {
67
74
  params.sort_order ?? null
68
75
  ]);
69
76
  }
70
- async function queryEndpoint(endpoint, params) {
71
- const cacheKey = getCacheKey(endpoint, params);
77
+ async function queryEndpoint(endpoint, params, options = {}) {
78
+ if (options.signal?.aborted) {
79
+ throw new Error("FDIC API request was canceled before it started.");
80
+ }
81
+ const shouldUseCache = !options.signal;
72
82
  const now = Date.now();
73
- const cached = queryCache.get(cacheKey);
83
+ pruneExpiredQueryCache(now);
84
+ const cacheKey = getCacheKey(endpoint, params);
85
+ const cached = shouldUseCache ? queryCache.get(cacheKey) : void 0;
74
86
  if (cached && cached.expiresAt > now) {
75
87
  return cached.value;
76
88
  }
@@ -86,10 +98,14 @@ async function queryEndpoint(endpoint, params) {
86
98
  if (params.sort_by) queryParams.sort_by = params.sort_by;
87
99
  if (params.sort_order) queryParams.sort_order = params.sort_order;
88
100
  const response = await apiClient.get(`/${endpoint}`, {
89
- params: queryParams
101
+ params: queryParams,
102
+ signal: options.signal
90
103
  });
91
104
  return response.data;
92
105
  } catch (err) {
106
+ if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
107
+ throw new Error("FDIC API request was canceled.");
108
+ }
93
109
  if (err instanceof import_axios.AxiosError) {
94
110
  const status = err.response?.status;
95
111
  const detail = err.response?.data?.message ?? err.message;
@@ -112,14 +128,18 @@ async function queryEndpoint(endpoint, params) {
112
128
  throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
113
129
  }
114
130
  })();
115
- queryCache.set(cacheKey, {
116
- expiresAt: now + QUERY_CACHE_TTL_MS,
117
- value: requestPromise
118
- });
131
+ if (shouldUseCache) {
132
+ queryCache.set(cacheKey, {
133
+ expiresAt: now + QUERY_CACHE_TTL_MS,
134
+ value: requestPromise
135
+ });
136
+ }
119
137
  try {
120
138
  return await requestPromise;
121
139
  } catch (error) {
122
- queryCache.delete(cacheKey);
140
+ if (shouldUseCache) {
141
+ queryCache.delete(cacheKey);
142
+ }
123
143
  throw error;
124
144
  }
125
145
  }
@@ -1055,6 +1075,7 @@ Prefer concise human-readable summaries or tables when answering users. Structur
1055
1075
  var import_zod7 = require("zod");
1056
1076
  var CHUNK_SIZE = 25;
1057
1077
  var MAX_CONCURRENCY = 4;
1078
+ var ANALYSIS_TIMEOUT_MS = 9e4;
1058
1079
  var SortFieldSchema = import_zod7.z.enum([
1059
1080
  "asset_growth",
1060
1081
  "asset_growth_pct",
@@ -1144,14 +1165,31 @@ function change(start, end) {
1144
1165
  if (start === null || end === null) return null;
1145
1166
  return end - start;
1146
1167
  }
1168
+ function getQuarterIndex(repdte) {
1169
+ const year = Number.parseInt(repdte.slice(0, 4), 10);
1170
+ const month = Number.parseInt(repdte.slice(4, 6), 10);
1171
+ const quarter = month / 3;
1172
+ if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
1173
+ return null;
1174
+ }
1175
+ return year * 4 + quarter;
1176
+ }
1147
1177
  function yearsBetween(startRepdte, endRepdte) {
1178
+ const startQuarterIndex = getQuarterIndex(startRepdte);
1179
+ const endQuarterIndex = getQuarterIndex(endRepdte);
1180
+ if (startQuarterIndex !== null && endQuarterIndex !== null) {
1181
+ return Math.max((endQuarterIndex - startQuarterIndex) / 4, 0);
1182
+ }
1148
1183
  const start = /* @__PURE__ */ new Date(
1149
- `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
1184
+ `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}T00:00:00Z`
1150
1185
  );
1151
1186
  const end = /* @__PURE__ */ new Date(
1152
- `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
1187
+ `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}T00:00:00Z`
1188
+ );
1189
+ return Math.max(
1190
+ (end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3),
1191
+ 0
1153
1192
  );
1154
- return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
1155
1193
  }
1156
1194
  function cagr(start, end, years) {
1157
1195
  if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
@@ -1223,6 +1261,21 @@ function buildTopLevelInsights(comparisons) {
1223
1261
  (comparison) => comparison.insights?.includes(
1224
1262
  "growth_with_branch_consolidation"
1225
1263
  )
1264
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1265
+ deposit_mix_softening: comparisons.filter(
1266
+ (comparison) => comparison.insights?.includes(
1267
+ "deposit_mix_softening"
1268
+ )
1269
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1270
+ sustained_asset_growth: comparisons.filter(
1271
+ (comparison) => comparison.insights?.includes(
1272
+ "sustained_asset_growth"
1273
+ )
1274
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1275
+ multi_quarter_roa_decline: comparisons.filter(
1276
+ (comparison) => comparison.insights?.includes(
1277
+ "multi_quarter_roa_decline"
1278
+ )
1226
1279
  ).slice(0, 5).map((comparison) => String(comparison.name))
1227
1280
  };
1228
1281
  }
@@ -1378,7 +1431,7 @@ Insights
1378
1431
  ${insights}` : `${header}
1379
1432
  ${rows.join("\n")}`;
1380
1433
  }
1381
- async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1434
+ async function fetchInstitutionRoster(state, institutionFilters, activeOnly, signal) {
1382
1435
  const filterParts = [];
1383
1436
  if (state) filterParts.push(`STNAME:"${state}"`);
1384
1437
  if (activeOnly) filterParts.push("ACTIVE:1");
@@ -1390,10 +1443,12 @@ async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1390
1443
  offset: 0,
1391
1444
  sort_by: "CERT",
1392
1445
  sort_order: "ASC"
1393
- });
1394
- return extractRecords(response);
1446
+ }, { signal });
1447
+ const records = extractRecords(response);
1448
+ const warning = response.meta.total > records.length ? `Institution roster truncated to ${records.length.toLocaleString()} records out of ${response.meta.total.toLocaleString()} matched institutions. Narrow the comparison set with institution_filters or certs for complete analysis.` : void 0;
1449
+ return { records, warning };
1395
1450
  }
1396
- async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
1451
+ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields, signal) {
1397
1452
  const certFilters = buildCertFilters(certs);
1398
1453
  const tasks = repdteFilters.flatMap(
1399
1454
  (repdteFilter) => certFilters.map((certFilter) => ({
@@ -1409,7 +1464,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1409
1464
  offset: 0,
1410
1465
  sort_by: "CERT",
1411
1466
  sort_order: "ASC"
1412
- });
1467
+ }, { signal });
1413
1468
  return { repdteFilter: task.repdteFilter, response };
1414
1469
  });
1415
1470
  const byDate = /* @__PURE__ */ new Map();
@@ -1425,7 +1480,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1425
1480
  }
1426
1481
  return byDate;
1427
1482
  }
1428
- async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
1483
+ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
1429
1484
  const certFilters = buildCertFilters(certs);
1430
1485
  const responses = await mapWithConcurrency(
1431
1486
  certFilters,
@@ -1437,7 +1492,7 @@ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, field
1437
1492
  offset: 0,
1438
1493
  sort_by: "REPDTE",
1439
1494
  sort_order: "ASC"
1440
- })
1495
+ }, { signal })
1441
1496
  );
1442
1497
  const grouped = /* @__PURE__ */ new Map();
1443
1498
  for (const response of responses) {
@@ -1567,12 +1622,19 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1567
1622
  sort_by,
1568
1623
  sort_order
1569
1624
  }) => {
1625
+ const controller = new AbortController();
1626
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
1570
1627
  try {
1571
- const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
1628
+ const rosterResult = certs && certs.length > 0 ? {
1629
+ records: certs.map((cert) => ({ CERT: cert })),
1630
+ warning: void 0
1631
+ } : await fetchInstitutionRoster(
1572
1632
  state,
1573
1633
  institution_filters,
1574
- active_only
1634
+ active_only,
1635
+ controller.signal
1575
1636
  );
1637
+ const roster = rosterResult.records;
1576
1638
  const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
1577
1639
  if (candidateCerts.length === 0) {
1578
1640
  const output2 = {
@@ -1605,14 +1667,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1605
1667
  candidateCerts,
1606
1668
  start_repdte,
1607
1669
  end_repdte,
1608
- "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1670
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1671
+ controller.signal
1609
1672
  ),
1610
1673
  include_demographics ? fetchSeriesRecords(
1611
1674
  ENDPOINTS.DEMOGRAPHICS,
1612
1675
  candidateCerts,
1613
1676
  start_repdte,
1614
1677
  end_repdte,
1615
- "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1678
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1679
+ controller.signal
1616
1680
  ) : Promise.resolve(/* @__PURE__ */ new Map())
1617
1681
  ]);
1618
1682
  comparisons = candidateCerts.map(
@@ -1633,13 +1697,15 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1633
1697
  ENDPOINTS.FINANCIALS,
1634
1698
  candidateCerts,
1635
1699
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1636
- "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1700
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1701
+ controller.signal
1637
1702
  ),
1638
1703
  include_demographics ? fetchBatchedRecordsForDates(
1639
1704
  ENDPOINTS.DEMOGRAPHICS,
1640
1705
  candidateCerts,
1641
1706
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1642
- "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1707
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1708
+ controller.signal
1643
1709
  ) : Promise.resolve(/* @__PURE__ */ new Map())
1644
1710
  ]);
1645
1711
  const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
@@ -1662,10 +1728,12 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1662
1728
  );
1663
1729
  }).filter((comparison) => comparison !== null);
1664
1730
  }
1665
- const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
1666
- 0,
1667
- limit
1731
+ const sortedComparisons = sortComparisons(
1732
+ comparisons,
1733
+ sort_by,
1734
+ sort_order
1668
1735
  );
1736
+ const ranked = sortedComparisons.slice(0, limit);
1669
1737
  const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
1670
1738
  const output = {
1671
1739
  total_candidates: candidateCerts.length,
@@ -1675,12 +1743,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1675
1743
  analysis_mode,
1676
1744
  sort_by,
1677
1745
  sort_order,
1678
- insights: buildTopLevelInsights(comparisons),
1746
+ warnings: rosterResult.warning ? [rosterResult.warning] : [],
1747
+ insights: buildTopLevelInsights(sortedComparisons),
1679
1748
  ...pagination,
1680
1749
  comparisons: ranked
1681
1750
  };
1682
1751
  const text = truncateIfNeeded(
1683
- formatComparisonText(output),
1752
+ [
1753
+ rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
1754
+ formatComparisonText(output)
1755
+ ].filter((value) => value !== null).join("\n\n"),
1684
1756
  CHARACTER_LIMIT
1685
1757
  );
1686
1758
  return {
@@ -1688,7 +1760,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1688
1760
  structuredContent: output
1689
1761
  };
1690
1762
  } catch (err) {
1763
+ if (controller.signal.aborted) {
1764
+ return formatToolError(
1765
+ new Error(
1766
+ `Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the comparison set with certs or institution_filters and try again.`
1767
+ )
1768
+ );
1769
+ }
1691
1770
  return formatToolError(err);
1771
+ } finally {
1772
+ clearTimeout(timeoutId);
1692
1773
  }
1693
1774
  }
1694
1775
  );
package/dist/server.js CHANGED
@@ -41,7 +41,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
41
41
  var import_express = __toESM(require("express"));
42
42
 
43
43
  // src/constants.ts
44
- var VERSION = true ? "1.0.7" : process.env.npm_package_version ?? "0.0.0-dev";
44
+ var VERSION = true ? "1.0.8" : process.env.npm_package_version ?? "0.0.0-dev";
45
45
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
46
46
  var CHARACTER_LIMIT = 5e4;
47
47
  var ENDPOINTS = {
@@ -67,6 +67,13 @@ var apiClient = import_axios.default.create({
67
67
  });
68
68
  var QUERY_CACHE_TTL_MS = 6e4;
69
69
  var queryCache = /* @__PURE__ */ new Map();
70
+ function pruneExpiredQueryCache(now) {
71
+ for (const [key, entry] of queryCache.entries()) {
72
+ if (entry.expiresAt <= now) {
73
+ queryCache.delete(key);
74
+ }
75
+ }
76
+ }
70
77
  function getCacheKey(endpoint, params) {
71
78
  return JSON.stringify([
72
79
  endpoint,
@@ -78,10 +85,15 @@ function getCacheKey(endpoint, params) {
78
85
  params.sort_order ?? null
79
86
  ]);
80
87
  }
81
- async function queryEndpoint(endpoint, params) {
82
- const cacheKey = getCacheKey(endpoint, params);
88
+ async function queryEndpoint(endpoint, params, options = {}) {
89
+ if (options.signal?.aborted) {
90
+ throw new Error("FDIC API request was canceled before it started.");
91
+ }
92
+ const shouldUseCache = !options.signal;
83
93
  const now = Date.now();
84
- const cached = queryCache.get(cacheKey);
94
+ pruneExpiredQueryCache(now);
95
+ const cacheKey = getCacheKey(endpoint, params);
96
+ const cached = shouldUseCache ? queryCache.get(cacheKey) : void 0;
85
97
  if (cached && cached.expiresAt > now) {
86
98
  return cached.value;
87
99
  }
@@ -97,10 +109,14 @@ async function queryEndpoint(endpoint, params) {
97
109
  if (params.sort_by) queryParams.sort_by = params.sort_by;
98
110
  if (params.sort_order) queryParams.sort_order = params.sort_order;
99
111
  const response = await apiClient.get(`/${endpoint}`, {
100
- params: queryParams
112
+ params: queryParams,
113
+ signal: options.signal
101
114
  });
102
115
  return response.data;
103
116
  } catch (err) {
117
+ if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
118
+ throw new Error("FDIC API request was canceled.");
119
+ }
104
120
  if (err instanceof import_axios.AxiosError) {
105
121
  const status = err.response?.status;
106
122
  const detail = err.response?.data?.message ?? err.message;
@@ -123,14 +139,18 @@ async function queryEndpoint(endpoint, params) {
123
139
  throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
124
140
  }
125
141
  })();
126
- queryCache.set(cacheKey, {
127
- expiresAt: now + QUERY_CACHE_TTL_MS,
128
- value: requestPromise
129
- });
142
+ if (shouldUseCache) {
143
+ queryCache.set(cacheKey, {
144
+ expiresAt: now + QUERY_CACHE_TTL_MS,
145
+ value: requestPromise
146
+ });
147
+ }
130
148
  try {
131
149
  return await requestPromise;
132
150
  } catch (error) {
133
- queryCache.delete(cacheKey);
151
+ if (shouldUseCache) {
152
+ queryCache.delete(cacheKey);
153
+ }
134
154
  throw error;
135
155
  }
136
156
  }
@@ -1066,6 +1086,7 @@ Prefer concise human-readable summaries or tables when answering users. Structur
1066
1086
  var import_zod7 = require("zod");
1067
1087
  var CHUNK_SIZE = 25;
1068
1088
  var MAX_CONCURRENCY = 4;
1089
+ var ANALYSIS_TIMEOUT_MS = 9e4;
1069
1090
  var SortFieldSchema = import_zod7.z.enum([
1070
1091
  "asset_growth",
1071
1092
  "asset_growth_pct",
@@ -1155,14 +1176,31 @@ function change(start, end) {
1155
1176
  if (start === null || end === null) return null;
1156
1177
  return end - start;
1157
1178
  }
1179
+ function getQuarterIndex(repdte) {
1180
+ const year = Number.parseInt(repdte.slice(0, 4), 10);
1181
+ const month = Number.parseInt(repdte.slice(4, 6), 10);
1182
+ const quarter = month / 3;
1183
+ if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
1184
+ return null;
1185
+ }
1186
+ return year * 4 + quarter;
1187
+ }
1158
1188
  function yearsBetween(startRepdte, endRepdte) {
1189
+ const startQuarterIndex = getQuarterIndex(startRepdte);
1190
+ const endQuarterIndex = getQuarterIndex(endRepdte);
1191
+ if (startQuarterIndex !== null && endQuarterIndex !== null) {
1192
+ return Math.max((endQuarterIndex - startQuarterIndex) / 4, 0);
1193
+ }
1159
1194
  const start = /* @__PURE__ */ new Date(
1160
- `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
1195
+ `${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}T00:00:00Z`
1161
1196
  );
1162
1197
  const end = /* @__PURE__ */ new Date(
1163
- `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
1198
+ `${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}T00:00:00Z`
1199
+ );
1200
+ return Math.max(
1201
+ (end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3),
1202
+ 0
1164
1203
  );
1165
- return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
1166
1204
  }
1167
1205
  function cagr(start, end, years) {
1168
1206
  if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
@@ -1234,6 +1272,21 @@ function buildTopLevelInsights(comparisons) {
1234
1272
  (comparison) => comparison.insights?.includes(
1235
1273
  "growth_with_branch_consolidation"
1236
1274
  )
1275
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1276
+ deposit_mix_softening: comparisons.filter(
1277
+ (comparison) => comparison.insights?.includes(
1278
+ "deposit_mix_softening"
1279
+ )
1280
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1281
+ sustained_asset_growth: comparisons.filter(
1282
+ (comparison) => comparison.insights?.includes(
1283
+ "sustained_asset_growth"
1284
+ )
1285
+ ).slice(0, 5).map((comparison) => String(comparison.name)),
1286
+ multi_quarter_roa_decline: comparisons.filter(
1287
+ (comparison) => comparison.insights?.includes(
1288
+ "multi_quarter_roa_decline"
1289
+ )
1237
1290
  ).slice(0, 5).map((comparison) => String(comparison.name))
1238
1291
  };
1239
1292
  }
@@ -1389,7 +1442,7 @@ Insights
1389
1442
  ${insights}` : `${header}
1390
1443
  ${rows.join("\n")}`;
1391
1444
  }
1392
- async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1445
+ async function fetchInstitutionRoster(state, institutionFilters, activeOnly, signal) {
1393
1446
  const filterParts = [];
1394
1447
  if (state) filterParts.push(`STNAME:"${state}"`);
1395
1448
  if (activeOnly) filterParts.push("ACTIVE:1");
@@ -1401,10 +1454,12 @@ async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
1401
1454
  offset: 0,
1402
1455
  sort_by: "CERT",
1403
1456
  sort_order: "ASC"
1404
- });
1405
- return extractRecords(response);
1457
+ }, { signal });
1458
+ const records = extractRecords(response);
1459
+ const warning = response.meta.total > records.length ? `Institution roster truncated to ${records.length.toLocaleString()} records out of ${response.meta.total.toLocaleString()} matched institutions. Narrow the comparison set with institution_filters or certs for complete analysis.` : void 0;
1460
+ return { records, warning };
1406
1461
  }
1407
- async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
1462
+ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields, signal) {
1408
1463
  const certFilters = buildCertFilters(certs);
1409
1464
  const tasks = repdteFilters.flatMap(
1410
1465
  (repdteFilter) => certFilters.map((certFilter) => ({
@@ -1420,7 +1475,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1420
1475
  offset: 0,
1421
1476
  sort_by: "CERT",
1422
1477
  sort_order: "ASC"
1423
- });
1478
+ }, { signal });
1424
1479
  return { repdteFilter: task.repdteFilter, response };
1425
1480
  });
1426
1481
  const byDate = /* @__PURE__ */ new Map();
@@ -1436,7 +1491,7 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1436
1491
  }
1437
1492
  return byDate;
1438
1493
  }
1439
- async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
1494
+ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
1440
1495
  const certFilters = buildCertFilters(certs);
1441
1496
  const responses = await mapWithConcurrency(
1442
1497
  certFilters,
@@ -1448,7 +1503,7 @@ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, field
1448
1503
  offset: 0,
1449
1504
  sort_by: "REPDTE",
1450
1505
  sort_order: "ASC"
1451
- })
1506
+ }, { signal })
1452
1507
  );
1453
1508
  const grouped = /* @__PURE__ */ new Map();
1454
1509
  for (const response of responses) {
@@ -1578,12 +1633,19 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1578
1633
  sort_by,
1579
1634
  sort_order
1580
1635
  }) => {
1636
+ const controller = new AbortController();
1637
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
1581
1638
  try {
1582
- const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
1639
+ const rosterResult = certs && certs.length > 0 ? {
1640
+ records: certs.map((cert) => ({ CERT: cert })),
1641
+ warning: void 0
1642
+ } : await fetchInstitutionRoster(
1583
1643
  state,
1584
1644
  institution_filters,
1585
- active_only
1645
+ active_only,
1646
+ controller.signal
1586
1647
  );
1648
+ const roster = rosterResult.records;
1587
1649
  const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
1588
1650
  if (candidateCerts.length === 0) {
1589
1651
  const output2 = {
@@ -1616,14 +1678,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1616
1678
  candidateCerts,
1617
1679
  start_repdte,
1618
1680
  end_repdte,
1619
- "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1681
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1682
+ controller.signal
1620
1683
  ),
1621
1684
  include_demographics ? fetchSeriesRecords(
1622
1685
  ENDPOINTS.DEMOGRAPHICS,
1623
1686
  candidateCerts,
1624
1687
  start_repdte,
1625
1688
  end_repdte,
1626
- "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1689
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1690
+ controller.signal
1627
1691
  ) : Promise.resolve(/* @__PURE__ */ new Map())
1628
1692
  ]);
1629
1693
  comparisons = candidateCerts.map(
@@ -1644,13 +1708,15 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1644
1708
  ENDPOINTS.FINANCIALS,
1645
1709
  candidateCerts,
1646
1710
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1647
- "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
1711
+ "CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE",
1712
+ controller.signal
1648
1713
  ),
1649
1714
  include_demographics ? fetchBatchedRecordsForDates(
1650
1715
  ENDPOINTS.DEMOGRAPHICS,
1651
1716
  candidateCerts,
1652
1717
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1653
- "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
1718
+ "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1719
+ controller.signal
1654
1720
  ) : Promise.resolve(/* @__PURE__ */ new Map())
1655
1721
  ]);
1656
1722
  const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
@@ -1673,10 +1739,12 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1673
1739
  );
1674
1740
  }).filter((comparison) => comparison !== null);
1675
1741
  }
1676
- const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
1677
- 0,
1678
- limit
1742
+ const sortedComparisons = sortComparisons(
1743
+ comparisons,
1744
+ sort_by,
1745
+ sort_order
1679
1746
  );
1747
+ const ranked = sortedComparisons.slice(0, limit);
1680
1748
  const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
1681
1749
  const output = {
1682
1750
  total_candidates: candidateCerts.length,
@@ -1686,12 +1754,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1686
1754
  analysis_mode,
1687
1755
  sort_by,
1688
1756
  sort_order,
1689
- insights: buildTopLevelInsights(comparisons),
1757
+ warnings: rosterResult.warning ? [rosterResult.warning] : [],
1758
+ insights: buildTopLevelInsights(sortedComparisons),
1690
1759
  ...pagination,
1691
1760
  comparisons: ranked
1692
1761
  };
1693
1762
  const text = truncateIfNeeded(
1694
- formatComparisonText(output),
1763
+ [
1764
+ rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
1765
+ formatComparisonText(output)
1766
+ ].filter((value) => value !== null).join("\n\n"),
1695
1767
  CHARACTER_LIMIT
1696
1768
  );
1697
1769
  return {
@@ -1699,7 +1771,16 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1699
1771
  structuredContent: output
1700
1772
  };
1701
1773
  } catch (err) {
1774
+ if (controller.signal.aborted) {
1775
+ return formatToolError(
1776
+ new Error(
1777
+ `Analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the comparison set with certs or institution_filters and try again.`
1778
+ )
1779
+ );
1780
+ }
1702
1781
  return formatToolError(err);
1782
+ } finally {
1783
+ clearTimeout(timeoutId);
1703
1784
  }
1704
1785
  }
1705
1786
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fdic-mcp-server",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "MCP server for the FDIC BankFind Suite API",
5
5
  "main": "dist/server.js",
6
6
  "files": [