fdic-mcp-server 1.29.0 → 1.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +664 -26
  2. package/dist/server.js +664 -26
  3. package/package.json +1 -1
package/dist/server.js CHANGED
@@ -47,7 +47,7 @@ var import_types = require("@modelcontextprotocol/sdk/types.js");
47
47
  var import_express2 = __toESM(require("express"));
48
48
 
49
49
  // src/constants.ts
50
- var VERSION = true ? "1.29.0" : process.env.npm_package_version ?? "0.0.0-dev";
50
+ var VERSION = true ? "1.30.1" : process.env.npm_package_version ?? "0.0.0-dev";
51
51
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
52
52
  var CHARACTER_LIMIT = 5e4;
53
53
  var DEFAULT_FDIC_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
@@ -88,6 +88,42 @@ var RateLimiter = class {
88
88
  return true;
89
89
  }
90
90
  };
91
+ var ConcurrentLimiter = class {
92
+ maxConcurrent;
93
+ active = /* @__PURE__ */ new Map();
94
+ constructor(maxConcurrent) {
95
+ this.maxConcurrent = maxConcurrent;
96
+ }
97
+ acquire(key) {
98
+ const current = this.active.get(key) ?? 0;
99
+ if (current >= this.maxConcurrent) {
100
+ return void 0;
101
+ }
102
+ this.active.set(key, current + 1);
103
+ let released = false;
104
+ return () => {
105
+ if (released) {
106
+ return;
107
+ }
108
+ released = true;
109
+ const latest = this.active.get(key) ?? 0;
110
+ if (latest <= 1) {
111
+ this.active.delete(key);
112
+ return;
113
+ }
114
+ this.active.set(key, latest - 1);
115
+ };
116
+ }
117
+ };
118
+
119
+ // src/requestIdentity.ts
120
+ function getRequestIp(req) {
121
+ const forwarded = req.get("x-forwarded-for");
122
+ if (forwarded) {
123
+ return forwarded.split(",")[0]?.trim() || req.ip || "unknown";
124
+ }
125
+ return req.ip || "unknown";
126
+ }
91
127
 
92
128
  // src/chat.ts
93
129
  var CHAT_SYSTEM_PROMPT = `You are a demo assistant for the FDIC BankFind MCP Server. You help users
@@ -210,13 +246,6 @@ function mapMessagesToContents(messages) {
210
246
  parts: [{ text: message.content }]
211
247
  }));
212
248
  }
213
- function getRequestIp(req) {
214
- const forwarded = req.get("x-forwarded-for");
215
- if (forwarded) {
216
- return forwarded.split(",")[0]?.trim() || req.ip || "unknown";
217
- }
218
- return req.ip || "unknown";
219
- }
220
249
  function getResponseParts(response) {
221
250
  return response.candidates?.[0]?.content?.parts ?? [];
222
251
  }
@@ -38750,8 +38779,542 @@ NOTE: Requires FRED_API_KEY environment variable for reliable data access. Degra
38750
38779
  );
38751
38780
  }
38752
38781
 
38753
- // src/tools/chatgptRetrieval.ts
38782
+ // src/tools/qbpLite.ts
38754
38783
  var import_zod21 = require("zod");
38784
+ var QBP_LITE_FETCH_LIMIT = 1e4;
38785
+ var QBP_LITE_FIELDS = [
38786
+ "CERT",
38787
+ "NAME",
38788
+ "REPDTE",
38789
+ "CB",
38790
+ "SPECGRP",
38791
+ "SPECGRPDESC",
38792
+ "ASSET",
38793
+ "DEP",
38794
+ "DEPDOM",
38795
+ "NETINC",
38796
+ "ROA",
38797
+ "ROE",
38798
+ "NIMY",
38799
+ "ERNAST",
38800
+ "LNLSNET",
38801
+ "LNRE",
38802
+ "LNCI",
38803
+ "LNCON",
38804
+ "LNCRCD",
38805
+ "LNAG",
38806
+ "SC",
38807
+ "EQTOT",
38808
+ "NCLNLSR",
38809
+ "NTLNLSR",
38810
+ "RBC",
38811
+ "RBC1AAJ",
38812
+ "IDT1RWAJR",
38813
+ "RBCT1",
38814
+ "RBCT1C",
38815
+ "RBCT1CER",
38816
+ "RBCRWAJ",
38817
+ "RWAJ",
38818
+ "RWAJT",
38819
+ "P3RER",
38820
+ "P3CIR",
38821
+ "P3CONR",
38822
+ "P3CRCDR",
38823
+ "P3AGR",
38824
+ "NTRER",
38825
+ "NTCIR",
38826
+ "NTCONR",
38827
+ "NTCRCDR",
38828
+ "NTAGR"
38829
+ ].join(",");
38830
+ var QbpLiteSchema = import_zod21.z.object({
38831
+ repdte: import_zod21.z.string().regex(/^\d{8}$/).optional().describe(
38832
+ "Quarter-end Report Date (REPDTE) in YYYYMMDD format. If omitted, the tool searches backward from the latest likely published quarter until data is found."
38833
+ ),
38834
+ trend_quarters: import_zod21.z.number().int().min(8).max(40).default(20).describe(
38835
+ "Number of quarterly observations to return for trend charts, including the current quarter. Default 20 quarters."
38836
+ ),
38837
+ include_community_banks: import_zod21.z.boolean().default(true).describe(
38838
+ "Include a compact community-bank-vs-industry comparison using the public community-bank flag."
38839
+ )
38840
+ });
38841
+ var EXECUTIVE_METRICS = [
38842
+ {
38843
+ id: "institution_count",
38844
+ label: "Number of institutions reporting",
38845
+ unit: "count"
38846
+ },
38847
+ { id: "total_assets", label: "Total assets", unit: "$thousands" },
38848
+ {
38849
+ id: "total_loans_and_leases",
38850
+ label: "Total loans and leases",
38851
+ unit: "$thousands"
38852
+ },
38853
+ { id: "domestic_deposits", label: "Domestic deposits", unit: "$thousands" },
38854
+ { id: "net_income", label: "Net income", unit: "$thousands" },
38855
+ { id: "roa_pct", label: "Return on assets", unit: "percent" },
38856
+ { id: "roe_pct", label: "Return on equity", unit: "percent" },
38857
+ { id: "net_interest_margin_pct", label: "Net interest margin", unit: "percent" },
38858
+ { id: "noncurrent_loans_pct", label: "Noncurrent loans to loans", unit: "percent" },
38859
+ { id: "net_chargeoffs_pct", label: "Net charge-offs to loans", unit: "percent" },
38860
+ { id: "leverage_ratio_pct", label: "Core capital (leverage) ratio", unit: "percent" }
38861
+ ];
38862
+ function sumField(records, field) {
38863
+ let total = 0;
38864
+ let seen = false;
38865
+ for (const record of records) {
38866
+ const value = asNumber(record[field]);
38867
+ if (value !== null) {
38868
+ total += value;
38869
+ seen = true;
38870
+ }
38871
+ }
38872
+ return seen ? total : null;
38873
+ }
38874
+ function weightedAverage(records, valueField, weightField) {
38875
+ let weightedSum = 0;
38876
+ let weightSum = 0;
38877
+ for (const record of records) {
38878
+ const value = asNumber(record[valueField]);
38879
+ const weight = asNumber(record[weightField]);
38880
+ if (value !== null && weight !== null && weight > 0) {
38881
+ weightedSum += value * weight;
38882
+ weightSum += weight;
38883
+ }
38884
+ }
38885
+ return weightSum > 0 ? weightedSum / weightSum : null;
38886
+ }
38887
+ function pctChange2(current, prior) {
38888
+ if (current === null || prior === null || prior === 0) return null;
38889
+ return (current - prior) / prior * 100;
38890
+ }
38891
+ function change2(current, prior) {
38892
+ if (current === null || prior === null) return null;
38893
+ return current - prior;
38894
+ }
38895
+ function dividePct(numerator, denominator) {
38896
+ if (numerator === null || denominator === null || denominator === 0) return null;
38897
+ return numerator / denominator * 100;
38898
+ }
38899
+ function sumRatioPct(records, numeratorField, denominatorField) {
38900
+ return dividePct(sumField(records, numeratorField), sumField(records, denominatorField));
38901
+ }
38902
+ function aggregateFinancialRecords(records) {
38903
+ const totalAssets = sumField(records, "ASSET");
38904
+ const totalLoans = sumField(records, "LNLSNET");
38905
+ const equityCapital = sumField(records, "EQTOT");
38906
+ return {
38907
+ institution_count: records.length,
38908
+ total_assets: totalAssets,
38909
+ total_loans_and_leases: totalLoans,
38910
+ domestic_deposits: sumField(records, "DEPDOM"),
38911
+ total_deposits: sumField(records, "DEP"),
38912
+ securities: sumField(records, "SC"),
38913
+ equity_capital: equityCapital,
38914
+ net_income: sumField(records, "NETINC"),
38915
+ roa_pct: weightedAverage(records, "ROA", "ASSET"),
38916
+ roe_pct: weightedAverage(records, "ROE", "EQTOT"),
38917
+ net_interest_margin_pct: weightedAverage(records, "NIMY", "ERNAST"),
38918
+ noncurrent_loans_pct: weightedAverage(records, "NCLNLSR", "LNLSNET"),
38919
+ net_chargeoffs_pct: weightedAverage(records, "NTLNLSR", "LNLSNET"),
38920
+ leverage_ratio_pct: weightedAverage(records, "RBC1AAJ", "ASSET"),
38921
+ common_equity_tier1_ratio_pct: sumRatioPct(records, "RBCT1C", "RWAJ") ?? weightedAverage(records, "RBCT1CER", "RWAJ"),
38922
+ tier1_risk_based_ratio_pct: sumRatioPct(records, "RBCT1", "RWAJ") ?? weightedAverage(records, "IDT1RWAJR", "RWAJ"),
38923
+ total_risk_based_ratio_pct: sumRatioPct(records, "RBC", "RWAJT") ?? weightedAverage(records, "RBCRWAJ", "RWAJT")
38924
+ };
38925
+ }
38926
+ function buildExecutiveSnapshot(current, priorQuarter, yearAgo) {
38927
+ return EXECUTIVE_METRICS.map(({ id, label, unit }) => {
38928
+ const currentValue = current[id];
38929
+ const priorValue = priorQuarter?.[id] ?? null;
38930
+ const yearAgoValue = yearAgo?.[id] ?? null;
38931
+ return {
38932
+ id,
38933
+ label,
38934
+ unit,
38935
+ change_unit: unit === "percent" ? "percentage_points" : unit,
38936
+ current: currentValue,
38937
+ prior_quarter_change: change2(currentValue, priorValue),
38938
+ prior_quarter_change_pct: unit === "percent" ? null : pctChange2(currentValue, priorValue),
38939
+ year_over_year_change: change2(currentValue, yearAgoValue),
38940
+ year_over_year_change_pct: unit === "percent" ? null : pctChange2(currentValue, yearAgoValue)
38941
+ };
38942
+ });
38943
+ }
38944
+ function isCommunityBank(record) {
38945
+ const value = record.CB;
38946
+ return value === 1 || value === "1";
38947
+ }
38948
+ function assetSizeGroup(asset) {
38949
+ if (asset === null) return "Unknown";
38950
+ if (asset < 1e5) return "Assets < $100 Million";
38951
+ if (asset < 1e6) return "Assets $100 Million - $1 Billion";
38952
+ if (asset < 1e7) return "Assets $1 Billion - $10 Billion";
38953
+ if (asset < 25e7) return "Assets $10 Billion - $250 Billion";
38954
+ return "Assets > $250 Billion";
38955
+ }
38956
+ function buildSeriesPoint(repdte, records) {
38957
+ const metrics = aggregateFinancialRecords(records);
38958
+ return {
38959
+ repdte,
38960
+ ...metrics
38961
+ };
38962
+ }
38963
+ function buildAssetSizeNimSeries(groupedRecords) {
38964
+ const rows = [];
38965
+ for (const [repdte, records] of groupedRecords) {
38966
+ const groups = /* @__PURE__ */ new Map();
38967
+ for (const record of records) {
38968
+ const group = assetSizeGroup(asNumber(record.ASSET));
38969
+ const groupRecords = groups.get(group);
38970
+ if (groupRecords) {
38971
+ groupRecords.push(record);
38972
+ } else {
38973
+ groups.set(group, [record]);
38974
+ }
38975
+ }
38976
+ rows.push({
38977
+ repdte,
38978
+ group: "Industry",
38979
+ institution_count: records.length,
38980
+ total_assets: sumField(records, "ASSET"),
38981
+ net_interest_margin_pct: weightedAverage(records, "NIMY", "ERNAST")
38982
+ });
38983
+ for (const [group, groupRecords] of groups) {
38984
+ rows.push({
38985
+ repdte,
38986
+ group,
38987
+ institution_count: groupRecords.length,
38988
+ total_assets: sumField(groupRecords, "ASSET"),
38989
+ net_interest_margin_pct: weightedAverage(groupRecords, "NIMY", "ERNAST")
38990
+ });
38991
+ }
38992
+ }
38993
+ return rows;
38994
+ }
38995
+ function buildLoanAndDepositSeries(series) {
38996
+ const byDate = new Map(series.map((point) => [point.repdte, point]));
38997
+ return series.map((point) => {
38998
+ const priorQuarter = getPriorQuarterDates(point.repdte, 1)[0];
38999
+ const yearAgo = getReportDateOneYearPrior(point.repdte);
39000
+ const priorPoint = byDate.get(priorQuarter);
39001
+ const yearAgoPoint = byDate.get(yearAgo);
39002
+ return {
39003
+ repdte: point.repdte,
39004
+ total_loans_and_leases: point.total_loans_and_leases,
39005
+ quarterly_loan_change: change2(
39006
+ point.total_loans_and_leases,
39007
+ priorPoint?.total_loans_and_leases ?? null
39008
+ ),
39009
+ loan_growth_12_month_pct: pctChange2(
39010
+ point.total_loans_and_leases,
39011
+ yearAgoPoint?.total_loans_and_leases ?? null
39012
+ ),
39013
+ domestic_deposits: point.domestic_deposits,
39014
+ quarterly_domestic_deposit_change: change2(
39015
+ point.domestic_deposits,
39016
+ priorPoint?.domestic_deposits ?? null
39017
+ ),
39018
+ domestic_deposit_growth_12_month_pct: pctChange2(
39019
+ point.domestic_deposits,
39020
+ yearAgoPoint?.domestic_deposits ?? null
39021
+ )
39022
+ };
39023
+ });
39024
+ }
39025
+ function buildPortfolioPerformance(records) {
39026
+ const portfolios = [
39027
+ {
39028
+ id: "real_estate",
39029
+ label: "Real estate loans",
39030
+ balanceField: "LNRE",
39031
+ noncurrentRateField: "P3RER",
39032
+ chargeoffRateField: "NTRER"
39033
+ },
39034
+ {
39035
+ id: "commercial_industrial",
39036
+ label: "Commercial and industrial loans",
39037
+ balanceField: "LNCI",
39038
+ noncurrentRateField: "P3CIR",
39039
+ chargeoffRateField: "NTCIR"
39040
+ },
39041
+ {
39042
+ id: "consumer",
39043
+ label: "Consumer loans",
39044
+ balanceField: "LNCON",
39045
+ noncurrentRateField: "P3CONR",
39046
+ chargeoffRateField: "NTCONR"
39047
+ },
39048
+ {
39049
+ id: "credit_card",
39050
+ label: "Credit card loans",
39051
+ balanceField: "LNCRCD",
39052
+ noncurrentRateField: "P3CRCDR",
39053
+ chargeoffRateField: "NTCRCDR"
39054
+ },
39055
+ {
39056
+ id: "farm",
39057
+ label: "Farm loans",
39058
+ balanceField: "LNAG",
39059
+ noncurrentRateField: "P3AGR",
39060
+ chargeoffRateField: "NTAGR"
39061
+ }
39062
+ ];
39063
+ return portfolios.map((portfolio) => ({
39064
+ id: portfolio.id,
39065
+ label: portfolio.label,
39066
+ balance: sumField(records, portfolio.balanceField),
39067
+ balance_share_of_total_loans_pct: dividePct(
39068
+ sumField(records, portfolio.balanceField),
39069
+ sumField(records, "LNLSNET")
39070
+ ),
39071
+ noncurrent_rate_pct: weightedAverage(
39072
+ records,
39073
+ portfolio.noncurrentRateField,
39074
+ portfolio.balanceField
39075
+ ),
39076
+ net_chargeoff_rate_pct: weightedAverage(
39077
+ records,
39078
+ portfolio.chargeoffRateField,
39079
+ portfolio.balanceField
39080
+ )
39081
+ }));
39082
+ }
39083
+ function buildCommunityComparison(groupedRecords) {
39084
+ const rows = [];
39085
+ for (const [repdte, records] of groupedRecords) {
39086
+ for (const [group, groupRecords] of [
39087
+ ["Industry", records],
39088
+ ["Community Banks", records.filter(isCommunityBank)]
39089
+ ]) {
39090
+ const metrics = aggregateFinancialRecords(groupRecords);
39091
+ rows.push({
39092
+ repdte,
39093
+ group,
39094
+ institution_count: metrics.institution_count,
39095
+ total_assets: metrics.total_assets,
39096
+ total_loans_and_leases: metrics.total_loans_and_leases,
39097
+ domestic_deposits: metrics.domestic_deposits,
39098
+ net_income: metrics.net_income,
39099
+ roa_pct: metrics.roa_pct,
39100
+ net_interest_margin_pct: metrics.net_interest_margin_pct,
39101
+ noncurrent_loans_pct: metrics.noncurrent_loans_pct,
39102
+ net_chargeoffs_pct: metrics.net_chargeoffs_pct
39103
+ });
39104
+ }
39105
+ }
39106
+ return rows;
39107
+ }
39108
+ function buildTruncationWarning2(repdte, returnedCount, totalCount, limit = QBP_LITE_FETCH_LIMIT) {
39109
+ if (returnedCount >= limit && totalCount > limit) {
39110
+ return `REPDTE ${repdte}: results truncated at ${limit.toLocaleString()} of ${totalCount.toLocaleString()} institutions.`;
39111
+ }
39112
+ return null;
39113
+ }
39114
+ async function fetchRecordsForRepdte(repdte) {
39115
+ const response = await queryEndpoint(ENDPOINTS.FINANCIALS, {
39116
+ filters: `REPDTE:${repdte}`,
39117
+ fields: QBP_LITE_FIELDS,
39118
+ limit: QBP_LITE_FETCH_LIMIT,
39119
+ sort_by: "CERT",
39120
+ sort_order: "ASC"
39121
+ });
39122
+ const records = extractRecords(response);
39123
+ return {
39124
+ records,
39125
+ total: response.meta.total,
39126
+ truncationWarning: buildTruncationWarning2(
39127
+ repdte,
39128
+ records.length,
39129
+ response.meta.total
39130
+ )
39131
+ };
39132
+ }
39133
+ async function hasFinancialData(repdte) {
39134
+ const response = await queryEndpoint(ENDPOINTS.FINANCIALS, {
39135
+ filters: `REPDTE:${repdte}`,
39136
+ fields: "CERT,REPDTE",
39137
+ limit: 1
39138
+ });
39139
+ return response.meta.total > 0;
39140
+ }
39141
+ async function resolveReportDate(params) {
39142
+ if (params.repdte) {
39143
+ const validationError = validateQuarterEndDate(params.repdte, "repdte");
39144
+ if (validationError) {
39145
+ throw new Error(validationError);
39146
+ }
39147
+ return params.repdte;
39148
+ }
39149
+ const candidates = [getDefaultReportDate(), ...getPriorQuarterDates(getDefaultReportDate(), 7)];
39150
+ for (const candidate of candidates) {
39151
+ if (await hasFinancialData(candidate)) {
39152
+ return candidate;
39153
+ }
39154
+ }
39155
+ throw new Error("No BankFind financial data found in the latest eight expected reporting quarters.");
39156
+ }
39157
+ async function buildQbpLiteData(params) {
39158
+ const trendQuarters = params.trend_quarters ?? 20;
39159
+ const includeCommunityBanks = params.include_community_banks ?? true;
39160
+ const repdte = await resolveReportDate(params);
39161
+ const priorQuarterRepdte = getPriorQuarterDates(repdte, 1)[0];
39162
+ const yearAgoRepdte = getReportDateOneYearPrior(repdte);
39163
+ const trendDates = [...getPriorQuarterDates(repdte, trendQuarters - 1)].reverse();
39164
+ const requiredDates = [.../* @__PURE__ */ new Set([...trendDates, repdte, priorQuarterRepdte, yearAgoRepdte])].sort();
39165
+ const recordsByDate = /* @__PURE__ */ new Map();
39166
+ const warnings = [];
39167
+ await mapWithConcurrency(requiredDates, 4, async (date) => {
39168
+ const { records, truncationWarning } = await fetchRecordsForRepdte(date);
39169
+ recordsByDate.set(date, records);
39170
+ if (records.length === 0) {
39171
+ warnings.push(`No financial records found for REPDTE ${date}.`);
39172
+ }
39173
+ if (truncationWarning) {
39174
+ warnings.push(truncationWarning);
39175
+ }
39176
+ });
39177
+ const currentRecords = recordsByDate.get(repdte) ?? [];
39178
+ if (currentRecords.length === 0) {
39179
+ throw new Error(`No financial records found for current REPDTE ${repdte}.`);
39180
+ }
39181
+ const trendRecords = new Map(
39182
+ [...trendDates, repdte].filter((date) => (recordsByDate.get(date)?.length ?? 0) > 0).map((date) => [date, recordsByDate.get(date) ?? []])
39183
+ );
39184
+ const trendSeries = [...trendRecords.entries()].map(
39185
+ ([date, records]) => buildSeriesPoint(date, records)
39186
+ );
39187
+ const current = aggregateFinancialRecords(currentRecords);
39188
+ const priorQuarter = recordsByDate.has(priorQuarterRepdte) ? aggregateFinancialRecords(recordsByDate.get(priorQuarterRepdte) ?? []) : null;
39189
+ const yearAgo = recordsByDate.has(yearAgoRepdte) ? aggregateFinancialRecords(recordsByDate.get(yearAgoRepdte) ?? []) : null;
39190
+ return {
39191
+ report: {
39192
+ title: "QBP Lite: FDIC-Insured Institutions",
39193
+ repdte,
39194
+ prior_quarter_repdte: priorQuarterRepdte,
39195
+ year_ago_repdte: yearAgoRepdte,
39196
+ trend_start_repdte: trendSeries[0]?.repdte ?? repdte,
39197
+ trend_end_repdte: repdte
39198
+ },
39199
+ executive_snapshot: buildExecutiveSnapshot(current, priorQuarter, yearAgo),
39200
+ charts: {
39201
+ quarterly_net_income_and_roa: trendSeries.map((point) => ({
39202
+ repdte: point.repdte,
39203
+ net_income: point.net_income,
39204
+ roa_pct: point.roa_pct
39205
+ })),
39206
+ net_interest_margin_by_asset_size: buildAssetSizeNimSeries(trendRecords),
39207
+ loans_and_deposits: buildLoanAndDepositSeries(trendSeries),
39208
+ credit_quality: trendSeries.map((point) => ({
39209
+ repdte: point.repdte,
39210
+ noncurrent_loans_pct: point.noncurrent_loans_pct,
39211
+ net_chargeoffs_pct: point.net_chargeoffs_pct
39212
+ })),
39213
+ loan_performance_by_portfolio: buildPortfolioPerformance(currentRecords),
39214
+ capital_ratios: trendSeries.map((point) => ({
39215
+ repdte: point.repdte,
39216
+ leverage_ratio_pct: point.leverage_ratio_pct,
39217
+ common_equity_tier1_ratio_pct: point.common_equity_tier1_ratio_pct,
39218
+ tier1_risk_based_ratio_pct: point.tier1_risk_based_ratio_pct,
39219
+ total_risk_based_ratio_pct: point.total_risk_based_ratio_pct
39220
+ })),
39221
+ community_banks_vs_industry: includeCommunityBanks ? buildCommunityComparison(trendRecords) : []
39222
+ },
39223
+ data_notes: {
39224
+ source_dataset: "FDIC BankFind financials endpoint",
39225
+ date_field: "REPDTE",
39226
+ dollar_units: "Thousands of dollars unless converted by the client.",
39227
+ aggregation_notes: [
39228
+ "Dollar fields are summed across reporting institutions.",
39229
+ "ROA, ROE, credit-quality ratios, and leverage ratio are weighted using the closest available public denominator field.",
39230
+ "Leverage ratio is asset-weighted using period-end ASSET as a proxy for average assets because the average-asset denominator is not exposed as a separate public BankFind field.",
39231
+ "Net interest margin is weighted by earning assets.",
39232
+ "Risk-based capital ratios are calculated from summed public capital-dollar and risk-weighted-asset fields when available, with public ratio fields used only as a fallback.",
39233
+ "The report uses public quarterly Call Report-derived BankFind data; it is not an official FDIC QBP publication."
39234
+ ],
39235
+ known_exclusions: [
39236
+ "Official Problem Bank List counts are excluded because they depend on confidential CAMELS ratings.",
39237
+ "Deposit Insurance Fund balance, reserve ratio, assessments earned, and fund income or expense are excluded because they are DIF accounting data rather than BankFind institution financials.",
39238
+ "Assessment-rate distribution data is excluded because public BankFind financials do not provide institution assessment-rate ranges.",
39239
+ "Community-bank merger-adjusted prior-period series are excluded; community-bank comparisons use the public CB flag available in BankFind records."
39240
+ ],
39241
+ warnings
39242
+ }
39243
+ };
39244
+ }
39245
+ function formatMetricValue2(value, unit) {
39246
+ if (value === null) return "n/a";
39247
+ if (unit === "percent") return `${value.toFixed(2)}%`;
39248
+ return Math.round(value).toLocaleString();
39249
+ }
39250
+ function formatChangeValue(value, unit) {
39251
+ if (value === null) return "n/a";
39252
+ if (unit === "percentage_points") return `${value.toFixed(2)} ppts`;
39253
+ return Math.round(value).toLocaleString();
39254
+ }
39255
+ function formatQbpLiteText(data) {
39256
+ const lines = [
39257
+ `${data.report.title}`,
39258
+ `Quarter ended: ${data.report.repdte}`,
39259
+ `Prior quarter: ${data.report.prior_quarter_repdte} | Year ago: ${data.report.year_ago_repdte}`,
39260
+ "",
39261
+ "Executive Snapshot"
39262
+ ];
39263
+ for (const metric of data.executive_snapshot) {
39264
+ const current = formatMetricValue2(metric.current, metric.unit);
39265
+ const qoq = formatChangeValue(
39266
+ metric.prior_quarter_change,
39267
+ metric.change_unit
39268
+ );
39269
+ const yoy = formatChangeValue(
39270
+ metric.year_over_year_change,
39271
+ metric.change_unit
39272
+ );
39273
+ lines.push(`- ${metric.label}: ${current}; QoQ change ${qoq}; YoY change ${yoy}`);
39274
+ }
39275
+ lines.push(
39276
+ "",
39277
+ "Chart-ready datasets included: quarterly_net_income_and_roa, net_interest_margin_by_asset_size, loans_and_deposits, credit_quality, loan_performance_by_portfolio, capital_ratios, community_banks_vs_industry.",
39278
+ "",
39279
+ "Known exclusions: official Problem Bank List counts, DIF accounting, assessment-rate distributions, and merger-adjusted community-bank prior-period series."
39280
+ );
39281
+ return truncateIfNeeded(
39282
+ lines.join("\n"),
39283
+ CHARACTER_LIMIT,
39284
+ "Use structuredContent for the complete QBP Lite dataset."
39285
+ );
39286
+ }
39287
+ function registerQbpLiteTools(server) {
39288
+ server.registerTool(
39289
+ "fdic_qbp_lite_data",
39290
+ {
39291
+ title: "Generate QBP Lite Data Bundle",
39292
+ description: "Build chart-ready data for a concise QBP Lite report from reproducible public BankFind quarterly financials. Includes executive snapshot metrics, trend series, community-bank comparison data, source notes, and explicit exclusions for non-public or non-BankFind QBP items.",
39293
+ inputSchema: QbpLiteSchema,
39294
+ outputSchema: FdicAnalysisOutputSchema,
39295
+ annotations: {
39296
+ readOnlyHint: true,
39297
+ destructiveHint: false,
39298
+ idempotentHint: true,
39299
+ openWorldHint: true
39300
+ }
39301
+ },
39302
+ async (params) => {
39303
+ try {
39304
+ const data = await buildQbpLiteData(params);
39305
+ return {
39306
+ content: [{ type: "text", text: formatQbpLiteText(data) }],
39307
+ structuredContent: data
39308
+ };
39309
+ } catch (err) {
39310
+ return formatToolError(err);
39311
+ }
39312
+ }
39313
+ );
39314
+ }
39315
+
39316
+ // src/tools/chatgptRetrieval.ts
39317
+ var import_zod22 = require("zod");
38755
39318
 
38756
39319
  // src/tools/shared/chatgptUrls.ts
38757
39320
  var FDIC_BANKFIND_BASE_URL = "https://banks.data.fdic.gov/bankfind-suite";
@@ -38771,11 +39334,11 @@ function getBranchCitationUrl() {
38771
39334
  }
38772
39335
 
38773
39336
  // src/tools/chatgptRetrieval.ts
38774
- var SearchInputSchema = import_zod21.z.object({
38775
- query: import_zod21.z.string().min(1).describe("Natural-language search query.")
39337
+ var SearchInputSchema = import_zod22.z.object({
39338
+ query: import_zod22.z.string().min(1).describe("Natural-language search query.")
38776
39339
  });
38777
- var FetchInputSchema = import_zod21.z.object({
38778
- id: import_zod21.z.string().min(1).describe(
39340
+ var FetchInputSchema = import_zod22.z.object({
39341
+ id: import_zod22.z.string().min(1).describe(
38779
39342
  "Retrieval item id, such as institution:<CERT>, failure:<CERT>, branch:<UNINUM>, or schema:<endpoint>."
38780
39343
  )
38781
39344
  });
@@ -39213,7 +39776,7 @@ function registerChatGptRetrievalTools(server, options = {}) {
39213
39776
  }
39214
39777
 
39215
39778
  // src/tools/chatgptBankDeepDive.ts
39216
- var import_zod22 = require("zod");
39779
+ var import_zod23 = require("zod");
39217
39780
 
39218
39781
  // src/resources/chatgptAppResources.ts
39219
39782
  var BANK_DEEP_DIVE_WIDGET_URI = "ui://widget/fdic-bank-deep-dive-v1.html";
@@ -39474,9 +40037,9 @@ function registerChatGptAppResources(server) {
39474
40037
  }
39475
40038
 
39476
40039
  // src/tools/chatgptBankDeepDive.ts
39477
- var BankDeepDiveInputSchema = import_zod22.z.object({
39478
- cert: import_zod22.z.number().int().positive().describe("FDIC Certificate Number of the institution to render."),
39479
- repdte: import_zod22.z.string().regex(/^\d{8}$/).optional().describe(
40040
+ var BankDeepDiveInputSchema = import_zod23.z.object({
40041
+ cert: import_zod23.z.number().int().positive().describe("FDIC Certificate Number of the institution to render."),
40042
+ repdte: import_zod23.z.string().regex(/^\d{8}$/).optional().describe(
39480
40043
  "Quarter-end report date in YYYYMMDD format. Defaults to the most recent likely published quarter."
39481
40044
  )
39482
40045
  });
@@ -39775,28 +40338,28 @@ function registerSchemaResources(server) {
39775
40338
  }
39776
40339
 
39777
40340
  // src/prompts/workflows.ts
39778
- var import_zod23 = require("zod");
40341
+ var import_zod24 = require("zod");
39779
40342
  var BankDeepDiveArgs = {
39780
- bank: import_zod23.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
39781
- repdte: import_zod23.z.string().regex(/^\d{8}$/).optional().describe(
40343
+ bank: import_zod24.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
40344
+ repdte: import_zod24.z.string().regex(/^\d{8}$/).optional().describe(
39782
40345
  "Optional quarter-end report date in YYYYMMDD format (0331, 0630, 0930, or 1231)."
39783
40346
  )
39784
40347
  };
39785
40348
  var FailureForensicsArgs = {
39786
- bank: import_zod23.z.string().min(1).describe("Failed bank name or FDIC Certificate Number (CERT)."),
39787
- lookback_quarters: import_zod23.z.string().regex(/^\d+$/).optional().describe(
40349
+ bank: import_zod24.z.string().min(1).describe("Failed bank name or FDIC Certificate Number (CERT)."),
40350
+ lookback_quarters: import_zod24.z.string().regex(/^\d+$/).optional().describe(
39788
40351
  "Number of pre-failure quarters to reconstruct (default 12 if omitted)."
39789
40352
  )
39790
40353
  };
39791
40354
  var PortfolioSurveillanceArgs = {
39792
- scope: import_zod23.z.string().min(1).describe(
40355
+ scope: import_zod24.z.string().min(1).describe(
39793
40356
  "Universe to screen \u2014 e.g., 'state:NC', 'asset_min:1000000,asset_max:10000000', or a comma-separated CERT list ('certs:3511,29846,...')."
39794
40357
  ),
39795
- repdte: import_zod23.z.string().regex(/^\d{8}$/).optional().describe("Optional quarter-end report date in YYYYMMDD format.")
40358
+ repdte: import_zod24.z.string().regex(/^\d{8}$/).optional().describe("Optional quarter-end report date in YYYYMMDD format.")
39796
40359
  };
39797
40360
  var ExaminerOverlayArgs = {
39798
- bank: import_zod23.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
39799
- qualitative_notes: import_zod23.z.string().optional().describe(
40361
+ bank: import_zod24.z.string().min(1).describe("Bank name or FDIC Certificate Number (CERT)."),
40362
+ qualitative_notes: import_zod24.z.string().optional().describe(
39800
40363
  "Optional qualitative analyst inputs (management quality, governance, exam findings) to overlay onto the public proxy assessment."
39801
40364
  )
39802
40365
  };
@@ -39969,6 +40532,7 @@ function createServer(options = {}) {
39969
40532
  registerFranchiseFootprintTools(server);
39970
40533
  registerHoldingCompanyProfileTools(server);
39971
40534
  registerRegionalContextTools(server);
40535
+ registerQbpLiteTools(server);
39972
40536
  }
39973
40537
  if (profile.chatgptCanonical || profile.chatgptAliases) {
39974
40538
  registerChatGptRetrievalTools(server, {
@@ -40020,6 +40584,21 @@ function parseAllowedOrigins(rawOrigins, port) {
40020
40584
  }
40021
40585
  var DEFAULT_SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
40022
40586
  var DEFAULT_SESSION_SWEEP_INTERVAL_MS = 5 * 60 * 1e3;
40587
+ var DEFAULT_MCP_RATE_LIMIT_MAX_REQUESTS = 120;
40588
+ var DEFAULT_MCP_RATE_LIMIT_WINDOW_MS = 6e4;
40589
+ var DEFAULT_MCP_STREAM_RATE_LIMIT_MAX_REQUESTS = 2;
40590
+ var DEFAULT_MCP_STREAM_RATE_LIMIT_WINDOW_MS = 60 * 60 * 1e3;
40591
+ var DEFAULT_MCP_MAX_CONCURRENT_STREAMS_PER_IP = 1;
40592
+ function parsePositiveInteger(rawValue, fallback, name) {
40593
+ if (rawValue === void 0 || rawValue.trim().length === 0) {
40594
+ return fallback;
40595
+ }
40596
+ const value = Number.parseInt(rawValue, 10);
40597
+ if (!Number.isInteger(value) || value < 1) {
40598
+ throw new Error(`${name} must be a positive integer. Received: ${rawValue}`);
40599
+ }
40600
+ return value;
40601
+ }
40023
40602
  async function closeSession(sessions, sessionId) {
40024
40603
  const session = sessions.get(sessionId);
40025
40604
  if (!session) {
@@ -40055,6 +40634,17 @@ function sendInvalidSessionResponse(res) {
40055
40634
  id: null
40056
40635
  });
40057
40636
  }
40637
+ function sendMcpRateLimitResponse(res, retryAfterSeconds) {
40638
+ res.setHeader("Retry-After", String(retryAfterSeconds));
40639
+ res.status(429).json({
40640
+ jsonrpc: "2.0",
40641
+ error: {
40642
+ code: -32e3,
40643
+ message: "Rate limit exceeded. Try again shortly."
40644
+ },
40645
+ id: null
40646
+ });
40647
+ }
40058
40648
  function createApp(options = {}) {
40059
40649
  const app = (0, import_express2.default)();
40060
40650
  const serverFactory = options.serverFactory ?? (() => createServer());
@@ -40065,6 +40655,29 @@ function createApp(options = {}) {
40065
40655
  const sessionIdleTimeoutMs = options.sessionIdleTimeoutMs ?? DEFAULT_SESSION_IDLE_TIMEOUT_MS;
40066
40656
  const sessionSweepIntervalMs = options.sessionSweepIntervalMs ?? DEFAULT_SESSION_SWEEP_INTERVAL_MS;
40067
40657
  const stateless = options.stateless ?? process.env.FDIC_MCP_STATELESS_HTTP === "true";
40658
+ const mcpRateLimiter = options.mcpRateLimiter ?? new RateLimiter({
40659
+ maxRequests: parsePositiveInteger(
40660
+ process.env.MCP_RATE_LIMIT_MAX_REQUESTS_PER_MINUTE,
40661
+ DEFAULT_MCP_RATE_LIMIT_MAX_REQUESTS,
40662
+ "MCP_RATE_LIMIT_MAX_REQUESTS_PER_MINUTE"
40663
+ ),
40664
+ windowMs: DEFAULT_MCP_RATE_LIMIT_WINDOW_MS
40665
+ });
40666
+ const mcpStreamRateLimiter = options.mcpStreamRateLimiter ?? new RateLimiter({
40667
+ maxRequests: parsePositiveInteger(
40668
+ process.env.MCP_STREAM_RATE_LIMIT_MAX_REQUESTS_PER_HOUR,
40669
+ DEFAULT_MCP_STREAM_RATE_LIMIT_MAX_REQUESTS,
40670
+ "MCP_STREAM_RATE_LIMIT_MAX_REQUESTS_PER_HOUR"
40671
+ ),
40672
+ windowMs: DEFAULT_MCP_STREAM_RATE_LIMIT_WINDOW_MS
40673
+ });
40674
+ const mcpStreamConcurrentLimiter = options.mcpStreamConcurrentLimiter ?? new ConcurrentLimiter(
40675
+ parsePositiveInteger(
40676
+ process.env.MCP_MAX_CONCURRENT_STREAMS_PER_IP,
40677
+ DEFAULT_MCP_MAX_CONCURRENT_STREAMS_PER_IP,
40678
+ "MCP_MAX_CONCURRENT_STREAMS_PER_IP"
40679
+ )
40680
+ );
40068
40681
  app.use(import_express2.default.json());
40069
40682
  if (!stateless) {
40070
40683
  const sessionSweepTimer = setInterval(() => {
@@ -40077,6 +40690,31 @@ function createApp(options = {}) {
40077
40690
  res.json({ status: "ok", server: "fdic-mcp-server", version: VERSION });
40078
40691
  });
40079
40692
  app.all("/mcp", async (req, res) => {
40693
+ const requestIp = getRequestIp(req);
40694
+ if (!mcpRateLimiter.check(requestIp)) {
40695
+ sendMcpRateLimitResponse(
40696
+ res,
40697
+ Math.ceil(DEFAULT_MCP_RATE_LIMIT_WINDOW_MS / 1e3)
40698
+ );
40699
+ return;
40700
+ }
40701
+ if (req.method === "GET") {
40702
+ const releaseStream = mcpStreamConcurrentLimiter.acquire(requestIp);
40703
+ if (!releaseStream) {
40704
+ sendMcpRateLimitResponse(res, 60);
40705
+ return;
40706
+ }
40707
+ if (!mcpStreamRateLimiter.check(requestIp)) {
40708
+ releaseStream();
40709
+ sendMcpRateLimitResponse(
40710
+ res,
40711
+ Math.ceil(DEFAULT_MCP_STREAM_RATE_LIMIT_WINDOW_MS / 1e3)
40712
+ );
40713
+ return;
40714
+ }
40715
+ res.once("close", releaseStream);
40716
+ res.once("finish", releaseStream);
40717
+ }
40080
40718
  if (stateless) {
40081
40719
  let server;
40082
40720
  let transport;