fdic-mcp-server 1.0.5 → 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.5" : 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) {
@@ -122,6 +153,37 @@ function truncateIfNeeded(text, charLimit) {
122
153
 
123
154
  [Response truncated at ${charLimit} characters. Use limit/offset parameters to paginate or narrow your query with filters.]`;
124
155
  }
156
+ function formatValue(value) {
157
+ if (value === null || value === void 0) return "n/a";
158
+ if (typeof value === "number") return Number.isInteger(value) ? `${value}` : value.toFixed(4);
159
+ if (typeof value === "string") return value;
160
+ if (typeof value === "boolean") return value ? "true" : "false";
161
+ return JSON.stringify(value);
162
+ }
163
+ function summarizeRecord(record, preferredKeys, maxFields = 4) {
164
+ const orderedKeys = [
165
+ ...preferredKeys.filter((key) => key in record),
166
+ ...Object.keys(record).filter(
167
+ (key) => key !== "ID" && !preferredKeys.includes(key)
168
+ )
169
+ ];
170
+ return orderedKeys.slice(0, maxFields).map((key) => `${key}: ${formatValue(record[key])}`).join(" | ");
171
+ }
172
+ function formatSearchResultText(label, records, pagination, preferredKeys) {
173
+ const header = `Found ${pagination.total} ${label} (showing ${pagination.count}, offset ${pagination.offset}).`;
174
+ if (records.length === 0) {
175
+ return header;
176
+ }
177
+ const rows = records.map((record, index) => `${index + 1}. ${summarizeRecord(record, preferredKeys)}`).join("\n");
178
+ const footer = pagination.has_more ? `
179
+ More results available. Use offset ${pagination.next_offset} to continue.` : "";
180
+ return `${header}
181
+ ${rows}${footer}`;
182
+ }
183
+ function formatLookupResultText(label, record, preferredKeys) {
184
+ return `${label}
185
+ ${summarizeRecord(record, preferredKeys, 8)}`;
186
+ }
125
187
  function formatToolError(err) {
126
188
  const message = err instanceof Error ? err.message : String(err);
127
189
  return {
@@ -194,7 +256,7 @@ Args:
194
256
  - sort_by (string, optional): Field to sort by
195
257
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
196
258
 
197
- Returns JSON with { total, offset, count, has_more, next_offset?, institutions[] }`,
259
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and institution records.`,
198
260
  inputSchema: CommonQuerySchema,
199
261
  annotations: {
200
262
  readOnlyHint: true,
@@ -214,7 +276,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, institutions[]
214
276
  );
215
277
  const output = { ...pagination, institutions: records };
216
278
  const text = truncateIfNeeded(
217
- JSON.stringify(output, null, 2),
279
+ formatSearchResultText("institutions", records, pagination, [
280
+ "CERT",
281
+ "NAME",
282
+ "CITY",
283
+ "STALP",
284
+ "ASSET",
285
+ "ACTIVE"
286
+ ]),
218
287
  CHARACTER_LIMIT
219
288
  );
220
289
  return {
@@ -238,7 +307,7 @@ Args:
238
307
  - cert (number): FDIC Certificate Number (e.g., 3511 for Bank of America)
239
308
  - fields (string, optional): Comma-separated list of fields to return
240
309
 
241
- Returns full institution profile including financial metrics, charter info, locations, and regulatory details.`,
310
+ Returns a detailed institution profile suitable for concise summaries, with structured fields available for exact values when needed.`,
242
311
  inputSchema: CertSchema,
243
312
  annotations: {
244
313
  readOnlyHint: true,
@@ -267,7 +336,16 @@ Returns full institution profile including financial metrics, charter info, loca
267
336
  };
268
337
  }
269
338
  const output = records[0];
270
- const text = JSON.stringify(output, null, 2);
339
+ const text = formatLookupResultText("Institution details", output, [
340
+ "CERT",
341
+ "NAME",
342
+ "CITY",
343
+ "STALP",
344
+ "ASSET",
345
+ "DEP",
346
+ "ACTIVE",
347
+ "REGAGNT"
348
+ ]);
271
349
  return {
272
350
  content: [{ type: "text", text }],
273
351
  structuredContent: output
@@ -315,7 +393,7 @@ Args:
315
393
  - sort_by (string, optional): Field to sort by (e.g., FAILDATE, COST)
316
394
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
317
395
 
318
- Returns JSON with { total, offset, count, has_more, next_offset?, failures[] }`,
396
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and failure records.`,
319
397
  inputSchema: CommonQuerySchema,
320
398
  annotations: {
321
399
  readOnlyHint: true,
@@ -335,7 +413,15 @@ Returns JSON with { total, offset, count, has_more, next_offset?, failures[] }`,
335
413
  );
336
414
  const output = { ...pagination, failures: records };
337
415
  const text = truncateIfNeeded(
338
- JSON.stringify(output, null, 2),
416
+ formatSearchResultText("failures", records, pagination, [
417
+ "CERT",
418
+ "NAME",
419
+ "CITY",
420
+ "STALP",
421
+ "FAILDATE",
422
+ "COST",
423
+ "RESTYPE"
424
+ ]),
339
425
  CHARACTER_LIMIT
340
426
  );
341
427
  return {
@@ -359,7 +445,7 @@ Args:
359
445
  - cert (number): FDIC Certificate Number of the failed institution
360
446
  - fields (string, optional): Comma-separated list of fields to return
361
447
 
362
- Returns failure details including failure date, resolution method, and cost to FDIC.`,
448
+ Returns detailed failure information suitable for concise summaries, with structured fields available for exact values when needed.`,
363
449
  inputSchema: CertSchema,
364
450
  annotations: {
365
451
  readOnlyHint: true,
@@ -388,7 +474,16 @@ Returns failure details including failure date, resolution method, and cost to F
388
474
  };
389
475
  }
390
476
  const output = records[0];
391
- const text = JSON.stringify(output, null, 2);
477
+ const text = formatLookupResultText("Failure details", output, [
478
+ "CERT",
479
+ "NAME",
480
+ "FAILDATE",
481
+ "RESTYPE",
482
+ "COST",
483
+ "QBFASSET",
484
+ "CITY",
485
+ "STALP"
486
+ ]);
392
487
  return {
393
488
  content: [{ type: "text", text }],
394
489
  structuredContent: output
@@ -446,7 +541,7 @@ Args:
446
541
  - sort_by (string, optional): Field to sort by
447
542
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
448
543
 
449
- Returns JSON with { total, offset, count, has_more, next_offset?, locations[] }`,
544
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and branch location records.`,
450
545
  inputSchema: LocationQuerySchema,
451
546
  annotations: {
452
547
  readOnlyHint: true,
@@ -473,7 +568,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, locations[] }`
473
568
  );
474
569
  const output = { ...pagination, locations: records };
475
570
  const text = truncateIfNeeded(
476
- JSON.stringify(output, null, 2),
571
+ formatSearchResultText("locations", records, pagination, [
572
+ "CERT",
573
+ "UNINAME",
574
+ "NAMEFULL",
575
+ "CITY",
576
+ "STALP",
577
+ "BRNUM"
578
+ ]),
477
579
  CHARACTER_LIMIT
478
580
  );
479
581
  return {
@@ -534,7 +636,7 @@ Args:
534
636
  - sort_by (string, optional): Field to sort by (e.g., PROCDATE)
535
637
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
536
638
 
537
- Returns JSON with { total, offset, count, has_more, next_offset?, events[] }`,
639
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and event records.`,
538
640
  inputSchema: HistoryQuerySchema,
539
641
  annotations: {
540
642
  readOnlyHint: true,
@@ -561,7 +663,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, events[] }`,
561
663
  );
562
664
  const output = { ...pagination, events: records };
563
665
  const text = truncateIfNeeded(
564
- JSON.stringify(output, null, 2),
666
+ formatSearchResultText("events", records, pagination, [
667
+ "CERT",
668
+ "INSTNAME",
669
+ "TYPE",
670
+ "PROCDATE",
671
+ "PCITY",
672
+ "PSTALP"
673
+ ]),
565
674
  CHARACTER_LIMIT
566
675
  );
567
676
  return {
@@ -631,7 +740,7 @@ Args:
631
740
  - sort_by (string, optional): Field to sort by
632
741
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'DESC' recommended for most recent first)
633
742
 
634
- Returns JSON with { total, offset, count, has_more, next_offset?, financials[] }`,
743
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and quarterly financial records.`,
635
744
  inputSchema: FinancialQuerySchema,
636
745
  annotations: {
637
746
  readOnlyHint: true,
@@ -658,7 +767,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, financials[] }
658
767
  );
659
768
  const output = { ...pagination, financials: records };
660
769
  const text = truncateIfNeeded(
661
- JSON.stringify(output, null, 2),
770
+ formatSearchResultText("financial records", records, pagination, [
771
+ "CERT",
772
+ "NAME",
773
+ "REPDTE",
774
+ "ASSET",
775
+ "DEP",
776
+ "NETINC"
777
+ ]),
662
778
  CHARACTER_LIMIT
663
779
  );
664
780
  return {
@@ -706,7 +822,7 @@ Args:
706
822
  - sort_by (string, optional): Field to sort by (e.g., YEAR, ASSET)
707
823
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
708
824
 
709
- Returns JSON with { total, offset, count, has_more, next_offset?, summary[] }`,
825
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and annual summary records.`,
710
826
  inputSchema: SummaryQuerySchema,
711
827
  annotations: {
712
828
  readOnlyHint: true,
@@ -733,7 +849,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, summary[] }`,
733
849
  );
734
850
  const output = { ...pagination, summary: records };
735
851
  const text = truncateIfNeeded(
736
- JSON.stringify(output, null, 2),
852
+ formatSearchResultText("annual summary records", records, pagination, [
853
+ "CERT",
854
+ "YEAR",
855
+ "ASSET",
856
+ "DEP",
857
+ "NETINC",
858
+ "ROA"
859
+ ]),
737
860
  CHARACTER_LIMIT
738
861
  );
739
862
  return {
@@ -795,7 +918,7 @@ Args:
795
918
  - sort_by (string, optional): Field to sort by (e.g., DEPSUMBR, YEAR)
796
919
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
797
920
 
798
- Returns JSON with { total, offset, count, has_more, next_offset?, deposits[] }`,
921
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and deposit records.`,
799
922
  inputSchema: SodQuerySchema,
800
923
  annotations: {
801
924
  readOnlyHint: true,
@@ -822,7 +945,14 @@ Returns JSON with { total, offset, count, has_more, next_offset?, deposits[] }`,
822
945
  );
823
946
  const output = { ...pagination, deposits: records };
824
947
  const text = truncateIfNeeded(
825
- JSON.stringify(output, null, 2),
948
+ formatSearchResultText("deposit records", records, pagination, [
949
+ "CERT",
950
+ "YEAR",
951
+ "UNINAME",
952
+ "NAMEFULL",
953
+ "CITYBR",
954
+ "DEPSUMBR"
955
+ ]),
826
956
  CHARACTER_LIMIT
827
957
  );
828
958
  return {
@@ -884,7 +1014,7 @@ Args:
884
1014
  - sort_by (string, optional): Field to sort by
885
1015
  - sort_order ('ASC'|'DESC'): Sort direction (default: 'ASC')
886
1016
 
887
- Returns JSON with { total, offset, count, has_more, next_offset?, demographics[] }`,
1017
+ Prefer concise human-readable summaries or tables when answering users. Structured fields are available for totals, pagination, and demographic records.`,
888
1018
  inputSchema: DemographicsQuerySchema,
889
1019
  annotations: {
890
1020
  readOnlyHint: true,
@@ -911,7 +1041,657 @@ Returns JSON with { total, offset, count, has_more, next_offset?, demographics[]
911
1041
  );
912
1042
  const output = { ...pagination, demographics: records };
913
1043
  const text = truncateIfNeeded(
914
- JSON.stringify(output, null, 2),
1044
+ formatSearchResultText("demographic records", records, pagination, [
1045
+ "CERT",
1046
+ "REPDTE",
1047
+ "OFFTOT",
1048
+ "OFFSTATE",
1049
+ "METRO",
1050
+ "CBSANAME"
1051
+ ]),
1052
+ CHARACTER_LIMIT
1053
+ );
1054
+ return {
1055
+ content: [{ type: "text", text }],
1056
+ structuredContent: output
1057
+ };
1058
+ } catch (err) {
1059
+ return formatToolError(err);
1060
+ }
1061
+ }
1062
+ );
1063
+ }
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),
915
1695
  CHARACTER_LIMIT
916
1696
  );
917
1697
  return {
@@ -938,6 +1718,7 @@ function createServer() {
938
1718
  registerFinancialTools(server);
939
1719
  registerSodTools(server);
940
1720
  registerDemographicsTools(server);
1721
+ registerAnalysisTools(server);
941
1722
  return server;
942
1723
  }
943
1724
  async function runStdio() {