fdic-mcp-server 1.0.8 → 1.1.1

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 (4) hide show
  1. package/README.md +154 -185
  2. package/dist/index.js +732 -54
  3. package/dist/server.js +736 -56
  4. package/package.json +5 -2
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.8" : process.env.npm_package_version ?? "0.0.0-dev";
33
+ var VERSION = true ? "1.1.1" : 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 = {
@@ -55,12 +55,23 @@ var apiClient = import_axios.default.create({
55
55
  }
56
56
  });
57
57
  var QUERY_CACHE_TTL_MS = 6e4;
58
+ var QUERY_CACHE_MAX_ENTRIES = 500;
58
59
  var queryCache = /* @__PURE__ */ new Map();
59
60
  function pruneExpiredQueryCache(now) {
60
61
  for (const [key, entry] of queryCache.entries()) {
61
- if (entry.expiresAt <= now) {
62
- queryCache.delete(key);
62
+ if (entry.expiresAt > now) {
63
+ break;
63
64
  }
65
+ queryCache.delete(key);
66
+ }
67
+ }
68
+ function evictOverflowQueryCache() {
69
+ while (queryCache.size > QUERY_CACHE_MAX_ENTRIES) {
70
+ const oldestKey = queryCache.keys().next().value;
71
+ if (oldestKey === void 0) {
72
+ break;
73
+ }
74
+ queryCache.delete(oldestKey);
64
75
  }
65
76
  }
66
77
  function getCacheKey(endpoint, params) {
@@ -74,6 +85,38 @@ function getCacheKey(endpoint, params) {
74
85
  params.sort_order ?? null
75
86
  ]);
76
87
  }
88
+ function isRecord(value) {
89
+ return typeof value === "object" && value !== null;
90
+ }
91
+ function validateFdicResponseShape(endpoint, payload) {
92
+ if (!isRecord(payload)) {
93
+ throw new Error(
94
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected an object payload.`
95
+ );
96
+ }
97
+ const { data, meta } = payload;
98
+ if (!Array.isArray(data)) {
99
+ throw new Error(
100
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected 'data' to be an array.`
101
+ );
102
+ }
103
+ if (!isRecord(meta) || typeof meta.total !== "number") {
104
+ throw new Error(
105
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected 'meta.total' to be a number.`
106
+ );
107
+ }
108
+ return {
109
+ data: data.map((item, index) => {
110
+ if (!isRecord(item) || !isRecord(item.data)) {
111
+ throw new Error(
112
+ `Unexpected FDIC API response shape for endpoint ${endpoint}: expected data[${index}] to contain an object 'data' property.`
113
+ );
114
+ }
115
+ return { data: item.data };
116
+ }),
117
+ meta: { total: meta.total }
118
+ };
119
+ }
77
120
  async function queryEndpoint(endpoint, params, options = {}) {
78
121
  if (options.signal?.aborted) {
79
122
  throw new Error("FDIC API request was canceled before it started.");
@@ -101,7 +144,7 @@ async function queryEndpoint(endpoint, params, options = {}) {
101
144
  params: queryParams,
102
145
  signal: options.signal
103
146
  });
104
- return response.data;
147
+ return validateFdicResponseShape(endpoint, response.data);
105
148
  } catch (err) {
106
149
  if (options.signal?.aborted || typeof err === "object" && err !== null && "code" in err && err.code === "ERR_CANCELED" || err instanceof DOMException && err.name === "AbortError") {
107
150
  throw new Error("FDIC API request was canceled.");
@@ -133,6 +176,7 @@ async function queryEndpoint(endpoint, params, options = {}) {
133
176
  expiresAt: now + QUERY_CACHE_TTL_MS,
134
177
  value: requestPromise
135
178
  });
179
+ evictOverflowQueryCache();
136
180
  }
137
181
  try {
138
182
  return await requestPromise;
@@ -144,7 +188,14 @@ async function queryEndpoint(endpoint, params, options = {}) {
144
188
  }
145
189
  }
146
190
  function extractRecords(response) {
147
- return response.data.map((item) => item.data);
191
+ return response.data.map((item, index) => {
192
+ if (!isRecord(item) || !isRecord(item.data)) {
193
+ throw new Error(
194
+ `Unexpected FDIC API response shape: expected data[${index}] to contain an object 'data' property.`
195
+ );
196
+ }
197
+ return item.data;
198
+ });
148
199
  }
149
200
  function buildPaginationInfo(total, offset, count) {
150
201
  const has_more = total > offset + count;
@@ -156,6 +207,10 @@ function buildPaginationInfo(total, offset, count) {
156
207
  ...has_more ? { next_offset: offset + count } : {}
157
208
  };
158
209
  }
210
+ function buildTruncationWarning(label, total, count, guidance) {
211
+ if (total <= count) return void 0;
212
+ return `${label} truncated to ${count.toLocaleString()} records out of ${total.toLocaleString()} matched rows. ${guidance}`;
213
+ }
159
214
  function truncateIfNeeded(text, charLimit) {
160
215
  if (text.length <= charLimit) return text;
161
216
  return text.slice(0, charLimit) + `
@@ -1073,9 +1128,40 @@ Prefer concise human-readable summaries or tables when answering users. Structur
1073
1128
 
1074
1129
  // src/tools/analysis.ts
1075
1130
  var import_zod7 = require("zod");
1131
+
1132
+ // src/tools/shared/queryUtils.ts
1076
1133
  var CHUNK_SIZE = 25;
1077
1134
  var MAX_CONCURRENCY = 4;
1078
1135
  var ANALYSIS_TIMEOUT_MS = 9e4;
1136
+ function asNumber(value) {
1137
+ return typeof value === "number" ? value : null;
1138
+ }
1139
+ function buildCertFilters(certs) {
1140
+ const filters = [];
1141
+ for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
1142
+ const chunk = certs.slice(i, i + CHUNK_SIZE);
1143
+ filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1144
+ }
1145
+ return filters;
1146
+ }
1147
+ async function mapWithConcurrency(values, limit, mapper) {
1148
+ const results = new Array(values.length);
1149
+ let nextIndex = 0;
1150
+ async function worker() {
1151
+ while (true) {
1152
+ const currentIndex = nextIndex;
1153
+ nextIndex += 1;
1154
+ if (currentIndex >= values.length) return;
1155
+ results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1156
+ }
1157
+ }
1158
+ await Promise.all(
1159
+ Array.from({ length: Math.min(limit, values.length) }, () => worker())
1160
+ );
1161
+ return results;
1162
+ }
1163
+
1164
+ // src/tools/analysis.ts
1079
1165
  var SortFieldSchema = import_zod7.z.enum([
1080
1166
  "asset_growth",
1081
1167
  "asset_growth_pct",
@@ -1124,32 +1210,9 @@ var SnapshotAnalysisSchema = import_zod7.z.object({
1124
1210
  });
1125
1211
  }
1126
1212
  });
1127
- function asNumber(value) {
1128
- return typeof value === "number" ? value : null;
1129
- }
1130
- function buildCertFilters(certs) {
1131
- const filters = [];
1132
- for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
1133
- const chunk = certs.slice(i, i + CHUNK_SIZE);
1134
- filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1135
- }
1136
- return filters;
1137
- }
1138
- async function mapWithConcurrency(values, limit, mapper) {
1139
- const results = new Array(values.length);
1140
- let nextIndex = 0;
1141
- async function worker() {
1142
- while (true) {
1143
- const currentIndex = nextIndex;
1144
- nextIndex += 1;
1145
- if (currentIndex >= values.length) return;
1146
- results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1147
- }
1148
- }
1149
- await Promise.all(
1150
- Array.from({ length: Math.min(limit, values.length) }, () => worker())
1151
- );
1152
- return results;
1213
+ function maxOrNull(values) {
1214
+ const nonNullValues = values.filter((value) => value !== null);
1215
+ return nonNullValues.length > 0 ? Math.max(...nonNullValues) : null;
1153
1216
  }
1154
1217
  function ratio(numerator, denominator) {
1155
1218
  if (numerator === null || denominator === null || denominator === 0) {
@@ -1333,7 +1396,7 @@ function summarizeTimeSeries(records, demographicsByDate, institution) {
1333
1396
  const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
1334
1397
  const depositsToAssetsStart = ratio(depStart, assetStart);
1335
1398
  const depositsToAssetsEnd = ratio(depEnd, assetEnd);
1336
- const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
1399
+ const peakAsset = maxOrNull(assetSeries);
1337
1400
  const troughRoaValues = roaSeries.filter((value) => value !== null);
1338
1401
  const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
1339
1402
  const comparison = {
@@ -1456,29 +1519,46 @@ async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, field
1456
1519
  certFilter
1457
1520
  }))
1458
1521
  );
1459
- const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
1460
- const response = await queryEndpoint(endpoint, {
1461
- filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
1462
- fields,
1463
- limit: 1e4,
1464
- offset: 0,
1465
- sort_by: "CERT",
1466
- sort_order: "ASC"
1467
- }, { signal });
1468
- return { repdteFilter: task.repdteFilter, response };
1469
- });
1522
+ const responses = await mapWithConcurrency(
1523
+ tasks,
1524
+ MAX_CONCURRENCY,
1525
+ async (task) => {
1526
+ const response = await queryEndpoint(
1527
+ endpoint,
1528
+ {
1529
+ filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
1530
+ fields,
1531
+ limit: 1e4,
1532
+ offset: 0,
1533
+ sort_by: "CERT",
1534
+ sort_order: "ASC"
1535
+ },
1536
+ { signal }
1537
+ );
1538
+ return { repdteFilter: task.repdteFilter, response };
1539
+ }
1540
+ );
1470
1541
  const byDate = /* @__PURE__ */ new Map();
1542
+ const warnings = /* @__PURE__ */ new Set();
1471
1543
  for (const { repdteFilter, response } of responses) {
1472
1544
  if (!byDate.has(repdteFilter)) {
1473
1545
  byDate.set(repdteFilter, /* @__PURE__ */ new Map());
1474
1546
  }
1547
+ const records = extractRecords(response);
1548
+ const warning = buildTruncationWarning(
1549
+ `${endpoint} batch for ${repdteFilter}`,
1550
+ response.meta.total,
1551
+ records.length,
1552
+ "Narrow the comparison set with institution_filters or certs for complete analysis."
1553
+ );
1554
+ if (warning) warnings.add(warning);
1475
1555
  const target = byDate.get(repdteFilter);
1476
- for (const record of extractRecords(response)) {
1556
+ for (const record of records) {
1477
1557
  const cert = asNumber(record.CERT);
1478
1558
  if (cert !== null) target.set(cert, record);
1479
1559
  }
1480
1560
  }
1481
- return byDate;
1561
+ return { byDate, warnings: [...warnings] };
1482
1562
  }
1483
1563
  async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields, signal) {
1484
1564
  const certFilters = buildCertFilters(certs);
@@ -1495,15 +1575,24 @@ async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, field
1495
1575
  }, { signal })
1496
1576
  );
1497
1577
  const grouped = /* @__PURE__ */ new Map();
1578
+ const warnings = /* @__PURE__ */ new Set();
1498
1579
  for (const response of responses) {
1499
- for (const record of extractRecords(response)) {
1580
+ const records = extractRecords(response);
1581
+ const warning = buildTruncationWarning(
1582
+ `${endpoint} batch for REPDTE:[${startRepdte} TO ${endRepdte}]`,
1583
+ response.meta.total,
1584
+ records.length,
1585
+ "Narrow the comparison set with certs or a shorter date range for complete analysis."
1586
+ );
1587
+ if (warning) warnings.add(warning);
1588
+ for (const record of records) {
1500
1589
  const cert = asNumber(record.CERT);
1501
1590
  if (cert === null) continue;
1502
1591
  if (!grouped.has(cert)) grouped.set(cert, []);
1503
1592
  grouped.get(cert).push(record);
1504
1593
  }
1505
1594
  }
1506
- return grouped;
1595
+ return { grouped, warnings: [...warnings] };
1507
1596
  }
1508
1597
  function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
1509
1598
  const assetStart = asNumber(startFinancial.ASSET);
@@ -1660,8 +1749,9 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1660
1749
  )
1661
1750
  );
1662
1751
  let comparisons = [];
1752
+ const warnings = rosterResult.warning ? [rosterResult.warning] : [];
1663
1753
  if (analysis_mode === "timeseries") {
1664
- const [financialSeries, demographicsSeries] = await Promise.all([
1754
+ const [financialSeriesResult, demographicsSeriesResult] = await Promise.all([
1665
1755
  fetchSeriesRecords(
1666
1756
  ENDPOINTS.FINANCIALS,
1667
1757
  candidateCerts,
@@ -1677,8 +1767,17 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1677
1767
  end_repdte,
1678
1768
  "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1679
1769
  controller.signal
1680
- ) : Promise.resolve(/* @__PURE__ */ new Map())
1770
+ ) : Promise.resolve({
1771
+ grouped: /* @__PURE__ */ new Map(),
1772
+ warnings: []
1773
+ })
1681
1774
  ]);
1775
+ warnings.push(
1776
+ ...financialSeriesResult.warnings,
1777
+ ...demographicsSeriesResult.warnings
1778
+ );
1779
+ const financialSeries = financialSeriesResult.grouped;
1780
+ const demographicsSeries = demographicsSeriesResult.grouped;
1682
1781
  comparisons = candidateCerts.map(
1683
1782
  (cert) => summarizeTimeSeries(
1684
1783
  financialSeries.get(cert) ?? [],
@@ -1692,7 +1791,7 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1692
1791
  )
1693
1792
  ).filter((comparison) => comparison !== null);
1694
1793
  } else {
1695
- const [financialSnapshots, demographicSnapshots] = await Promise.all([
1794
+ const [financialSnapshotsResult, demographicSnapshotsResult] = await Promise.all([
1696
1795
  fetchBatchedRecordsForDates(
1697
1796
  ENDPOINTS.FINANCIALS,
1698
1797
  candidateCerts,
@@ -1706,8 +1805,17 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1706
1805
  [`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
1707
1806
  "CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME",
1708
1807
  controller.signal
1709
- ) : Promise.resolve(/* @__PURE__ */ new Map())
1808
+ ) : Promise.resolve({
1809
+ byDate: /* @__PURE__ */ new Map(),
1810
+ warnings: []
1811
+ })
1710
1812
  ]);
1813
+ warnings.push(
1814
+ ...financialSnapshotsResult.warnings,
1815
+ ...demographicSnapshotsResult.warnings
1816
+ );
1817
+ const financialSnapshots = financialSnapshotsResult.byDate;
1818
+ const demographicSnapshots = demographicSnapshotsResult.byDate;
1711
1819
  const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
1712
1820
  const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
1713
1821
  const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
@@ -1743,14 +1851,14 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1743
1851
  analysis_mode,
1744
1852
  sort_by,
1745
1853
  sort_order,
1746
- warnings: rosterResult.warning ? [rosterResult.warning] : [],
1854
+ warnings,
1747
1855
  insights: buildTopLevelInsights(sortedComparisons),
1748
1856
  ...pagination,
1749
1857
  comparisons: ranked
1750
1858
  };
1751
1859
  const text = truncateIfNeeded(
1752
1860
  [
1753
- rosterResult.warning ? `Warning: ${rosterResult.warning}` : null,
1861
+ ...warnings.map((warning) => `Warning: ${warning}`),
1754
1862
  formatComparisonText(output)
1755
1863
  ].filter((value) => value !== null).join("\n\n"),
1756
1864
  CHARACTER_LIMIT
@@ -1775,6 +1883,565 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1775
1883
  );
1776
1884
  }
1777
1885
 
1886
+ // src/tools/peerGroup.ts
1887
+ var import_zod8 = require("zod");
1888
+
1889
+ // src/tools/shared/financialMetrics.ts
1890
+ function safeRatio(numerator, denominator) {
1891
+ if (numerator === null || denominator === null || denominator === 0) {
1892
+ return null;
1893
+ }
1894
+ return numerator / denominator;
1895
+ }
1896
+ function safeRatioPositiveDenom(numerator, denominator) {
1897
+ if (numerator === null || denominator === null || denominator <= 0) {
1898
+ return null;
1899
+ }
1900
+ return numerator / denominator;
1901
+ }
1902
+ function deriveMetrics(raw) {
1903
+ const asset = asNumber(raw.ASSET);
1904
+ const dep = asNumber(raw.DEP);
1905
+ const eqtot = asNumber(raw.EQTOT);
1906
+ const lnlsnet = asNumber(raw.LNLSNET);
1907
+ const intinc = asNumber(raw.INTINC);
1908
+ const eintexp = asNumber(raw.EINTEXP);
1909
+ const nonii = asNumber(raw.NONII);
1910
+ const nonix = asNumber(raw.NONIX);
1911
+ const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
1912
+ const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
1913
+ const equityRatioRaw = safeRatio(eqtot, asset);
1914
+ const efficiencyRatioRaw = safeRatioPositiveDenom(nonix, revenueDenominator);
1915
+ return {
1916
+ asset,
1917
+ dep,
1918
+ roa: asNumber(raw.ROA),
1919
+ roe: asNumber(raw.ROE),
1920
+ netnim: asNumber(raw.NETNIM),
1921
+ equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
1922
+ efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
1923
+ loan_to_deposit: safeRatio(lnlsnet, dep),
1924
+ deposits_to_assets: safeRatio(dep, asset),
1925
+ noninterest_income_share: safeRatioPositiveDenom(nonii, revenueDenominator)
1926
+ };
1927
+ }
1928
+ function computeMedian(values) {
1929
+ if (values.length === 0) return null;
1930
+ const sorted = [...values].sort((a, b) => a - b);
1931
+ const mid = Math.floor(sorted.length / 2);
1932
+ if (sorted.length % 2 === 1) return sorted[mid];
1933
+ return (sorted[mid - 1] + sorted[mid]) / 2;
1934
+ }
1935
+
1936
+ // src/tools/peerGroup.ts
1937
+ function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
1938
+ if (peerValues.length === 0) return null;
1939
+ const ascending = higherIsBetter === false;
1940
+ const all = [...peerValues, subjectValue];
1941
+ const sorted = [...all].sort(
1942
+ (a, b) => ascending ? a - b : b - a
1943
+ );
1944
+ const ranks = /* @__PURE__ */ new Map();
1945
+ for (let i = 0; i < sorted.length; i++) {
1946
+ if (!ranks.has(sorted[i])) {
1947
+ ranks.set(sorted[i], i + 1);
1948
+ }
1949
+ }
1950
+ const rank = ranks.get(subjectValue);
1951
+ const of = all.length;
1952
+ const percentile = Math.round((1 - (rank - 1) / of) * 100);
1953
+ return { rank, of, percentile };
1954
+ }
1955
+ function formatRepdteHuman(repdte) {
1956
+ if (repdte.length !== 8) return repdte;
1957
+ const year = repdte.slice(0, 4);
1958
+ const month = repdte.slice(4, 6);
1959
+ const day = repdte.slice(6, 8);
1960
+ const date = /* @__PURE__ */ new Date(`${year}-${month}-${day}T00:00:00Z`);
1961
+ if (Number.isNaN(date.getTime())) return repdte;
1962
+ return date.toLocaleDateString("en-US", {
1963
+ year: "numeric",
1964
+ month: "long",
1965
+ day: "numeric",
1966
+ timeZone: "UTC"
1967
+ });
1968
+ }
1969
+ var METRIC_KEYS = [
1970
+ "asset",
1971
+ "dep",
1972
+ "roa",
1973
+ "roe",
1974
+ "netnim",
1975
+ "equity_ratio",
1976
+ "efficiency_ratio",
1977
+ "loan_to_deposit",
1978
+ "deposits_to_assets",
1979
+ "noninterest_income_share"
1980
+ ];
1981
+ var METRIC_DEFINITIONS = {
1982
+ asset: { higher_is_better: true, unit: "$thousands", label: "Total Assets" },
1983
+ dep: { higher_is_better: true, unit: "$thousands", label: "Total Deposits" },
1984
+ roa: { higher_is_better: true, unit: "%", label: "Return on Assets" },
1985
+ roe: { higher_is_better: true, unit: "%", label: "Return on Equity" },
1986
+ netnim: {
1987
+ higher_is_better: true,
1988
+ unit: "%",
1989
+ label: "Net Interest Margin"
1990
+ },
1991
+ equity_ratio: {
1992
+ higher_is_better: true,
1993
+ unit: "%",
1994
+ label: "Equity Capital Ratio"
1995
+ },
1996
+ efficiency_ratio: {
1997
+ higher_is_better: false,
1998
+ unit: "%",
1999
+ label: "Efficiency Ratio"
2000
+ },
2001
+ loan_to_deposit: {
2002
+ higher_is_better: null,
2003
+ unit: "ratio",
2004
+ label: "Loan-to-Deposit Ratio",
2005
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
2006
+ },
2007
+ deposits_to_assets: {
2008
+ higher_is_better: null,
2009
+ unit: "ratio",
2010
+ label: "Deposits-to-Assets Ratio",
2011
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
2012
+ },
2013
+ noninterest_income_share: {
2014
+ higher_is_better: true,
2015
+ unit: "ratio",
2016
+ label: "Non-Interest Income Share"
2017
+ }
2018
+ };
2019
+ var PeerGroupInputSchema = import_zod8.z.object({
2020
+ cert: import_zod8.z.number().int().positive().optional().describe(
2021
+ "Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers."
2022
+ ),
2023
+ repdte: import_zod8.z.string().regex(/^\d{8}$/).describe("Report date in YYYYMMDD format."),
2024
+ asset_min: import_zod8.z.number().positive().optional().describe(
2025
+ "Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided."
2026
+ ),
2027
+ asset_max: import_zod8.z.number().positive().optional().describe(
2028
+ "Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided."
2029
+ ),
2030
+ charter_classes: import_zod8.z.array(import_zod8.z.string()).optional().describe(
2031
+ `Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject's charter class when cert is provided.`
2032
+ ),
2033
+ state: import_zod8.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code (e.g., "NC", "TX").'),
2034
+ raw_filter: import_zod8.z.string().optional().describe(
2035
+ "Advanced: raw ElasticSearch query string appended to peer selection criteria with AND."
2036
+ ),
2037
+ active_only: import_zod8.z.boolean().default(true).describe(
2038
+ "Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured)."
2039
+ ),
2040
+ extra_fields: import_zod8.z.array(import_zod8.z.string()).optional().describe(
2041
+ "Additional FDIC field names to include as raw values in the response. Does not affect peer selection."
2042
+ ),
2043
+ limit: import_zod8.z.number().int().min(1).max(500).default(50).describe(
2044
+ "Max peer records returned in the response. All matched peers are used for ranking regardless of this limit."
2045
+ )
2046
+ }).superRefine((value, ctx) => {
2047
+ if (!value.cert && value.asset_min === void 0 && value.asset_max === void 0 && !value.charter_classes && !value.state && !value.raw_filter) {
2048
+ ctx.addIssue({
2049
+ code: import_zod8.z.ZodIssueCode.custom,
2050
+ message: "At least one peer-group constructor is required: cert, asset_min, asset_max, charter_classes, state, or raw_filter.",
2051
+ path: ["cert"]
2052
+ });
2053
+ }
2054
+ if (value.asset_min !== void 0 && value.asset_max !== void 0 && value.asset_min > value.asset_max) {
2055
+ ctx.addIssue({
2056
+ code: import_zod8.z.ZodIssueCode.custom,
2057
+ message: "asset_min must be <= asset_max.",
2058
+ path: ["asset_min"]
2059
+ });
2060
+ }
2061
+ });
2062
+ var FINANCIAL_FIELDS = "CERT,ASSET,DEP,NETINC,ROA,ROE,NETNIM,EQTOT,LNLSNET,INTINC,EINTEXP,NONII,NONIX";
2063
+ function formatMetricValue(key, value) {
2064
+ if (value === null) return "n/a";
2065
+ const def = METRIC_DEFINITIONS[key];
2066
+ if (def.unit === "$thousands")
2067
+ return `$${Math.round(value).toLocaleString()}k`;
2068
+ if (def.unit === "%") return `${value.toFixed(4)}%`;
2069
+ return value.toFixed(4);
2070
+ }
2071
+ function ordinalSuffix(n) {
2072
+ const mod100 = n % 100;
2073
+ if (mod100 >= 11 && mod100 <= 13) return `${n}th`;
2074
+ const mod10 = n % 10;
2075
+ if (mod10 === 1) return `${n}st`;
2076
+ if (mod10 === 2) return `${n}nd`;
2077
+ if (mod10 === 3) return `${n}rd`;
2078
+ return `${n}th`;
2079
+ }
2080
+ function formatPeerGroupText(repdte, subjectProfile, subjectMetrics, rankings, medians, returnedPeers, peerCount, warnings) {
2081
+ const parts = [];
2082
+ for (const warning of warnings) {
2083
+ parts.push(`Warning: ${warning}`);
2084
+ }
2085
+ const dateStr = formatRepdteHuman(repdte);
2086
+ const subjectLabel = subjectProfile ? ` for ${subjectProfile.NAME} (CERT ${subjectProfile.CERT ?? ""})` : "";
2087
+ parts.push(`Peer group analysis${subjectLabel} as of ${dateStr}.`);
2088
+ parts.push(`${peerCount} peers matched.`);
2089
+ if (subjectMetrics && subjectProfile) {
2090
+ parts.push("");
2091
+ parts.push("Subject rankings:");
2092
+ for (const key of METRIC_KEYS) {
2093
+ const def = METRIC_DEFINITIONS[key];
2094
+ const ranking = rankings[key];
2095
+ const value = formatMetricValue(key, subjectMetrics[key]);
2096
+ const medianValue = formatMetricValue(key, medians[key] ?? null);
2097
+ if (ranking) {
2098
+ const pctLabel = `${ordinalSuffix(ranking.percentile)} percentile`;
2099
+ parts.push(
2100
+ ` ${def.label.padEnd(28)} rank ${String(ranking.rank).padStart(2)} of ${String(ranking.of).padEnd(4)} (${pctLabel.padEnd(18)}) ${value.padStart(16)} median: ${medianValue}`
2101
+ );
2102
+ } else {
2103
+ parts.push(` ${def.label.padEnd(28)} n/a`);
2104
+ }
2105
+ }
2106
+ } else if (peerCount > 0) {
2107
+ parts.push("");
2108
+ parts.push("Peer group medians:");
2109
+ const medianParts = METRIC_KEYS.filter((k) => medians[k] !== null).map(
2110
+ (k) => `${METRIC_DEFINITIONS[k].label}: ${formatMetricValue(k, medians[k] ?? null)}`
2111
+ );
2112
+ parts.push(` ${medianParts.join(" | ")}`);
2113
+ }
2114
+ if (returnedPeers.length > 0) {
2115
+ parts.push("");
2116
+ parts.push(`Peers (${returnedPeers.length} returned):`);
2117
+ for (let i = 0; i < returnedPeers.length; i++) {
2118
+ const p = returnedPeers[i];
2119
+ const location = [p.city, p.stalp].filter(Boolean).join(" ");
2120
+ const locationStr = location ? `, ${location}` : "";
2121
+ parts.push(
2122
+ `${i + 1}. ${p.name}${locationStr} (CERT ${p.cert}) | Asset: ${formatMetricValue("asset", p.metrics.asset)} | ROA: ${formatMetricValue("roa", p.metrics.roa)} | ROE: ${formatMetricValue("roe", p.metrics.roe)}`
2123
+ );
2124
+ }
2125
+ }
2126
+ return parts.join("\n");
2127
+ }
2128
+ function registerPeerGroupTools(server) {
2129
+ server.registerTool(
2130
+ "fdic_peer_group_analysis",
2131
+ {
2132
+ title: "Peer Group Analysis",
2133
+ description: `Build a peer group for an FDIC-insured institution and rank it against peers on financial and efficiency metrics at a single report date.
2134
+
2135
+ Three usage modes:
2136
+ - Subject-driven: provide cert and repdte \u2014 auto-derives peer criteria from the subject's asset size and charter class
2137
+ - Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter
2138
+ - Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults
2139
+
2140
+ Metrics ranked (fixed order):
2141
+ - Total Assets, Total Deposits, ROA, ROE, Net Interest Margin
2142
+ - Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
2143
+ - Deposits-to-Assets Ratio, Non-Interest Income Share
2144
+
2145
+ Rankings use competition rank (1, 2, 2, 4). Rank, denominator, and percentile all use the same comparison set: matched peers plus the subject institution.
2146
+
2147
+ Output includes:
2148
+ - Subject rankings and percentiles (when cert provided)
2149
+ - Peer group medians
2150
+ - Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis)
2151
+ - Metric definitions with directionality metadata
2152
+
2153
+ Override precedence: cert derives defaults, then explicit params override them.`,
2154
+ inputSchema: PeerGroupInputSchema,
2155
+ annotations: {
2156
+ readOnlyHint: true,
2157
+ destructiveHint: false,
2158
+ idempotentHint: true,
2159
+ openWorldHint: true
2160
+ }
2161
+ },
2162
+ async (params) => {
2163
+ const controller = new AbortController();
2164
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
2165
+ try {
2166
+ const warnings = [];
2167
+ let subjectProfile = null;
2168
+ let subjectFinancials = null;
2169
+ if (params.cert) {
2170
+ const [profileResponse, financialsResponse] = await Promise.all([
2171
+ queryEndpoint(
2172
+ ENDPOINTS.INSTITUTIONS,
2173
+ {
2174
+ filters: `CERT:${params.cert}`,
2175
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2176
+ limit: 1
2177
+ },
2178
+ { signal: controller.signal }
2179
+ ),
2180
+ queryEndpoint(
2181
+ ENDPOINTS.FINANCIALS,
2182
+ {
2183
+ filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
2184
+ fields: FINANCIAL_FIELDS,
2185
+ limit: 1
2186
+ },
2187
+ { signal: controller.signal }
2188
+ )
2189
+ ]);
2190
+ const profileRecords = extractRecords(profileResponse);
2191
+ if (profileRecords.length === 0) {
2192
+ return formatToolError(
2193
+ new Error(
2194
+ `No institution found with CERT number ${params.cert}.`
2195
+ )
2196
+ );
2197
+ }
2198
+ subjectProfile = profileRecords[0];
2199
+ const financialRecords = extractRecords(financialsResponse);
2200
+ if (financialRecords.length === 0) {
2201
+ return formatToolError(
2202
+ new Error(
2203
+ `No financial data for CERT ${params.cert} at report date ${params.repdte}. Auto-derivation of peer criteria requires asset data at the specified report date.`
2204
+ )
2205
+ );
2206
+ }
2207
+ subjectFinancials = financialRecords[0];
2208
+ }
2209
+ const subjectAsset = subjectFinancials && typeof subjectFinancials.ASSET === "number" ? subjectFinancials.ASSET : null;
2210
+ const assetMin = params.asset_min ?? (subjectAsset !== null ? subjectAsset * 0.5 : void 0);
2211
+ const assetMax = params.asset_max ?? (subjectAsset !== null ? subjectAsset * 2 : void 0);
2212
+ const charterClasses = params.charter_classes ?? (subjectProfile && typeof subjectProfile.BKCLASS === "string" ? [subjectProfile.BKCLASS] : void 0);
2213
+ const { state, active_only, raw_filter } = params;
2214
+ const filterParts = [];
2215
+ if (assetMin !== void 0 || assetMax !== void 0) {
2216
+ const min = assetMin ?? 0;
2217
+ const max = assetMax ?? "*";
2218
+ filterParts.push(`ASSET:[${min} TO ${max}]`);
2219
+ }
2220
+ if (charterClasses && charterClasses.length > 0) {
2221
+ const classFilter = charterClasses.map((cls) => `BKCLASS:${cls}`).join(" OR ");
2222
+ filterParts.push(
2223
+ charterClasses.length > 1 ? `(${classFilter})` : classFilter
2224
+ );
2225
+ }
2226
+ if (state) filterParts.push(`STALP:${state}`);
2227
+ if (active_only) filterParts.push("ACTIVE:1");
2228
+ if (raw_filter) filterParts.push(`(${raw_filter})`);
2229
+ const rosterResponse = await queryEndpoint(
2230
+ ENDPOINTS.INSTITUTIONS,
2231
+ {
2232
+ filters: filterParts.join(" AND "),
2233
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2234
+ limit: 1e4,
2235
+ offset: 0,
2236
+ sort_by: "CERT",
2237
+ sort_order: "ASC"
2238
+ },
2239
+ { signal: controller.signal }
2240
+ );
2241
+ let rosterRecords = extractRecords(rosterResponse);
2242
+ if (rosterResponse.meta.total > rosterRecords.length) {
2243
+ warnings.push(
2244
+ `Institution roster truncated to ${rosterRecords.length.toLocaleString()} records out of ${rosterResponse.meta.total.toLocaleString()} matched institutions. Narrow the peer group criteria for complete analysis.`
2245
+ );
2246
+ }
2247
+ if (params.cert) {
2248
+ rosterRecords = rosterRecords.filter(
2249
+ (r) => asNumber(r.CERT) !== params.cert
2250
+ );
2251
+ }
2252
+ const criteriaUsed = {
2253
+ asset_min: assetMin ?? null,
2254
+ asset_max: assetMax ?? null,
2255
+ charter_classes: charterClasses ?? null,
2256
+ state: state ?? null,
2257
+ active_only,
2258
+ raw_filter: raw_filter ?? null
2259
+ };
2260
+ if (rosterRecords.length === 0) {
2261
+ const subjectMetrics2 = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2262
+ const output2 = {};
2263
+ if (subjectProfile) {
2264
+ output2.subject = {
2265
+ cert: params.cert,
2266
+ name: subjectProfile.NAME,
2267
+ city: subjectProfile.CITY,
2268
+ stalp: subjectProfile.STALP,
2269
+ bkclass: subjectProfile.BKCLASS,
2270
+ metrics: subjectMetrics2,
2271
+ rankings: null
2272
+ };
2273
+ }
2274
+ output2.peer_group = {
2275
+ repdte: params.repdte,
2276
+ criteria_used: criteriaUsed,
2277
+ medians: {}
2278
+ };
2279
+ output2.metric_definitions = METRIC_DEFINITIONS;
2280
+ output2.peers = [];
2281
+ output2.peer_count = 0;
2282
+ output2.returned_count = 0;
2283
+ output2.has_more = false;
2284
+ output2.message = "No peers matched the specified criteria.";
2285
+ output2.warnings = warnings;
2286
+ const text2 = formatPeerGroupText(
2287
+ params.repdte,
2288
+ subjectProfile,
2289
+ subjectMetrics2,
2290
+ {},
2291
+ {},
2292
+ [],
2293
+ 0,
2294
+ warnings
2295
+ );
2296
+ return {
2297
+ content: [{ type: "text", text: text2 }],
2298
+ structuredContent: output2
2299
+ };
2300
+ }
2301
+ const peerCerts = rosterRecords.map((r) => asNumber(r.CERT)).filter((c) => c !== null);
2302
+ const certFilters = buildCertFilters(peerCerts);
2303
+ const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
2304
+ const financialResponses = await mapWithConcurrency(
2305
+ certFilters,
2306
+ MAX_CONCURRENCY,
2307
+ async (certFilter) => queryEndpoint(
2308
+ ENDPOINTS.FINANCIALS,
2309
+ {
2310
+ filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
2311
+ fields: FINANCIAL_FIELDS + extraFieldsCsv,
2312
+ limit: 1e4,
2313
+ offset: 0,
2314
+ sort_by: "CERT",
2315
+ sort_order: "ASC"
2316
+ },
2317
+ { signal: controller.signal }
2318
+ )
2319
+ );
2320
+ const peerFinancialsByCert = /* @__PURE__ */ new Map();
2321
+ for (const response of financialResponses) {
2322
+ const records = extractRecords(response);
2323
+ const warning = buildTruncationWarning(
2324
+ `financials batch for REPDTE:${params.repdte}`,
2325
+ response.meta.total,
2326
+ records.length,
2327
+ "Narrow the peer group criteria for complete analysis."
2328
+ );
2329
+ if (warning && !warnings.includes(warning)) warnings.push(warning);
2330
+ for (const record of records) {
2331
+ const cert = asNumber(record.CERT);
2332
+ if (cert !== null) peerFinancialsByCert.set(cert, record);
2333
+ }
2334
+ }
2335
+ const rosterByCert = new Map(
2336
+ rosterRecords.map((r) => [asNumber(r.CERT), r]).filter(
2337
+ (e) => e[0] !== null
2338
+ )
2339
+ );
2340
+ const peers = [];
2341
+ for (const [cert, financials] of peerFinancialsByCert) {
2342
+ const roster = rosterByCert.get(cert);
2343
+ const metrics = deriveMetrics(financials);
2344
+ const extraFields = {};
2345
+ if (params.extra_fields) {
2346
+ for (const field of params.extra_fields) {
2347
+ extraFields[field] = financials[field] ?? null;
2348
+ }
2349
+ }
2350
+ peers.push({
2351
+ cert,
2352
+ name: String(roster?.NAME ?? financials.NAME ?? cert),
2353
+ city: roster?.CITY != null ? String(roster.CITY) : null,
2354
+ stalp: roster?.STALP != null ? String(roster.STALP) : null,
2355
+ bkclass: roster?.BKCLASS != null ? String(roster.BKCLASS) : null,
2356
+ metrics,
2357
+ extraFields
2358
+ });
2359
+ }
2360
+ const peerCount = peers.length;
2361
+ const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2362
+ const rankings = {};
2363
+ const medians = {};
2364
+ for (const key of METRIC_KEYS) {
2365
+ const peerValues = peers.map((p) => p.metrics[key]).filter((v) => v !== null);
2366
+ medians[key] = computeMedian(peerValues);
2367
+ if (subjectMetrics && subjectMetrics[key] !== null) {
2368
+ rankings[key] = computeCompetitionRank(
2369
+ subjectMetrics[key],
2370
+ peerValues,
2371
+ METRIC_DEFINITIONS[key].higher_is_better
2372
+ );
2373
+ } else {
2374
+ rankings[key] = null;
2375
+ }
2376
+ }
2377
+ peers.sort((a, b) => (b.metrics.asset ?? 0) - (a.metrics.asset ?? 0));
2378
+ const returnedPeers = peers.slice(0, params.limit);
2379
+ const returnedCount = returnedPeers.length;
2380
+ const hasMore = peerCount > returnedCount;
2381
+ const output = {};
2382
+ if (subjectProfile && subjectMetrics) {
2383
+ output.subject = {
2384
+ cert: params.cert,
2385
+ name: subjectProfile.NAME,
2386
+ city: subjectProfile.CITY,
2387
+ stalp: subjectProfile.STALP,
2388
+ bkclass: subjectProfile.BKCLASS,
2389
+ metrics: subjectMetrics,
2390
+ rankings
2391
+ };
2392
+ }
2393
+ output.peer_group = {
2394
+ repdte: params.repdte,
2395
+ criteria_used: criteriaUsed,
2396
+ medians
2397
+ };
2398
+ output.metric_definitions = METRIC_DEFINITIONS;
2399
+ output.peers = returnedPeers.map((p) => ({
2400
+ cert: p.cert,
2401
+ name: p.name,
2402
+ city: p.city,
2403
+ stalp: p.stalp,
2404
+ metrics: p.metrics,
2405
+ ...p.extraFields
2406
+ }));
2407
+ output.peer_count = peerCount;
2408
+ output.returned_count = returnedCount;
2409
+ output.has_more = hasMore;
2410
+ output.message = null;
2411
+ output.warnings = warnings;
2412
+ const text = truncateIfNeeded(
2413
+ formatPeerGroupText(
2414
+ params.repdte,
2415
+ subjectProfile,
2416
+ subjectMetrics,
2417
+ rankings,
2418
+ medians,
2419
+ returnedPeers,
2420
+ peerCount,
2421
+ warnings
2422
+ ),
2423
+ CHARACTER_LIMIT
2424
+ );
2425
+ return {
2426
+ content: [{ type: "text", text }],
2427
+ structuredContent: output
2428
+ };
2429
+ } catch (err) {
2430
+ if (controller.signal.aborted) {
2431
+ return formatToolError(
2432
+ new Error(
2433
+ `Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the peer group criteria and try again.`
2434
+ )
2435
+ );
2436
+ }
2437
+ return formatToolError(err);
2438
+ } finally {
2439
+ clearTimeout(timeoutId);
2440
+ }
2441
+ }
2442
+ );
2443
+ }
2444
+
1778
2445
  // src/index.ts
1779
2446
  function createServer() {
1780
2447
  const server = new import_mcp.McpServer({
@@ -1789,6 +2456,7 @@ function createServer() {
1789
2456
  registerSodTools(server);
1790
2457
  registerDemographicsTools(server);
1791
2458
  registerAnalysisTools(server);
2459
+ registerPeerGroupTools(server);
1792
2460
  return server;
1793
2461
  }
1794
2462
  async function runStdio() {
@@ -1797,6 +2465,16 @@ async function runStdio() {
1797
2465
  await server.connect(transport);
1798
2466
  console.error("FDIC BankFind MCP server running on stdio");
1799
2467
  }
2468
+ function parseHttpPort(rawPort) {
2469
+ const port = Number.parseInt(rawPort ?? "3000", 10);
2470
+ if (Number.isNaN(port)) {
2471
+ throw new Error(`Invalid PORT value: ${rawPort ?? ""}`);
2472
+ }
2473
+ if (port < 0 || port > 65535) {
2474
+ throw new Error(`PORT must be between 0 and 65535. Received: ${port}`);
2475
+ }
2476
+ return port;
2477
+ }
1800
2478
  function createApp() {
1801
2479
  const app = (0, import_express.default)();
1802
2480
  app.use(import_express.default.json());
@@ -1840,7 +2518,7 @@ function createApp() {
1840
2518
  }
1841
2519
  async function runHTTP() {
1842
2520
  const app = createApp();
1843
- const port = parseInt(process.env.PORT ?? "3000");
2521
+ const port = parseHttpPort(process.env.PORT);
1844
2522
  app.listen(port, () => {
1845
2523
  console.error(
1846
2524
  `FDIC BankFind MCP server running on http://localhost:${port}/mcp`