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.
- package/README.md +154 -185
- package/dist/index.js +732 -54
- package/dist/server.js +736 -56
- 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.
|
|
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
|
|
62
|
-
|
|
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) =>
|
|
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
|
|
1128
|
-
|
|
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 =
|
|
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(
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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
|
|
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
|
-
|
|
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 [
|
|
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(
|
|
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 [
|
|
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(
|
|
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
|
|
1854
|
+
warnings,
|
|
1747
1855
|
insights: buildTopLevelInsights(sortedComparisons),
|
|
1748
1856
|
...pagination,
|
|
1749
1857
|
comparisons: ranked
|
|
1750
1858
|
};
|
|
1751
1859
|
const text = truncateIfNeeded(
|
|
1752
1860
|
[
|
|
1753
|
-
|
|
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 =
|
|
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`
|