fdic-mcp-server 1.1.0 → 1.1.2
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 +129 -294
- package/dist/index.js +207 -107
- package/dist/server.js +211 -109
- package/package.json +3 -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.1.
|
|
33
|
+
var VERSION = true ? "1.1.2" : 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
|
|
@@ -1777,9 +1885,8 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
|
|
|
1777
1885
|
|
|
1778
1886
|
// src/tools/peerGroup.ts
|
|
1779
1887
|
var import_zod8 = require("zod");
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
}
|
|
1888
|
+
|
|
1889
|
+
// src/tools/shared/financialMetrics.ts
|
|
1783
1890
|
function safeRatio(numerator, denominator) {
|
|
1784
1891
|
if (numerator === null || denominator === null || denominator === 0) {
|
|
1785
1892
|
return null;
|
|
@@ -1793,14 +1900,14 @@ function safeRatioPositiveDenom(numerator, denominator) {
|
|
|
1793
1900
|
return numerator / denominator;
|
|
1794
1901
|
}
|
|
1795
1902
|
function deriveMetrics(raw) {
|
|
1796
|
-
const asset =
|
|
1797
|
-
const dep =
|
|
1798
|
-
const eqtot =
|
|
1799
|
-
const lnlsnet =
|
|
1800
|
-
const intinc =
|
|
1801
|
-
const eintexp =
|
|
1802
|
-
const nonii =
|
|
1803
|
-
const nonix =
|
|
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);
|
|
1804
1911
|
const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
|
|
1805
1912
|
const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
|
|
1806
1913
|
const equityRatioRaw = safeRatio(eqtot, asset);
|
|
@@ -1808,9 +1915,9 @@ function deriveMetrics(raw) {
|
|
|
1808
1915
|
return {
|
|
1809
1916
|
asset,
|
|
1810
1917
|
dep,
|
|
1811
|
-
roa:
|
|
1812
|
-
roe:
|
|
1813
|
-
netnim:
|
|
1918
|
+
roa: asNumber(raw.ROA),
|
|
1919
|
+
roe: asNumber(raw.ROE),
|
|
1920
|
+
netnim: asNumber(raw.NETNIM),
|
|
1814
1921
|
equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
|
|
1815
1922
|
efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
|
|
1816
1923
|
loan_to_deposit: safeRatio(lnlsnet, dep),
|
|
@@ -1825,6 +1932,8 @@ function computeMedian(values) {
|
|
|
1825
1932
|
if (sorted.length % 2 === 1) return sorted[mid];
|
|
1826
1933
|
return (sorted[mid - 1] + sorted[mid]) / 2;
|
|
1827
1934
|
}
|
|
1935
|
+
|
|
1936
|
+
// src/tools/peerGroup.ts
|
|
1828
1937
|
function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
|
|
1829
1938
|
if (peerValues.length === 0) return null;
|
|
1830
1939
|
const ascending = higherIsBetter === false;
|
|
@@ -1839,7 +1948,7 @@ function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
|
|
|
1839
1948
|
}
|
|
1840
1949
|
}
|
|
1841
1950
|
const rank = ranks.get(subjectValue);
|
|
1842
|
-
const of =
|
|
1951
|
+
const of = all.length;
|
|
1843
1952
|
const percentile = Math.round((1 - (rank - 1) / of) * 100);
|
|
1844
1953
|
return { rank, of, percentile };
|
|
1845
1954
|
}
|
|
@@ -1950,34 +2059,7 @@ var PeerGroupInputSchema = import_zod8.z.object({
|
|
|
1950
2059
|
});
|
|
1951
2060
|
}
|
|
1952
2061
|
});
|
|
1953
|
-
var CHUNK_SIZE2 = 25;
|
|
1954
|
-
var MAX_CONCURRENCY2 = 4;
|
|
1955
|
-
var ANALYSIS_TIMEOUT_MS2 = 9e4;
|
|
1956
2062
|
var FINANCIAL_FIELDS = "CERT,ASSET,DEP,NETINC,ROA,ROE,NETNIM,EQTOT,LNLSNET,INTINC,EINTEXP,NONII,NONIX";
|
|
1957
|
-
function buildCertFilters2(certs) {
|
|
1958
|
-
const filters = [];
|
|
1959
|
-
for (let i = 0; i < certs.length; i += CHUNK_SIZE2) {
|
|
1960
|
-
const chunk = certs.slice(i, i + CHUNK_SIZE2);
|
|
1961
|
-
filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
|
|
1962
|
-
}
|
|
1963
|
-
return filters;
|
|
1964
|
-
}
|
|
1965
|
-
async function mapWithConcurrency2(values, limit, mapper) {
|
|
1966
|
-
const results = new Array(values.length);
|
|
1967
|
-
let nextIndex = 0;
|
|
1968
|
-
async function worker() {
|
|
1969
|
-
while (true) {
|
|
1970
|
-
const currentIndex = nextIndex;
|
|
1971
|
-
nextIndex += 1;
|
|
1972
|
-
if (currentIndex >= values.length) return;
|
|
1973
|
-
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
|
|
1974
|
-
}
|
|
1975
|
-
}
|
|
1976
|
-
await Promise.all(
|
|
1977
|
-
Array.from({ length: Math.min(limit, values.length) }, () => worker())
|
|
1978
|
-
);
|
|
1979
|
-
return results;
|
|
1980
|
-
}
|
|
1981
2063
|
function formatMetricValue(key, value) {
|
|
1982
2064
|
if (value === null) return "n/a";
|
|
1983
2065
|
const def = METRIC_DEFINITIONS[key];
|
|
@@ -2060,7 +2142,7 @@ Metrics ranked (fixed order):
|
|
|
2060
2142
|
- Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
|
|
2061
2143
|
- Deposits-to-Assets Ratio, Non-Interest Income Share
|
|
2062
2144
|
|
|
2063
|
-
Rankings use competition rank (1, 2, 2, 4)
|
|
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.
|
|
2064
2146
|
|
|
2065
2147
|
Output includes:
|
|
2066
2148
|
- Subject rankings and percentiles (when cert provided)
|
|
@@ -2079,7 +2161,7 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2079
2161
|
},
|
|
2080
2162
|
async (params) => {
|
|
2081
2163
|
const controller = new AbortController();
|
|
2082
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
2164
|
+
const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS);
|
|
2083
2165
|
try {
|
|
2084
2166
|
const warnings = [];
|
|
2085
2167
|
let subjectProfile = null;
|
|
@@ -2164,7 +2246,7 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2164
2246
|
}
|
|
2165
2247
|
if (params.cert) {
|
|
2166
2248
|
rosterRecords = rosterRecords.filter(
|
|
2167
|
-
(r) =>
|
|
2249
|
+
(r) => asNumber(r.CERT) !== params.cert
|
|
2168
2250
|
);
|
|
2169
2251
|
}
|
|
2170
2252
|
const criteriaUsed = {
|
|
@@ -2216,12 +2298,12 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2216
2298
|
structuredContent: output2
|
|
2217
2299
|
};
|
|
2218
2300
|
}
|
|
2219
|
-
const peerCerts = rosterRecords.map((r) =>
|
|
2220
|
-
const certFilters =
|
|
2301
|
+
const peerCerts = rosterRecords.map((r) => asNumber(r.CERT)).filter((c) => c !== null);
|
|
2302
|
+
const certFilters = buildCertFilters(peerCerts);
|
|
2221
2303
|
const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
|
|
2222
|
-
const financialResponses = await
|
|
2304
|
+
const financialResponses = await mapWithConcurrency(
|
|
2223
2305
|
certFilters,
|
|
2224
|
-
|
|
2306
|
+
MAX_CONCURRENCY,
|
|
2225
2307
|
async (certFilter) => queryEndpoint(
|
|
2226
2308
|
ENDPOINTS.FINANCIALS,
|
|
2227
2309
|
{
|
|
@@ -2237,13 +2319,21 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2237
2319
|
);
|
|
2238
2320
|
const peerFinancialsByCert = /* @__PURE__ */ new Map();
|
|
2239
2321
|
for (const response of financialResponses) {
|
|
2240
|
-
|
|
2241
|
-
|
|
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);
|
|
2242
2332
|
if (cert !== null) peerFinancialsByCert.set(cert, record);
|
|
2243
2333
|
}
|
|
2244
2334
|
}
|
|
2245
2335
|
const rosterByCert = new Map(
|
|
2246
|
-
rosterRecords.map((r) => [
|
|
2336
|
+
rosterRecords.map((r) => [asNumber(r.CERT), r]).filter(
|
|
2247
2337
|
(e) => e[0] !== null
|
|
2248
2338
|
)
|
|
2249
2339
|
);
|
|
@@ -2340,7 +2430,7 @@ Override precedence: cert derives defaults, then explicit params override them.`
|
|
|
2340
2430
|
if (controller.signal.aborted) {
|
|
2341
2431
|
return formatToolError(
|
|
2342
2432
|
new Error(
|
|
2343
|
-
`Peer group analysis timed out after ${Math.floor(
|
|
2433
|
+
`Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS / 1e3)} seconds. Narrow the peer group criteria and try again.`
|
|
2344
2434
|
)
|
|
2345
2435
|
);
|
|
2346
2436
|
}
|
|
@@ -2375,6 +2465,16 @@ async function runStdio() {
|
|
|
2375
2465
|
await server.connect(transport);
|
|
2376
2466
|
console.error("FDIC BankFind MCP server running on stdio");
|
|
2377
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
|
+
}
|
|
2378
2478
|
function createApp() {
|
|
2379
2479
|
const app = (0, import_express.default)();
|
|
2380
2480
|
app.use(import_express.default.json());
|
|
@@ -2418,7 +2518,7 @@ function createApp() {
|
|
|
2418
2518
|
}
|
|
2419
2519
|
async function runHTTP() {
|
|
2420
2520
|
const app = createApp();
|
|
2421
|
-
const port =
|
|
2521
|
+
const port = parseHttpPort(process.env.PORT);
|
|
2422
2522
|
app.listen(port, () => {
|
|
2423
2523
|
console.error(
|
|
2424
2524
|
`FDIC BankFind MCP server running on http://localhost:${port}/mcp`
|