fdic-mcp-server 1.0.6 → 1.0.7
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 +52 -1
- package/dist/index.js +709 -34
- package/dist/server.js +709 -34
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -41,7 +41,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
|
|
|
41
41
|
var import_express = __toESM(require("express"));
|
|
42
42
|
|
|
43
43
|
// src/constants.ts
|
|
44
|
-
var VERSION = true ? "1.0.
|
|
44
|
+
var VERSION = true ? "1.0.7" : process.env.npm_package_version ?? "0.0.0-dev";
|
|
45
45
|
var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
|
|
46
46
|
var CHARACTER_LIMIT = 5e4;
|
|
47
47
|
var ENDPOINTS = {
|
|
@@ -65,42 +65,73 @@ var apiClient = import_axios.default.create({
|
|
|
65
65
|
"User-Agent": `fdic-mcp-server/${VERSION}`
|
|
66
66
|
}
|
|
67
67
|
});
|
|
68
|
+
var QUERY_CACHE_TTL_MS = 6e4;
|
|
69
|
+
var queryCache = /* @__PURE__ */ new Map();
|
|
70
|
+
function getCacheKey(endpoint, params) {
|
|
71
|
+
return JSON.stringify([
|
|
72
|
+
endpoint,
|
|
73
|
+
params.filters ?? null,
|
|
74
|
+
params.fields ?? null,
|
|
75
|
+
params.limit ?? null,
|
|
76
|
+
params.offset ?? null,
|
|
77
|
+
params.sort_by ?? null,
|
|
78
|
+
params.sort_order ?? null
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
68
81
|
async function queryEndpoint(endpoint, params) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
)
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
)
|
|
99
|
-
|
|
100
|
-
|
|
82
|
+
const cacheKey = getCacheKey(endpoint, params);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const cached = queryCache.get(cacheKey);
|
|
85
|
+
if (cached && cached.expiresAt > now) {
|
|
86
|
+
return cached.value;
|
|
87
|
+
}
|
|
88
|
+
const requestPromise = (async () => {
|
|
89
|
+
try {
|
|
90
|
+
const queryParams = {
|
|
91
|
+
limit: params.limit ?? 20,
|
|
92
|
+
offset: params.offset ?? 0,
|
|
93
|
+
output: "json"
|
|
94
|
+
};
|
|
95
|
+
if (params.filters) queryParams.filters = params.filters;
|
|
96
|
+
if (params.fields) queryParams.fields = params.fields;
|
|
97
|
+
if (params.sort_by) queryParams.sort_by = params.sort_by;
|
|
98
|
+
if (params.sort_order) queryParams.sort_order = params.sort_order;
|
|
99
|
+
const response = await apiClient.get(`/${endpoint}`, {
|
|
100
|
+
params: queryParams
|
|
101
|
+
});
|
|
102
|
+
return response.data;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err instanceof import_axios.AxiosError) {
|
|
105
|
+
const status = err.response?.status;
|
|
106
|
+
const detail = err.response?.data?.message ?? err.message;
|
|
107
|
+
if (status === 400) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
|
|
110
|
+
);
|
|
111
|
+
} else if (status === 429) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"FDIC API rate limit exceeded. Please wait a moment and try again."
|
|
114
|
+
);
|
|
115
|
+
} else if (status === 500) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
"FDIC API server error. The service may be temporarily unavailable. Try again later."
|
|
118
|
+
);
|
|
119
|
+
} else {
|
|
120
|
+
throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
|
|
121
|
+
}
|
|
101
122
|
}
|
|
123
|
+
throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
|
|
102
124
|
}
|
|
103
|
-
|
|
125
|
+
})();
|
|
126
|
+
queryCache.set(cacheKey, {
|
|
127
|
+
expiresAt: now + QUERY_CACHE_TTL_MS,
|
|
128
|
+
value: requestPromise
|
|
129
|
+
});
|
|
130
|
+
try {
|
|
131
|
+
return await requestPromise;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
queryCache.delete(cacheKey);
|
|
134
|
+
throw error;
|
|
104
135
|
}
|
|
105
136
|
}
|
|
106
137
|
function extractRecords(response) {
|
|
@@ -1031,6 +1062,649 @@ Prefer concise human-readable summaries or tables when answering users. Structur
|
|
|
1031
1062
|
);
|
|
1032
1063
|
}
|
|
1033
1064
|
|
|
1065
|
+
// src/tools/analysis.ts
|
|
1066
|
+
var import_zod7 = require("zod");
|
|
1067
|
+
var CHUNK_SIZE = 25;
|
|
1068
|
+
var MAX_CONCURRENCY = 4;
|
|
1069
|
+
var SortFieldSchema = import_zod7.z.enum([
|
|
1070
|
+
"asset_growth",
|
|
1071
|
+
"asset_growth_pct",
|
|
1072
|
+
"dep_growth",
|
|
1073
|
+
"dep_growth_pct",
|
|
1074
|
+
"netinc_change",
|
|
1075
|
+
"netinc_change_pct",
|
|
1076
|
+
"roa_change",
|
|
1077
|
+
"roe_change",
|
|
1078
|
+
"offices_change",
|
|
1079
|
+
"assets_per_office_change",
|
|
1080
|
+
"deposits_per_office_change",
|
|
1081
|
+
"deposits_to_assets_change"
|
|
1082
|
+
]);
|
|
1083
|
+
var AnalysisModeSchema = import_zod7.z.enum(["snapshot", "timeseries"]);
|
|
1084
|
+
var SnapshotAnalysisSchema = import_zod7.z.object({
|
|
1085
|
+
state: import_zod7.z.string().optional().describe(
|
|
1086
|
+
'State name for the institution roster filter. Example: "North Carolina"'
|
|
1087
|
+
),
|
|
1088
|
+
certs: import_zod7.z.array(import_zod7.z.number().int().positive()).max(100).optional().describe(
|
|
1089
|
+
"Optional list of FDIC certificate numbers to compare directly. Max 100."
|
|
1090
|
+
),
|
|
1091
|
+
institution_filters: import_zod7.z.string().optional().describe(
|
|
1092
|
+
'Additional institution-level filter used when building the comparison set. Example: BKCLASS:N or CITY:"Charlotte"'
|
|
1093
|
+
),
|
|
1094
|
+
active_only: import_zod7.z.boolean().default(true).describe("Limit the comparison set to currently active institutions."),
|
|
1095
|
+
start_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Starting report date in YYYYMMDD format."),
|
|
1096
|
+
end_repdte: import_zod7.z.string().regex(/^\d{8}$/).describe("Ending report date in YYYYMMDD format."),
|
|
1097
|
+
analysis_mode: AnalysisModeSchema.default("snapshot").describe(
|
|
1098
|
+
"Use snapshot for two-point comparison or timeseries for quarterly trend analysis across the date range."
|
|
1099
|
+
),
|
|
1100
|
+
include_demographics: import_zod7.z.boolean().default(true).describe(
|
|
1101
|
+
"Include office-count changes from the demographics dataset when available."
|
|
1102
|
+
),
|
|
1103
|
+
limit: import_zod7.z.number().int().min(1).max(100).default(10).describe("Maximum number of ranked comparisons to return."),
|
|
1104
|
+
sort_by: SortFieldSchema.default("asset_growth").describe(
|
|
1105
|
+
"Comparison field used to rank institutions."
|
|
1106
|
+
),
|
|
1107
|
+
sort_order: import_zod7.z.enum(["ASC", "DESC"]).default("DESC").describe("Sort direction for the ranked comparisons.")
|
|
1108
|
+
}).superRefine((value, ctx) => {
|
|
1109
|
+
if (!value.state && (!value.certs || value.certs.length === 0)) {
|
|
1110
|
+
ctx.addIssue({
|
|
1111
|
+
code: import_zod7.z.ZodIssueCode.custom,
|
|
1112
|
+
message: "Provide either state or certs.",
|
|
1113
|
+
path: ["state"]
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
});
|
|
1117
|
+
function asNumber(value) {
|
|
1118
|
+
return typeof value === "number" ? value : null;
|
|
1119
|
+
}
|
|
1120
|
+
function buildCertFilters(certs) {
|
|
1121
|
+
const filters = [];
|
|
1122
|
+
for (let i = 0; i < certs.length; i += CHUNK_SIZE) {
|
|
1123
|
+
const chunk = certs.slice(i, i + CHUNK_SIZE);
|
|
1124
|
+
filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
|
|
1125
|
+
}
|
|
1126
|
+
return filters;
|
|
1127
|
+
}
|
|
1128
|
+
async function mapWithConcurrency(values, limit, mapper) {
|
|
1129
|
+
const results = new Array(values.length);
|
|
1130
|
+
let nextIndex = 0;
|
|
1131
|
+
async function worker() {
|
|
1132
|
+
while (true) {
|
|
1133
|
+
const currentIndex = nextIndex;
|
|
1134
|
+
nextIndex += 1;
|
|
1135
|
+
if (currentIndex >= values.length) return;
|
|
1136
|
+
results[currentIndex] = await mapper(values[currentIndex], currentIndex);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
await Promise.all(
|
|
1140
|
+
Array.from({ length: Math.min(limit, values.length) }, () => worker())
|
|
1141
|
+
);
|
|
1142
|
+
return results;
|
|
1143
|
+
}
|
|
1144
|
+
function ratio(numerator, denominator) {
|
|
1145
|
+
if (numerator === null || denominator === null || denominator === 0) {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
return numerator / denominator;
|
|
1149
|
+
}
|
|
1150
|
+
function pctChange(start, end) {
|
|
1151
|
+
if (start === null || end === null || start === 0) return null;
|
|
1152
|
+
return (end - start) / start * 100;
|
|
1153
|
+
}
|
|
1154
|
+
function change(start, end) {
|
|
1155
|
+
if (start === null || end === null) return null;
|
|
1156
|
+
return end - start;
|
|
1157
|
+
}
|
|
1158
|
+
function yearsBetween(startRepdte, endRepdte) {
|
|
1159
|
+
const start = /* @__PURE__ */ new Date(
|
|
1160
|
+
`${startRepdte.slice(0, 4)}-${startRepdte.slice(4, 6)}-${startRepdte.slice(6, 8)}`
|
|
1161
|
+
);
|
|
1162
|
+
const end = /* @__PURE__ */ new Date(
|
|
1163
|
+
`${endRepdte.slice(0, 4)}-${endRepdte.slice(4, 6)}-${endRepdte.slice(6, 8)}`
|
|
1164
|
+
);
|
|
1165
|
+
return Math.max((end.getTime() - start.getTime()) / (365.25 * 24 * 60 * 60 * 1e3), 0);
|
|
1166
|
+
}
|
|
1167
|
+
function cagr(start, end, years) {
|
|
1168
|
+
if (start === null || end === null || start <= 0 || end <= 0 || years <= 0) {
|
|
1169
|
+
return null;
|
|
1170
|
+
}
|
|
1171
|
+
return (Math.pow(end / start, 1 / years) - 1) * 100;
|
|
1172
|
+
}
|
|
1173
|
+
function formatPercent(value) {
|
|
1174
|
+
return value === null ? "n/a" : `${value.toFixed(1)}%`;
|
|
1175
|
+
}
|
|
1176
|
+
function formatChange(value, digits = 4) {
|
|
1177
|
+
if (value === null) return "n/a";
|
|
1178
|
+
return value.toFixed(digits);
|
|
1179
|
+
}
|
|
1180
|
+
function formatInteger(value) {
|
|
1181
|
+
return value === null ? "n/a" : `${Math.round(value).toLocaleString()}`;
|
|
1182
|
+
}
|
|
1183
|
+
function sortComparisons(comparisons, sortBy, sortOrder) {
|
|
1184
|
+
const direction = sortOrder === "ASC" ? 1 : -1;
|
|
1185
|
+
return [...comparisons].sort((left, right) => {
|
|
1186
|
+
const leftValue = asNumber(left[sortBy]) ?? Number.NEGATIVE_INFINITY;
|
|
1187
|
+
const rightValue = asNumber(right[sortBy]) ?? Number.NEGATIVE_INFINITY;
|
|
1188
|
+
return (leftValue - rightValue) * direction;
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
function classifyInsights(comparison) {
|
|
1192
|
+
const insights = [];
|
|
1193
|
+
const assetGrowthPct = asNumber(comparison.asset_growth_pct);
|
|
1194
|
+
const depGrowthPct = asNumber(comparison.dep_growth_pct);
|
|
1195
|
+
const roaChange = asNumber(comparison.roa_change);
|
|
1196
|
+
const roeChange = asNumber(comparison.roe_change);
|
|
1197
|
+
const officesChange = asNumber(comparison.offices_change);
|
|
1198
|
+
const depositsToAssetsChange = asNumber(comparison.deposits_to_assets_change);
|
|
1199
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && roaChange !== null && roaChange > 0) {
|
|
1200
|
+
insights.push("growth_with_better_profitability");
|
|
1201
|
+
}
|
|
1202
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 25 && depGrowthPct !== null && depGrowthPct >= 15 && officesChange !== null && officesChange > 0) {
|
|
1203
|
+
insights.push("growth_with_branch_expansion");
|
|
1204
|
+
}
|
|
1205
|
+
if (assetGrowthPct !== null && assetGrowthPct >= 20 && (roaChange === null || roaChange <= 0) && (roeChange === null || roeChange <= 0)) {
|
|
1206
|
+
insights.push("balance_sheet_growth_without_profitability");
|
|
1207
|
+
}
|
|
1208
|
+
if (assetGrowthPct !== null && assetGrowthPct > 0 && officesChange !== null && officesChange < 0) {
|
|
1209
|
+
insights.push("growth_with_branch_consolidation");
|
|
1210
|
+
}
|
|
1211
|
+
if (depositsToAssetsChange !== null && depositsToAssetsChange < 0 && depGrowthPct !== null && depGrowthPct < 0) {
|
|
1212
|
+
insights.push("deposit_mix_softening");
|
|
1213
|
+
}
|
|
1214
|
+
return insights;
|
|
1215
|
+
}
|
|
1216
|
+
function buildTopLevelInsights(comparisons) {
|
|
1217
|
+
return {
|
|
1218
|
+
growth_with_better_profitability: comparisons.filter(
|
|
1219
|
+
(comparison) => comparison.insights?.includes(
|
|
1220
|
+
"growth_with_better_profitability"
|
|
1221
|
+
)
|
|
1222
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1223
|
+
growth_with_branch_expansion: comparisons.filter(
|
|
1224
|
+
(comparison) => comparison.insights?.includes(
|
|
1225
|
+
"growth_with_branch_expansion"
|
|
1226
|
+
)
|
|
1227
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1228
|
+
balance_sheet_growth_without_profitability: comparisons.filter(
|
|
1229
|
+
(comparison) => comparison.insights?.includes(
|
|
1230
|
+
"balance_sheet_growth_without_profitability"
|
|
1231
|
+
)
|
|
1232
|
+
).slice(0, 5).map((comparison) => String(comparison.name)),
|
|
1233
|
+
growth_with_branch_consolidation: comparisons.filter(
|
|
1234
|
+
(comparison) => comparison.insights?.includes(
|
|
1235
|
+
"growth_with_branch_consolidation"
|
|
1236
|
+
)
|
|
1237
|
+
).slice(0, 5).map((comparison) => String(comparison.name))
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
function longestMonotonicStreak(values, direction) {
|
|
1241
|
+
let longest = 0;
|
|
1242
|
+
let current = 0;
|
|
1243
|
+
for (let i = 1; i < values.length; i += 1) {
|
|
1244
|
+
const previous = values[i - 1];
|
|
1245
|
+
const next = values[i];
|
|
1246
|
+
if (previous === null || next === null) {
|
|
1247
|
+
current = 0;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
const matches = direction === "up" ? next > previous : next < previous;
|
|
1251
|
+
if (matches) {
|
|
1252
|
+
current += 1;
|
|
1253
|
+
longest = Math.max(longest, current);
|
|
1254
|
+
} else {
|
|
1255
|
+
current = 0;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
return longest;
|
|
1259
|
+
}
|
|
1260
|
+
function summarizeTimeSeries(records, demographicsByDate, institution) {
|
|
1261
|
+
if (records.length < 2) return null;
|
|
1262
|
+
const sorted = [...records].sort(
|
|
1263
|
+
(left, right) => String(left.REPDTE).localeCompare(String(right.REPDTE))
|
|
1264
|
+
);
|
|
1265
|
+
const start = sorted[0];
|
|
1266
|
+
const end = sorted[sorted.length - 1];
|
|
1267
|
+
const startRepdte = String(start.REPDTE);
|
|
1268
|
+
const endRepdte = String(end.REPDTE);
|
|
1269
|
+
const years = yearsBetween(startRepdte, endRepdte);
|
|
1270
|
+
const assetSeries = sorted.map((record) => asNumber(record.ASSET));
|
|
1271
|
+
const roaSeries = sorted.map((record) => asNumber(record.ROA));
|
|
1272
|
+
const roeSeries = sorted.map((record) => asNumber(record.ROE));
|
|
1273
|
+
const officesSeries = sorted.map(
|
|
1274
|
+
(record) => asNumber(demographicsByDate.get(String(record.REPDTE))?.OFFTOT)
|
|
1275
|
+
);
|
|
1276
|
+
const assetStart = asNumber(start.ASSET);
|
|
1277
|
+
const assetEnd = asNumber(end.ASSET);
|
|
1278
|
+
const depStart = asNumber(start.DEP);
|
|
1279
|
+
const depEnd = asNumber(end.DEP);
|
|
1280
|
+
const roaStart = asNumber(start.ROA);
|
|
1281
|
+
const roaEnd = asNumber(end.ROA);
|
|
1282
|
+
const roeStart = asNumber(start.ROE);
|
|
1283
|
+
const roeEnd = asNumber(end.ROE);
|
|
1284
|
+
const netIncStart = asNumber(start.NETINC);
|
|
1285
|
+
const netIncEnd = asNumber(end.NETINC);
|
|
1286
|
+
const officesStart = officesSeries[0] ?? null;
|
|
1287
|
+
const officesEnd = officesSeries[officesSeries.length - 1] ?? null;
|
|
1288
|
+
const assetsPerOfficeStart = ratio(assetStart, officesStart);
|
|
1289
|
+
const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
|
|
1290
|
+
const depositsPerOfficeStart = ratio(depStart, officesStart);
|
|
1291
|
+
const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
|
|
1292
|
+
const depositsToAssetsStart = ratio(depStart, assetStart);
|
|
1293
|
+
const depositsToAssetsEnd = ratio(depEnd, assetEnd);
|
|
1294
|
+
const peakAsset = Math.max(...assetSeries.filter((value) => value !== null));
|
|
1295
|
+
const troughRoaValues = roaSeries.filter((value) => value !== null);
|
|
1296
|
+
const troughRoa = troughRoaValues.length > 0 ? Math.min(...troughRoaValues) : null;
|
|
1297
|
+
const comparison = {
|
|
1298
|
+
cert: asNumber(start.CERT),
|
|
1299
|
+
name: end.NAME ?? start.NAME ?? institution.NAME,
|
|
1300
|
+
city: institution.CITY,
|
|
1301
|
+
stalp: institution.STALP,
|
|
1302
|
+
analysis_mode: "timeseries",
|
|
1303
|
+
start_repdte: startRepdte,
|
|
1304
|
+
end_repdte: endRepdte,
|
|
1305
|
+
periods_analyzed: sorted.length,
|
|
1306
|
+
asset_start: assetStart,
|
|
1307
|
+
asset_end: assetEnd,
|
|
1308
|
+
asset_growth: change(assetStart, assetEnd),
|
|
1309
|
+
asset_growth_pct: pctChange(assetStart, assetEnd),
|
|
1310
|
+
asset_cagr: cagr(assetStart, assetEnd, years),
|
|
1311
|
+
dep_start: depStart,
|
|
1312
|
+
dep_end: depEnd,
|
|
1313
|
+
dep_growth: change(depStart, depEnd),
|
|
1314
|
+
dep_growth_pct: pctChange(depStart, depEnd),
|
|
1315
|
+
netinc_start: netIncStart,
|
|
1316
|
+
netinc_end: netIncEnd,
|
|
1317
|
+
netinc_change: change(netIncStart, netIncEnd),
|
|
1318
|
+
netinc_change_pct: pctChange(netIncStart, netIncEnd),
|
|
1319
|
+
roa_start: roaStart,
|
|
1320
|
+
roa_end: roaEnd,
|
|
1321
|
+
roa_change: change(roaStart, roaEnd),
|
|
1322
|
+
roe_start: roeStart,
|
|
1323
|
+
roe_end: roeEnd,
|
|
1324
|
+
roe_change: change(roeStart, roeEnd),
|
|
1325
|
+
offices_start: officesStart,
|
|
1326
|
+
offices_end: officesEnd,
|
|
1327
|
+
offices_change: change(officesStart, officesEnd),
|
|
1328
|
+
assets_per_office_start: assetsPerOfficeStart,
|
|
1329
|
+
assets_per_office_end: assetsPerOfficeEnd,
|
|
1330
|
+
assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
|
|
1331
|
+
deposits_per_office_start: depositsPerOfficeStart,
|
|
1332
|
+
deposits_per_office_end: depositsPerOfficeEnd,
|
|
1333
|
+
deposits_per_office_change: change(
|
|
1334
|
+
depositsPerOfficeStart,
|
|
1335
|
+
depositsPerOfficeEnd
|
|
1336
|
+
),
|
|
1337
|
+
deposits_to_assets_start: depositsToAssetsStart,
|
|
1338
|
+
deposits_to_assets_end: depositsToAssetsEnd,
|
|
1339
|
+
deposits_to_assets_change: change(
|
|
1340
|
+
depositsToAssetsStart,
|
|
1341
|
+
depositsToAssetsEnd
|
|
1342
|
+
),
|
|
1343
|
+
asset_peak: peakAsset,
|
|
1344
|
+
roa_trough: troughRoa,
|
|
1345
|
+
asset_growth_streak: longestMonotonicStreak(assetSeries, "up"),
|
|
1346
|
+
roa_decline_streak: longestMonotonicStreak(roaSeries, "down"),
|
|
1347
|
+
roe_decline_streak: longestMonotonicStreak(roeSeries, "down"),
|
|
1348
|
+
time_series: sorted.map((record) => {
|
|
1349
|
+
const repdte = String(record.REPDTE);
|
|
1350
|
+
const demo = demographicsByDate.get(repdte);
|
|
1351
|
+
return {
|
|
1352
|
+
repdte,
|
|
1353
|
+
asset: asNumber(record.ASSET),
|
|
1354
|
+
dep: asNumber(record.DEP),
|
|
1355
|
+
netinc: asNumber(record.NETINC),
|
|
1356
|
+
roa: asNumber(record.ROA),
|
|
1357
|
+
roe: asNumber(record.ROE),
|
|
1358
|
+
offices: asNumber(demo?.OFFTOT)
|
|
1359
|
+
};
|
|
1360
|
+
})
|
|
1361
|
+
};
|
|
1362
|
+
comparison.insights = classifyInsights(comparison);
|
|
1363
|
+
if (comparison.asset_growth_streak >= 3) {
|
|
1364
|
+
comparison.insights.push("sustained_asset_growth");
|
|
1365
|
+
}
|
|
1366
|
+
if (comparison.roa_decline_streak >= 2) {
|
|
1367
|
+
comparison.insights.push("multi_quarter_roa_decline");
|
|
1368
|
+
}
|
|
1369
|
+
return comparison;
|
|
1370
|
+
}
|
|
1371
|
+
function formatComparisonText(output) {
|
|
1372
|
+
const header = `Compared ${output.analyzed_count} institutions from ${output.start_repdte} to ${output.end_repdte} (from ${output.total_candidates} candidates), ranked by ${output.sort_by} using ${output.analysis_mode} analysis.`;
|
|
1373
|
+
if (output.comparisons.length === 0) {
|
|
1374
|
+
return header;
|
|
1375
|
+
}
|
|
1376
|
+
const rows = output.comparisons.map((comparison, index) => {
|
|
1377
|
+
const name = String(comparison.name ?? comparison.cert);
|
|
1378
|
+
const city = comparison.city ? `, ${comparison.city}` : "";
|
|
1379
|
+
const base = `${index + 1}. ${name}${city} | Asset growth: ${formatInteger(asNumber(comparison.asset_growth))} (${formatPercent(asNumber(comparison.asset_growth_pct))}) | Deposit growth: ${formatPercent(asNumber(comparison.dep_growth_pct))} | Offices: ${formatInteger(asNumber(comparison.offices_change))} | ROA: ${formatChange(asNumber(comparison.roa_change))} | ROE: ${formatChange(asNumber(comparison.roe_change))}`;
|
|
1380
|
+
if (output.analysis_mode === "timeseries") {
|
|
1381
|
+
return `${base} | Asset CAGR: ${formatPercent(asNumber(comparison.asset_cagr))} | Streaks: asset ${formatInteger(asNumber(comparison.asset_growth_streak))}, ROA decline ${formatInteger(asNumber(comparison.roa_decline_streak))}`;
|
|
1382
|
+
}
|
|
1383
|
+
return base;
|
|
1384
|
+
});
|
|
1385
|
+
const insights = output.insights ? Object.entries(output.insights).filter(([, names]) => names.length > 0).map(([label, names]) => `${label}: ${names.join(", ")}`).join("\n") : "";
|
|
1386
|
+
return insights ? `${header}
|
|
1387
|
+
${rows.join("\n")}
|
|
1388
|
+
Insights
|
|
1389
|
+
${insights}` : `${header}
|
|
1390
|
+
${rows.join("\n")}`;
|
|
1391
|
+
}
|
|
1392
|
+
async function fetchInstitutionRoster(state, institutionFilters, activeOnly) {
|
|
1393
|
+
const filterParts = [];
|
|
1394
|
+
if (state) filterParts.push(`STNAME:"${state}"`);
|
|
1395
|
+
if (activeOnly) filterParts.push("ACTIVE:1");
|
|
1396
|
+
if (institutionFilters) filterParts.push(`(${institutionFilters})`);
|
|
1397
|
+
const response = await queryEndpoint(ENDPOINTS.INSTITUTIONS, {
|
|
1398
|
+
filters: filterParts.join(" AND "),
|
|
1399
|
+
fields: "CERT,NAME,CITY,STALP,ACTIVE",
|
|
1400
|
+
limit: 1e4,
|
|
1401
|
+
offset: 0,
|
|
1402
|
+
sort_by: "CERT",
|
|
1403
|
+
sort_order: "ASC"
|
|
1404
|
+
});
|
|
1405
|
+
return extractRecords(response);
|
|
1406
|
+
}
|
|
1407
|
+
async function fetchBatchedRecordsForDates(endpoint, certs, repdteFilters, fields) {
|
|
1408
|
+
const certFilters = buildCertFilters(certs);
|
|
1409
|
+
const tasks = repdteFilters.flatMap(
|
|
1410
|
+
(repdteFilter) => certFilters.map((certFilter) => ({
|
|
1411
|
+
repdteFilter,
|
|
1412
|
+
certFilter
|
|
1413
|
+
}))
|
|
1414
|
+
);
|
|
1415
|
+
const responses = await mapWithConcurrency(tasks, MAX_CONCURRENCY, async (task) => {
|
|
1416
|
+
const response = await queryEndpoint(endpoint, {
|
|
1417
|
+
filters: `(${task.certFilter}) AND ${task.repdteFilter}`,
|
|
1418
|
+
fields,
|
|
1419
|
+
limit: 1e4,
|
|
1420
|
+
offset: 0,
|
|
1421
|
+
sort_by: "CERT",
|
|
1422
|
+
sort_order: "ASC"
|
|
1423
|
+
});
|
|
1424
|
+
return { repdteFilter: task.repdteFilter, response };
|
|
1425
|
+
});
|
|
1426
|
+
const byDate = /* @__PURE__ */ new Map();
|
|
1427
|
+
for (const { repdteFilter, response } of responses) {
|
|
1428
|
+
if (!byDate.has(repdteFilter)) {
|
|
1429
|
+
byDate.set(repdteFilter, /* @__PURE__ */ new Map());
|
|
1430
|
+
}
|
|
1431
|
+
const target = byDate.get(repdteFilter);
|
|
1432
|
+
for (const record of extractRecords(response)) {
|
|
1433
|
+
const cert = asNumber(record.CERT);
|
|
1434
|
+
if (cert !== null) target.set(cert, record);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
return byDate;
|
|
1438
|
+
}
|
|
1439
|
+
async function fetchSeriesRecords(endpoint, certs, startRepdte, endRepdte, fields) {
|
|
1440
|
+
const certFilters = buildCertFilters(certs);
|
|
1441
|
+
const responses = await mapWithConcurrency(
|
|
1442
|
+
certFilters,
|
|
1443
|
+
MAX_CONCURRENCY,
|
|
1444
|
+
async (certFilter) => queryEndpoint(endpoint, {
|
|
1445
|
+
filters: `(${certFilter}) AND REPDTE:[${startRepdte} TO ${endRepdte}]`,
|
|
1446
|
+
fields,
|
|
1447
|
+
limit: 1e4,
|
|
1448
|
+
offset: 0,
|
|
1449
|
+
sort_by: "REPDTE",
|
|
1450
|
+
sort_order: "ASC"
|
|
1451
|
+
})
|
|
1452
|
+
);
|
|
1453
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
1454
|
+
for (const response of responses) {
|
|
1455
|
+
for (const record of extractRecords(response)) {
|
|
1456
|
+
const cert = asNumber(record.CERT);
|
|
1457
|
+
if (cert === null) continue;
|
|
1458
|
+
if (!grouped.has(cert)) grouped.set(cert, []);
|
|
1459
|
+
grouped.get(cert).push(record);
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
return grouped;
|
|
1463
|
+
}
|
|
1464
|
+
function buildSnapshotComparison(cert, institution, startFinancial, endFinancial, startDemo, endDemo, startRepdte, endRepdte) {
|
|
1465
|
+
const assetStart = asNumber(startFinancial.ASSET);
|
|
1466
|
+
const assetEnd = asNumber(endFinancial.ASSET);
|
|
1467
|
+
const depStart = asNumber(startFinancial.DEP);
|
|
1468
|
+
const depEnd = asNumber(endFinancial.DEP);
|
|
1469
|
+
const netIncStart = asNumber(startFinancial.NETINC);
|
|
1470
|
+
const netIncEnd = asNumber(endFinancial.NETINC);
|
|
1471
|
+
const roaStart = asNumber(startFinancial.ROA);
|
|
1472
|
+
const roaEnd = asNumber(endFinancial.ROA);
|
|
1473
|
+
const roeStart = asNumber(startFinancial.ROE);
|
|
1474
|
+
const roeEnd = asNumber(endFinancial.ROE);
|
|
1475
|
+
const officesStart = asNumber(startDemo?.OFFTOT);
|
|
1476
|
+
const officesEnd = asNumber(endDemo?.OFFTOT);
|
|
1477
|
+
const assetsPerOfficeStart = ratio(assetStart, officesStart);
|
|
1478
|
+
const assetsPerOfficeEnd = ratio(assetEnd, officesEnd);
|
|
1479
|
+
const depositsPerOfficeStart = ratio(depStart, officesStart);
|
|
1480
|
+
const depositsPerOfficeEnd = ratio(depEnd, officesEnd);
|
|
1481
|
+
const depositsToAssetsStart = ratio(depStart, assetStart);
|
|
1482
|
+
const depositsToAssetsEnd = ratio(depEnd, assetEnd);
|
|
1483
|
+
const comparison = {
|
|
1484
|
+
cert,
|
|
1485
|
+
name: endFinancial.NAME ?? startFinancial.NAME ?? institution.NAME,
|
|
1486
|
+
city: institution.CITY,
|
|
1487
|
+
stalp: institution.STALP,
|
|
1488
|
+
analysis_mode: "snapshot",
|
|
1489
|
+
start_repdte: startRepdte,
|
|
1490
|
+
end_repdte: endRepdte,
|
|
1491
|
+
asset_start: assetStart,
|
|
1492
|
+
asset_end: assetEnd,
|
|
1493
|
+
asset_growth: change(assetStart, assetEnd),
|
|
1494
|
+
asset_growth_pct: pctChange(assetStart, assetEnd),
|
|
1495
|
+
dep_start: depStart,
|
|
1496
|
+
dep_end: depEnd,
|
|
1497
|
+
dep_growth: change(depStart, depEnd),
|
|
1498
|
+
dep_growth_pct: pctChange(depStart, depEnd),
|
|
1499
|
+
netinc_start: netIncStart,
|
|
1500
|
+
netinc_end: netIncEnd,
|
|
1501
|
+
netinc_change: change(netIncStart, netIncEnd),
|
|
1502
|
+
netinc_change_pct: pctChange(netIncStart, netIncEnd),
|
|
1503
|
+
roa_start: roaStart,
|
|
1504
|
+
roa_end: roaEnd,
|
|
1505
|
+
roa_change: change(roaStart, roaEnd),
|
|
1506
|
+
roe_start: roeStart,
|
|
1507
|
+
roe_end: roeEnd,
|
|
1508
|
+
roe_change: change(roeStart, roeEnd),
|
|
1509
|
+
offices_start: officesStart,
|
|
1510
|
+
offices_end: officesEnd,
|
|
1511
|
+
offices_change: change(officesStart, officesEnd),
|
|
1512
|
+
assets_per_office_start: assetsPerOfficeStart,
|
|
1513
|
+
assets_per_office_end: assetsPerOfficeEnd,
|
|
1514
|
+
assets_per_office_change: change(assetsPerOfficeStart, assetsPerOfficeEnd),
|
|
1515
|
+
deposits_per_office_start: depositsPerOfficeStart,
|
|
1516
|
+
deposits_per_office_end: depositsPerOfficeEnd,
|
|
1517
|
+
deposits_per_office_change: change(
|
|
1518
|
+
depositsPerOfficeStart,
|
|
1519
|
+
depositsPerOfficeEnd
|
|
1520
|
+
),
|
|
1521
|
+
deposits_to_assets_start: depositsToAssetsStart,
|
|
1522
|
+
deposits_to_assets_end: depositsToAssetsEnd,
|
|
1523
|
+
deposits_to_assets_change: change(
|
|
1524
|
+
depositsToAssetsStart,
|
|
1525
|
+
depositsToAssetsEnd
|
|
1526
|
+
),
|
|
1527
|
+
cbsa_start: startDemo?.CBSANAME,
|
|
1528
|
+
cbsa_end: endDemo?.CBSANAME
|
|
1529
|
+
};
|
|
1530
|
+
comparison.insights = classifyInsights(comparison);
|
|
1531
|
+
return comparison;
|
|
1532
|
+
}
|
|
1533
|
+
function registerAnalysisTools(server) {
|
|
1534
|
+
server.registerTool(
|
|
1535
|
+
"fdic_compare_bank_snapshots",
|
|
1536
|
+
{
|
|
1537
|
+
title: "Compare Bank Snapshot Trends",
|
|
1538
|
+
description: `Compare FDIC reporting snapshots across a set of institutions and rank the results by growth, profitability, or efficiency changes.
|
|
1539
|
+
|
|
1540
|
+
This tool is designed for heavier analytical prompts that would otherwise require many separate MCP calls. It batches institution roster lookup, financial snapshots, optional office-count snapshots, and can also fetch a quarterly time series inside the server.
|
|
1541
|
+
|
|
1542
|
+
Good uses:
|
|
1543
|
+
- Identify North Carolina banks with the strongest asset growth from 2021 to 2025
|
|
1544
|
+
- Compare whether deposit growth came with branch expansion or profitability improvement
|
|
1545
|
+
- Rank a specific cert list by ROA, ROE, asset-per-office, or deposit-to-asset changes
|
|
1546
|
+
- Pull a quarterly trend series and highlight inflection points, streaks, and structural shifts
|
|
1547
|
+
|
|
1548
|
+
Inputs:
|
|
1549
|
+
- state or certs: choose a geographic roster or provide a direct comparison set
|
|
1550
|
+
- start_repdte, end_repdte: report dates in YYYYMMDD format
|
|
1551
|
+
- analysis_mode: snapshot or timeseries
|
|
1552
|
+
- institution_filters: optional extra institution filter when building the roster
|
|
1553
|
+
- active_only: default true
|
|
1554
|
+
- include_demographics: default true, adds office-count comparisons when available
|
|
1555
|
+
- sort_by: ranking field such as asset_growth, dep_growth_pct, roa_change, assets_per_office_change
|
|
1556
|
+
- sort_order: ASC or DESC
|
|
1557
|
+
- limit: maximum ranked results to return
|
|
1558
|
+
|
|
1559
|
+
Returns concise comparison text plus structured deltas, derived metrics, and insight tags for each institution.`,
|
|
1560
|
+
inputSchema: SnapshotAnalysisSchema,
|
|
1561
|
+
annotations: {
|
|
1562
|
+
readOnlyHint: true,
|
|
1563
|
+
destructiveHint: false,
|
|
1564
|
+
idempotentHint: true,
|
|
1565
|
+
openWorldHint: true
|
|
1566
|
+
}
|
|
1567
|
+
},
|
|
1568
|
+
async ({
|
|
1569
|
+
state,
|
|
1570
|
+
certs,
|
|
1571
|
+
institution_filters,
|
|
1572
|
+
active_only,
|
|
1573
|
+
start_repdte,
|
|
1574
|
+
end_repdte,
|
|
1575
|
+
analysis_mode,
|
|
1576
|
+
include_demographics,
|
|
1577
|
+
limit,
|
|
1578
|
+
sort_by,
|
|
1579
|
+
sort_order
|
|
1580
|
+
}) => {
|
|
1581
|
+
try {
|
|
1582
|
+
const roster = certs && certs.length > 0 ? certs.map((cert) => ({ CERT: cert })) : await fetchInstitutionRoster(
|
|
1583
|
+
state,
|
|
1584
|
+
institution_filters,
|
|
1585
|
+
active_only
|
|
1586
|
+
);
|
|
1587
|
+
const candidateCerts = roster.map((record) => asNumber(record.CERT)).filter((cert) => cert !== null);
|
|
1588
|
+
if (candidateCerts.length === 0) {
|
|
1589
|
+
const output2 = {
|
|
1590
|
+
total_candidates: 0,
|
|
1591
|
+
analyzed_count: 0,
|
|
1592
|
+
start_repdte,
|
|
1593
|
+
end_repdte,
|
|
1594
|
+
analysis_mode,
|
|
1595
|
+
sort_by,
|
|
1596
|
+
sort_order,
|
|
1597
|
+
comparisons: []
|
|
1598
|
+
};
|
|
1599
|
+
return {
|
|
1600
|
+
content: [
|
|
1601
|
+
{ type: "text", text: "No institutions matched the comparison set." }
|
|
1602
|
+
],
|
|
1603
|
+
structuredContent: output2
|
|
1604
|
+
};
|
|
1605
|
+
}
|
|
1606
|
+
const rosterByCert = new Map(
|
|
1607
|
+
roster.map((record) => [asNumber(record.CERT), record]).filter(
|
|
1608
|
+
(entry) => entry[0] !== null
|
|
1609
|
+
)
|
|
1610
|
+
);
|
|
1611
|
+
let comparisons = [];
|
|
1612
|
+
if (analysis_mode === "timeseries") {
|
|
1613
|
+
const [financialSeries, demographicsSeries] = await Promise.all([
|
|
1614
|
+
fetchSeriesRecords(
|
|
1615
|
+
ENDPOINTS.FINANCIALS,
|
|
1616
|
+
candidateCerts,
|
|
1617
|
+
start_repdte,
|
|
1618
|
+
end_repdte,
|
|
1619
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1620
|
+
),
|
|
1621
|
+
include_demographics ? fetchSeriesRecords(
|
|
1622
|
+
ENDPOINTS.DEMOGRAPHICS,
|
|
1623
|
+
candidateCerts,
|
|
1624
|
+
start_repdte,
|
|
1625
|
+
end_repdte,
|
|
1626
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1627
|
+
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1628
|
+
]);
|
|
1629
|
+
comparisons = candidateCerts.map(
|
|
1630
|
+
(cert) => summarizeTimeSeries(
|
|
1631
|
+
financialSeries.get(cert) ?? [],
|
|
1632
|
+
new Map(
|
|
1633
|
+
(demographicsSeries.get(cert) ?? []).map((record) => [
|
|
1634
|
+
String(record.REPDTE),
|
|
1635
|
+
record
|
|
1636
|
+
])
|
|
1637
|
+
),
|
|
1638
|
+
rosterByCert.get(cert) ?? {}
|
|
1639
|
+
)
|
|
1640
|
+
).filter((comparison) => comparison !== null);
|
|
1641
|
+
} else {
|
|
1642
|
+
const [financialSnapshots, demographicSnapshots] = await Promise.all([
|
|
1643
|
+
fetchBatchedRecordsForDates(
|
|
1644
|
+
ENDPOINTS.FINANCIALS,
|
|
1645
|
+
candidateCerts,
|
|
1646
|
+
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1647
|
+
"CERT,NAME,REPDTE,ASSET,DEP,NETINC,ROA,ROE"
|
|
1648
|
+
),
|
|
1649
|
+
include_demographics ? fetchBatchedRecordsForDates(
|
|
1650
|
+
ENDPOINTS.DEMOGRAPHICS,
|
|
1651
|
+
candidateCerts,
|
|
1652
|
+
[`REPDTE:${start_repdte}`, `REPDTE:${end_repdte}`],
|
|
1653
|
+
"CERT,REPDTE,OFFTOT,OFFSTATE,CBSANAME"
|
|
1654
|
+
) : Promise.resolve(/* @__PURE__ */ new Map())
|
|
1655
|
+
]);
|
|
1656
|
+
const startFinancials = financialSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1657
|
+
const endFinancials = financialSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1658
|
+
const startDemographics = demographicSnapshots.get(`REPDTE:${start_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1659
|
+
const endDemographics = demographicSnapshots.get(`REPDTE:${end_repdte}`) ?? /* @__PURE__ */ new Map();
|
|
1660
|
+
comparisons = candidateCerts.map((cert) => {
|
|
1661
|
+
const startFinancial = startFinancials.get(cert);
|
|
1662
|
+
const endFinancial = endFinancials.get(cert);
|
|
1663
|
+
if (!startFinancial || !endFinancial) return null;
|
|
1664
|
+
return buildSnapshotComparison(
|
|
1665
|
+
cert,
|
|
1666
|
+
rosterByCert.get(cert) ?? {},
|
|
1667
|
+
startFinancial,
|
|
1668
|
+
endFinancial,
|
|
1669
|
+
startDemographics.get(cert),
|
|
1670
|
+
endDemographics.get(cert),
|
|
1671
|
+
start_repdte,
|
|
1672
|
+
end_repdte
|
|
1673
|
+
);
|
|
1674
|
+
}).filter((comparison) => comparison !== null);
|
|
1675
|
+
}
|
|
1676
|
+
const ranked = sortComparisons(comparisons, sort_by, sort_order).slice(
|
|
1677
|
+
0,
|
|
1678
|
+
limit
|
|
1679
|
+
);
|
|
1680
|
+
const pagination = buildPaginationInfo(comparisons.length, 0, ranked.length);
|
|
1681
|
+
const output = {
|
|
1682
|
+
total_candidates: candidateCerts.length,
|
|
1683
|
+
analyzed_count: comparisons.length,
|
|
1684
|
+
start_repdte,
|
|
1685
|
+
end_repdte,
|
|
1686
|
+
analysis_mode,
|
|
1687
|
+
sort_by,
|
|
1688
|
+
sort_order,
|
|
1689
|
+
insights: buildTopLevelInsights(comparisons),
|
|
1690
|
+
...pagination,
|
|
1691
|
+
comparisons: ranked
|
|
1692
|
+
};
|
|
1693
|
+
const text = truncateIfNeeded(
|
|
1694
|
+
formatComparisonText(output),
|
|
1695
|
+
CHARACTER_LIMIT
|
|
1696
|
+
);
|
|
1697
|
+
return {
|
|
1698
|
+
content: [{ type: "text", text }],
|
|
1699
|
+
structuredContent: output
|
|
1700
|
+
};
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
return formatToolError(err);
|
|
1703
|
+
}
|
|
1704
|
+
}
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1034
1708
|
// src/index.ts
|
|
1035
1709
|
function createServer() {
|
|
1036
1710
|
const server = new import_mcp.McpServer({
|
|
@@ -1044,6 +1718,7 @@ function createServer() {
|
|
|
1044
1718
|
registerFinancialTools(server);
|
|
1045
1719
|
registerSodTools(server);
|
|
1046
1720
|
registerDemographicsTools(server);
|
|
1721
|
+
registerAnalysisTools(server);
|
|
1047
1722
|
return server;
|
|
1048
1723
|
}
|
|
1049
1724
|
async function runStdio() {
|