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/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.6" : process.env.npm_package_version ?? "0.0.0-dev";
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
- try {
70
- const queryParams = {
71
- limit: params.limit ?? 20,
72
- offset: params.offset ?? 0,
73
- output: "json"
74
- };
75
- if (params.filters) queryParams.filters = params.filters;
76
- if (params.fields) queryParams.fields = params.fields;
77
- if (params.sort_by) queryParams.sort_by = params.sort_by;
78
- if (params.sort_order) queryParams.sort_order = params.sort_order;
79
- const response = await apiClient.get(`/${endpoint}`, {
80
- params: queryParams
81
- });
82
- return response.data;
83
- } catch (err) {
84
- if (err instanceof import_axios.AxiosError) {
85
- const status = err.response?.status;
86
- const detail = err.response?.data?.message ?? err.message;
87
- if (status === 400) {
88
- throw new Error(
89
- `Bad request to FDIC API: ${detail}. Check your filter syntax (use ElasticSearch query string syntax, e.g. STNAME:"California" AND ACTIVE:1).`
90
- );
91
- } else if (status === 429) {
92
- throw new Error(
93
- "FDIC API rate limit exceeded. Please wait a moment and try again."
94
- );
95
- } else if (status === 500) {
96
- throw new Error(
97
- "FDIC API server error. The service may be temporarily unavailable. Try again later."
98
- );
99
- } else {
100
- throw new Error(`FDIC API error (HTTP ${status}): ${detail}`);
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
- throw new Error(`Unexpected error calling FDIC API: ${String(err)}`);
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() {