fdic-mcp-server 1.0.8 → 1.1.0

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/index.js CHANGED
@@ -30,7 +30,7 @@ var import_streamableHttp = require("@modelcontextprotocol/sdk/server/streamable
30
30
  var import_express = __toESM(require("express"));
31
31
 
32
32
  // src/constants.ts
33
- var VERSION = true ? "1.0.8" : process.env.npm_package_version ?? "0.0.0-dev";
33
+ var VERSION = true ? "1.1.0" : process.env.npm_package_version ?? "0.0.0-dev";
34
34
  var FDIC_API_BASE_URL = "https://banks.data.fdic.gov/api";
35
35
  var CHARACTER_LIMIT = 5e4;
36
36
  var ENDPOINTS = {
@@ -1775,6 +1775,583 @@ Returns concise comparison text plus structured deltas, derived metrics, and ins
1775
1775
  );
1776
1776
  }
1777
1777
 
1778
+ // src/tools/peerGroup.ts
1779
+ var import_zod8 = require("zod");
1780
+ function asNumber2(value) {
1781
+ return typeof value === "number" ? value : null;
1782
+ }
1783
+ function safeRatio(numerator, denominator) {
1784
+ if (numerator === null || denominator === null || denominator === 0) {
1785
+ return null;
1786
+ }
1787
+ return numerator / denominator;
1788
+ }
1789
+ function safeRatioPositiveDenom(numerator, denominator) {
1790
+ if (numerator === null || denominator === null || denominator <= 0) {
1791
+ return null;
1792
+ }
1793
+ return numerator / denominator;
1794
+ }
1795
+ function deriveMetrics(raw) {
1796
+ const asset = asNumber2(raw.ASSET);
1797
+ const dep = asNumber2(raw.DEP);
1798
+ const eqtot = asNumber2(raw.EQTOT);
1799
+ const lnlsnet = asNumber2(raw.LNLSNET);
1800
+ const intinc = asNumber2(raw.INTINC);
1801
+ const eintexp = asNumber2(raw.EINTEXP);
1802
+ const nonii = asNumber2(raw.NONII);
1803
+ const nonix = asNumber2(raw.NONIX);
1804
+ const netInterestIncome = intinc !== null && eintexp !== null ? intinc - eintexp : null;
1805
+ const revenueDenominator = netInterestIncome !== null && nonii !== null ? netInterestIncome + nonii : null;
1806
+ const equityRatioRaw = safeRatio(eqtot, asset);
1807
+ const efficiencyRatioRaw = safeRatioPositiveDenom(nonix, revenueDenominator);
1808
+ return {
1809
+ asset,
1810
+ dep,
1811
+ roa: asNumber2(raw.ROA),
1812
+ roe: asNumber2(raw.ROE),
1813
+ netnim: asNumber2(raw.NETNIM),
1814
+ equity_ratio: equityRatioRaw !== null ? equityRatioRaw * 100 : null,
1815
+ efficiency_ratio: efficiencyRatioRaw !== null ? efficiencyRatioRaw * 100 : null,
1816
+ loan_to_deposit: safeRatio(lnlsnet, dep),
1817
+ deposits_to_assets: safeRatio(dep, asset),
1818
+ noninterest_income_share: safeRatioPositiveDenom(nonii, revenueDenominator)
1819
+ };
1820
+ }
1821
+ function computeMedian(values) {
1822
+ if (values.length === 0) return null;
1823
+ const sorted = [...values].sort((a, b) => a - b);
1824
+ const mid = Math.floor(sorted.length / 2);
1825
+ if (sorted.length % 2 === 1) return sorted[mid];
1826
+ return (sorted[mid - 1] + sorted[mid]) / 2;
1827
+ }
1828
+ function computeCompetitionRank(subjectValue, peerValues, higherIsBetter) {
1829
+ if (peerValues.length === 0) return null;
1830
+ const ascending = higherIsBetter === false;
1831
+ const all = [...peerValues, subjectValue];
1832
+ const sorted = [...all].sort(
1833
+ (a, b) => ascending ? a - b : b - a
1834
+ );
1835
+ const ranks = /* @__PURE__ */ new Map();
1836
+ for (let i = 0; i < sorted.length; i++) {
1837
+ if (!ranks.has(sorted[i])) {
1838
+ ranks.set(sorted[i], i + 1);
1839
+ }
1840
+ }
1841
+ const rank = ranks.get(subjectValue);
1842
+ const of = peerValues.length;
1843
+ const percentile = Math.round((1 - (rank - 1) / of) * 100);
1844
+ return { rank, of, percentile };
1845
+ }
1846
+ function formatRepdteHuman(repdte) {
1847
+ if (repdte.length !== 8) return repdte;
1848
+ const year = repdte.slice(0, 4);
1849
+ const month = repdte.slice(4, 6);
1850
+ const day = repdte.slice(6, 8);
1851
+ const date = /* @__PURE__ */ new Date(`${year}-${month}-${day}T00:00:00Z`);
1852
+ if (Number.isNaN(date.getTime())) return repdte;
1853
+ return date.toLocaleDateString("en-US", {
1854
+ year: "numeric",
1855
+ month: "long",
1856
+ day: "numeric",
1857
+ timeZone: "UTC"
1858
+ });
1859
+ }
1860
+ var METRIC_KEYS = [
1861
+ "asset",
1862
+ "dep",
1863
+ "roa",
1864
+ "roe",
1865
+ "netnim",
1866
+ "equity_ratio",
1867
+ "efficiency_ratio",
1868
+ "loan_to_deposit",
1869
+ "deposits_to_assets",
1870
+ "noninterest_income_share"
1871
+ ];
1872
+ var METRIC_DEFINITIONS = {
1873
+ asset: { higher_is_better: true, unit: "$thousands", label: "Total Assets" },
1874
+ dep: { higher_is_better: true, unit: "$thousands", label: "Total Deposits" },
1875
+ roa: { higher_is_better: true, unit: "%", label: "Return on Assets" },
1876
+ roe: { higher_is_better: true, unit: "%", label: "Return on Equity" },
1877
+ netnim: {
1878
+ higher_is_better: true,
1879
+ unit: "%",
1880
+ label: "Net Interest Margin"
1881
+ },
1882
+ equity_ratio: {
1883
+ higher_is_better: true,
1884
+ unit: "%",
1885
+ label: "Equity Capital Ratio"
1886
+ },
1887
+ efficiency_ratio: {
1888
+ higher_is_better: false,
1889
+ unit: "%",
1890
+ label: "Efficiency Ratio"
1891
+ },
1892
+ loan_to_deposit: {
1893
+ higher_is_better: null,
1894
+ unit: "ratio",
1895
+ label: "Loan-to-Deposit Ratio",
1896
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
1897
+ },
1898
+ deposits_to_assets: {
1899
+ higher_is_better: null,
1900
+ unit: "ratio",
1901
+ label: "Deposits-to-Assets Ratio",
1902
+ ranking_note: "Rank and percentile reflect position by value (1 = highest). Directionality is context-dependent."
1903
+ },
1904
+ noninterest_income_share: {
1905
+ higher_is_better: true,
1906
+ unit: "ratio",
1907
+ label: "Non-Interest Income Share"
1908
+ }
1909
+ };
1910
+ var PeerGroupInputSchema = import_zod8.z.object({
1911
+ cert: import_zod8.z.number().int().positive().optional().describe(
1912
+ "Subject institution CERT number. When provided, auto-derives peer criteria and ranks this bank against peers."
1913
+ ),
1914
+ repdte: import_zod8.z.string().regex(/^\d{8}$/).describe("Report date in YYYYMMDD format."),
1915
+ asset_min: import_zod8.z.number().positive().optional().describe(
1916
+ "Minimum total assets ($thousands) for peer selection. Defaults to 50% of subject's report-date assets when cert is provided."
1917
+ ),
1918
+ asset_max: import_zod8.z.number().positive().optional().describe(
1919
+ "Maximum total assets ($thousands) for peer selection. Defaults to 200% of subject's report-date assets when cert is provided."
1920
+ ),
1921
+ charter_classes: import_zod8.z.array(import_zod8.z.string()).optional().describe(
1922
+ `Charter class codes to include (e.g., ["N", "SM"]). Defaults to the subject's charter class when cert is provided.`
1923
+ ),
1924
+ state: import_zod8.z.string().regex(/^[A-Z]{2}$/).optional().describe('Two-letter state code (e.g., "NC", "TX").'),
1925
+ raw_filter: import_zod8.z.string().optional().describe(
1926
+ "Advanced: raw ElasticSearch query string appended to peer selection criteria with AND."
1927
+ ),
1928
+ active_only: import_zod8.z.boolean().default(true).describe(
1929
+ "Limit to institutions where ACTIVE:1 (currently operating, FDIC-insured)."
1930
+ ),
1931
+ extra_fields: import_zod8.z.array(import_zod8.z.string()).optional().describe(
1932
+ "Additional FDIC field names to include as raw values in the response. Does not affect peer selection."
1933
+ ),
1934
+ limit: import_zod8.z.number().int().min(1).max(500).default(50).describe(
1935
+ "Max peer records returned in the response. All matched peers are used for ranking regardless of this limit."
1936
+ )
1937
+ }).superRefine((value, ctx) => {
1938
+ if (!value.cert && value.asset_min === void 0 && value.asset_max === void 0 && !value.charter_classes && !value.state && !value.raw_filter) {
1939
+ ctx.addIssue({
1940
+ code: import_zod8.z.ZodIssueCode.custom,
1941
+ message: "At least one peer-group constructor is required: cert, asset_min, asset_max, charter_classes, state, or raw_filter.",
1942
+ path: ["cert"]
1943
+ });
1944
+ }
1945
+ if (value.asset_min !== void 0 && value.asset_max !== void 0 && value.asset_min > value.asset_max) {
1946
+ ctx.addIssue({
1947
+ code: import_zod8.z.ZodIssueCode.custom,
1948
+ message: "asset_min must be <= asset_max.",
1949
+ path: ["asset_min"]
1950
+ });
1951
+ }
1952
+ });
1953
+ var CHUNK_SIZE2 = 25;
1954
+ var MAX_CONCURRENCY2 = 4;
1955
+ var ANALYSIS_TIMEOUT_MS2 = 9e4;
1956
+ var FINANCIAL_FIELDS = "CERT,ASSET,DEP,NETINC,ROA,ROE,NETNIM,EQTOT,LNLSNET,INTINC,EINTEXP,NONII,NONIX";
1957
+ function buildCertFilters2(certs) {
1958
+ const filters = [];
1959
+ for (let i = 0; i < certs.length; i += CHUNK_SIZE2) {
1960
+ const chunk = certs.slice(i, i + CHUNK_SIZE2);
1961
+ filters.push(chunk.map((cert) => `CERT:${cert}`).join(" OR "));
1962
+ }
1963
+ return filters;
1964
+ }
1965
+ async function mapWithConcurrency2(values, limit, mapper) {
1966
+ const results = new Array(values.length);
1967
+ let nextIndex = 0;
1968
+ async function worker() {
1969
+ while (true) {
1970
+ const currentIndex = nextIndex;
1971
+ nextIndex += 1;
1972
+ if (currentIndex >= values.length) return;
1973
+ results[currentIndex] = await mapper(values[currentIndex], currentIndex);
1974
+ }
1975
+ }
1976
+ await Promise.all(
1977
+ Array.from({ length: Math.min(limit, values.length) }, () => worker())
1978
+ );
1979
+ return results;
1980
+ }
1981
+ function formatMetricValue(key, value) {
1982
+ if (value === null) return "n/a";
1983
+ const def = METRIC_DEFINITIONS[key];
1984
+ if (def.unit === "$thousands")
1985
+ return `$${Math.round(value).toLocaleString()}k`;
1986
+ if (def.unit === "%") return `${value.toFixed(4)}%`;
1987
+ return value.toFixed(4);
1988
+ }
1989
+ function ordinalSuffix(n) {
1990
+ const mod100 = n % 100;
1991
+ if (mod100 >= 11 && mod100 <= 13) return `${n}th`;
1992
+ const mod10 = n % 10;
1993
+ if (mod10 === 1) return `${n}st`;
1994
+ if (mod10 === 2) return `${n}nd`;
1995
+ if (mod10 === 3) return `${n}rd`;
1996
+ return `${n}th`;
1997
+ }
1998
+ function formatPeerGroupText(repdte, subjectProfile, subjectMetrics, rankings, medians, returnedPeers, peerCount, warnings) {
1999
+ const parts = [];
2000
+ for (const warning of warnings) {
2001
+ parts.push(`Warning: ${warning}`);
2002
+ }
2003
+ const dateStr = formatRepdteHuman(repdte);
2004
+ const subjectLabel = subjectProfile ? ` for ${subjectProfile.NAME} (CERT ${subjectProfile.CERT ?? ""})` : "";
2005
+ parts.push(`Peer group analysis${subjectLabel} as of ${dateStr}.`);
2006
+ parts.push(`${peerCount} peers matched.`);
2007
+ if (subjectMetrics && subjectProfile) {
2008
+ parts.push("");
2009
+ parts.push("Subject rankings:");
2010
+ for (const key of METRIC_KEYS) {
2011
+ const def = METRIC_DEFINITIONS[key];
2012
+ const ranking = rankings[key];
2013
+ const value = formatMetricValue(key, subjectMetrics[key]);
2014
+ const medianValue = formatMetricValue(key, medians[key] ?? null);
2015
+ if (ranking) {
2016
+ const pctLabel = `${ordinalSuffix(ranking.percentile)} percentile`;
2017
+ parts.push(
2018
+ ` ${def.label.padEnd(28)} rank ${String(ranking.rank).padStart(2)} of ${String(ranking.of).padEnd(4)} (${pctLabel.padEnd(18)}) ${value.padStart(16)} median: ${medianValue}`
2019
+ );
2020
+ } else {
2021
+ parts.push(` ${def.label.padEnd(28)} n/a`);
2022
+ }
2023
+ }
2024
+ } else if (peerCount > 0) {
2025
+ parts.push("");
2026
+ parts.push("Peer group medians:");
2027
+ const medianParts = METRIC_KEYS.filter((k) => medians[k] !== null).map(
2028
+ (k) => `${METRIC_DEFINITIONS[k].label}: ${formatMetricValue(k, medians[k] ?? null)}`
2029
+ );
2030
+ parts.push(` ${medianParts.join(" | ")}`);
2031
+ }
2032
+ if (returnedPeers.length > 0) {
2033
+ parts.push("");
2034
+ parts.push(`Peers (${returnedPeers.length} returned):`);
2035
+ for (let i = 0; i < returnedPeers.length; i++) {
2036
+ const p = returnedPeers[i];
2037
+ const location = [p.city, p.stalp].filter(Boolean).join(" ");
2038
+ const locationStr = location ? `, ${location}` : "";
2039
+ parts.push(
2040
+ `${i + 1}. ${p.name}${locationStr} (CERT ${p.cert}) | Asset: ${formatMetricValue("asset", p.metrics.asset)} | ROA: ${formatMetricValue("roa", p.metrics.roa)} | ROE: ${formatMetricValue("roe", p.metrics.roe)}`
2041
+ );
2042
+ }
2043
+ }
2044
+ return parts.join("\n");
2045
+ }
2046
+ function registerPeerGroupTools(server) {
2047
+ server.registerTool(
2048
+ "fdic_peer_group_analysis",
2049
+ {
2050
+ title: "Peer Group Analysis",
2051
+ description: `Build a peer group for an FDIC-insured institution and rank it against peers on financial and efficiency metrics at a single report date.
2052
+
2053
+ Three usage modes:
2054
+ - Subject-driven: provide cert and repdte \u2014 auto-derives peer criteria from the subject's asset size and charter class
2055
+ - Explicit criteria: provide repdte plus asset_min/asset_max, charter_classes, state, or raw_filter
2056
+ - Subject with overrides: provide cert plus explicit criteria to override auto-derived defaults
2057
+
2058
+ Metrics ranked (fixed order):
2059
+ - Total Assets, Total Deposits, ROA, ROE, Net Interest Margin
2060
+ - Equity Capital Ratio, Efficiency Ratio, Loan-to-Deposit Ratio
2061
+ - Deposits-to-Assets Ratio, Non-Interest Income Share
2062
+
2063
+ Rankings use competition rank (1, 2, 2, 4) with metric-specific denominators. Subject is excluded from peer set.
2064
+
2065
+ Output includes:
2066
+ - Subject rankings and percentiles (when cert provided)
2067
+ - Peer group medians
2068
+ - Peer list with CERTs (pass to fdic_compare_bank_snapshots for trend analysis)
2069
+ - Metric definitions with directionality metadata
2070
+
2071
+ Override precedence: cert derives defaults, then explicit params override them.`,
2072
+ inputSchema: PeerGroupInputSchema,
2073
+ annotations: {
2074
+ readOnlyHint: true,
2075
+ destructiveHint: false,
2076
+ idempotentHint: true,
2077
+ openWorldHint: true
2078
+ }
2079
+ },
2080
+ async (params) => {
2081
+ const controller = new AbortController();
2082
+ const timeoutId = setTimeout(() => controller.abort(), ANALYSIS_TIMEOUT_MS2);
2083
+ try {
2084
+ const warnings = [];
2085
+ let subjectProfile = null;
2086
+ let subjectFinancials = null;
2087
+ if (params.cert) {
2088
+ const [profileResponse, financialsResponse] = await Promise.all([
2089
+ queryEndpoint(
2090
+ ENDPOINTS.INSTITUTIONS,
2091
+ {
2092
+ filters: `CERT:${params.cert}`,
2093
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2094
+ limit: 1
2095
+ },
2096
+ { signal: controller.signal }
2097
+ ),
2098
+ queryEndpoint(
2099
+ ENDPOINTS.FINANCIALS,
2100
+ {
2101
+ filters: `CERT:${params.cert} AND REPDTE:${params.repdte}`,
2102
+ fields: FINANCIAL_FIELDS,
2103
+ limit: 1
2104
+ },
2105
+ { signal: controller.signal }
2106
+ )
2107
+ ]);
2108
+ const profileRecords = extractRecords(profileResponse);
2109
+ if (profileRecords.length === 0) {
2110
+ return formatToolError(
2111
+ new Error(
2112
+ `No institution found with CERT number ${params.cert}.`
2113
+ )
2114
+ );
2115
+ }
2116
+ subjectProfile = profileRecords[0];
2117
+ const financialRecords = extractRecords(financialsResponse);
2118
+ if (financialRecords.length === 0) {
2119
+ return formatToolError(
2120
+ new Error(
2121
+ `No financial data for CERT ${params.cert} at report date ${params.repdte}. Auto-derivation of peer criteria requires asset data at the specified report date.`
2122
+ )
2123
+ );
2124
+ }
2125
+ subjectFinancials = financialRecords[0];
2126
+ }
2127
+ const subjectAsset = subjectFinancials && typeof subjectFinancials.ASSET === "number" ? subjectFinancials.ASSET : null;
2128
+ const assetMin = params.asset_min ?? (subjectAsset !== null ? subjectAsset * 0.5 : void 0);
2129
+ const assetMax = params.asset_max ?? (subjectAsset !== null ? subjectAsset * 2 : void 0);
2130
+ const charterClasses = params.charter_classes ?? (subjectProfile && typeof subjectProfile.BKCLASS === "string" ? [subjectProfile.BKCLASS] : void 0);
2131
+ const { state, active_only, raw_filter } = params;
2132
+ const filterParts = [];
2133
+ if (assetMin !== void 0 || assetMax !== void 0) {
2134
+ const min = assetMin ?? 0;
2135
+ const max = assetMax ?? "*";
2136
+ filterParts.push(`ASSET:[${min} TO ${max}]`);
2137
+ }
2138
+ if (charterClasses && charterClasses.length > 0) {
2139
+ const classFilter = charterClasses.map((cls) => `BKCLASS:${cls}`).join(" OR ");
2140
+ filterParts.push(
2141
+ charterClasses.length > 1 ? `(${classFilter})` : classFilter
2142
+ );
2143
+ }
2144
+ if (state) filterParts.push(`STALP:${state}`);
2145
+ if (active_only) filterParts.push("ACTIVE:1");
2146
+ if (raw_filter) filterParts.push(`(${raw_filter})`);
2147
+ const rosterResponse = await queryEndpoint(
2148
+ ENDPOINTS.INSTITUTIONS,
2149
+ {
2150
+ filters: filterParts.join(" AND "),
2151
+ fields: "CERT,NAME,CITY,STALP,BKCLASS",
2152
+ limit: 1e4,
2153
+ offset: 0,
2154
+ sort_by: "CERT",
2155
+ sort_order: "ASC"
2156
+ },
2157
+ { signal: controller.signal }
2158
+ );
2159
+ let rosterRecords = extractRecords(rosterResponse);
2160
+ if (rosterResponse.meta.total > rosterRecords.length) {
2161
+ warnings.push(
2162
+ `Institution roster truncated to ${rosterRecords.length.toLocaleString()} records out of ${rosterResponse.meta.total.toLocaleString()} matched institutions. Narrow the peer group criteria for complete analysis.`
2163
+ );
2164
+ }
2165
+ if (params.cert) {
2166
+ rosterRecords = rosterRecords.filter(
2167
+ (r) => asNumber2(r.CERT) !== params.cert
2168
+ );
2169
+ }
2170
+ const criteriaUsed = {
2171
+ asset_min: assetMin ?? null,
2172
+ asset_max: assetMax ?? null,
2173
+ charter_classes: charterClasses ?? null,
2174
+ state: state ?? null,
2175
+ active_only,
2176
+ raw_filter: raw_filter ?? null
2177
+ };
2178
+ if (rosterRecords.length === 0) {
2179
+ const subjectMetrics2 = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2180
+ const output2 = {};
2181
+ if (subjectProfile) {
2182
+ output2.subject = {
2183
+ cert: params.cert,
2184
+ name: subjectProfile.NAME,
2185
+ city: subjectProfile.CITY,
2186
+ stalp: subjectProfile.STALP,
2187
+ bkclass: subjectProfile.BKCLASS,
2188
+ metrics: subjectMetrics2,
2189
+ rankings: null
2190
+ };
2191
+ }
2192
+ output2.peer_group = {
2193
+ repdte: params.repdte,
2194
+ criteria_used: criteriaUsed,
2195
+ medians: {}
2196
+ };
2197
+ output2.metric_definitions = METRIC_DEFINITIONS;
2198
+ output2.peers = [];
2199
+ output2.peer_count = 0;
2200
+ output2.returned_count = 0;
2201
+ output2.has_more = false;
2202
+ output2.message = "No peers matched the specified criteria.";
2203
+ output2.warnings = warnings;
2204
+ const text2 = formatPeerGroupText(
2205
+ params.repdte,
2206
+ subjectProfile,
2207
+ subjectMetrics2,
2208
+ {},
2209
+ {},
2210
+ [],
2211
+ 0,
2212
+ warnings
2213
+ );
2214
+ return {
2215
+ content: [{ type: "text", text: text2 }],
2216
+ structuredContent: output2
2217
+ };
2218
+ }
2219
+ const peerCerts = rosterRecords.map((r) => asNumber2(r.CERT)).filter((c) => c !== null);
2220
+ const certFilters = buildCertFilters2(peerCerts);
2221
+ const extraFieldsCsv = params.extra_fields && params.extra_fields.length > 0 ? "," + params.extra_fields.join(",") : "";
2222
+ const financialResponses = await mapWithConcurrency2(
2223
+ certFilters,
2224
+ MAX_CONCURRENCY2,
2225
+ async (certFilter) => queryEndpoint(
2226
+ ENDPOINTS.FINANCIALS,
2227
+ {
2228
+ filters: `(${certFilter}) AND REPDTE:${params.repdte}`,
2229
+ fields: FINANCIAL_FIELDS + extraFieldsCsv,
2230
+ limit: 1e4,
2231
+ offset: 0,
2232
+ sort_by: "CERT",
2233
+ sort_order: "ASC"
2234
+ },
2235
+ { signal: controller.signal }
2236
+ )
2237
+ );
2238
+ const peerFinancialsByCert = /* @__PURE__ */ new Map();
2239
+ for (const response of financialResponses) {
2240
+ for (const record of extractRecords(response)) {
2241
+ const cert = asNumber2(record.CERT);
2242
+ if (cert !== null) peerFinancialsByCert.set(cert, record);
2243
+ }
2244
+ }
2245
+ const rosterByCert = new Map(
2246
+ rosterRecords.map((r) => [asNumber2(r.CERT), r]).filter(
2247
+ (e) => e[0] !== null
2248
+ )
2249
+ );
2250
+ const peers = [];
2251
+ for (const [cert, financials] of peerFinancialsByCert) {
2252
+ const roster = rosterByCert.get(cert);
2253
+ const metrics = deriveMetrics(financials);
2254
+ const extraFields = {};
2255
+ if (params.extra_fields) {
2256
+ for (const field of params.extra_fields) {
2257
+ extraFields[field] = financials[field] ?? null;
2258
+ }
2259
+ }
2260
+ peers.push({
2261
+ cert,
2262
+ name: String(roster?.NAME ?? financials.NAME ?? cert),
2263
+ city: roster?.CITY != null ? String(roster.CITY) : null,
2264
+ stalp: roster?.STALP != null ? String(roster.STALP) : null,
2265
+ bkclass: roster?.BKCLASS != null ? String(roster.BKCLASS) : null,
2266
+ metrics,
2267
+ extraFields
2268
+ });
2269
+ }
2270
+ const peerCount = peers.length;
2271
+ const subjectMetrics = subjectFinancials ? deriveMetrics(subjectFinancials) : null;
2272
+ const rankings = {};
2273
+ const medians = {};
2274
+ for (const key of METRIC_KEYS) {
2275
+ const peerValues = peers.map((p) => p.metrics[key]).filter((v) => v !== null);
2276
+ medians[key] = computeMedian(peerValues);
2277
+ if (subjectMetrics && subjectMetrics[key] !== null) {
2278
+ rankings[key] = computeCompetitionRank(
2279
+ subjectMetrics[key],
2280
+ peerValues,
2281
+ METRIC_DEFINITIONS[key].higher_is_better
2282
+ );
2283
+ } else {
2284
+ rankings[key] = null;
2285
+ }
2286
+ }
2287
+ peers.sort((a, b) => (b.metrics.asset ?? 0) - (a.metrics.asset ?? 0));
2288
+ const returnedPeers = peers.slice(0, params.limit);
2289
+ const returnedCount = returnedPeers.length;
2290
+ const hasMore = peerCount > returnedCount;
2291
+ const output = {};
2292
+ if (subjectProfile && subjectMetrics) {
2293
+ output.subject = {
2294
+ cert: params.cert,
2295
+ name: subjectProfile.NAME,
2296
+ city: subjectProfile.CITY,
2297
+ stalp: subjectProfile.STALP,
2298
+ bkclass: subjectProfile.BKCLASS,
2299
+ metrics: subjectMetrics,
2300
+ rankings
2301
+ };
2302
+ }
2303
+ output.peer_group = {
2304
+ repdte: params.repdte,
2305
+ criteria_used: criteriaUsed,
2306
+ medians
2307
+ };
2308
+ output.metric_definitions = METRIC_DEFINITIONS;
2309
+ output.peers = returnedPeers.map((p) => ({
2310
+ cert: p.cert,
2311
+ name: p.name,
2312
+ city: p.city,
2313
+ stalp: p.stalp,
2314
+ metrics: p.metrics,
2315
+ ...p.extraFields
2316
+ }));
2317
+ output.peer_count = peerCount;
2318
+ output.returned_count = returnedCount;
2319
+ output.has_more = hasMore;
2320
+ output.message = null;
2321
+ output.warnings = warnings;
2322
+ const text = truncateIfNeeded(
2323
+ formatPeerGroupText(
2324
+ params.repdte,
2325
+ subjectProfile,
2326
+ subjectMetrics,
2327
+ rankings,
2328
+ medians,
2329
+ returnedPeers,
2330
+ peerCount,
2331
+ warnings
2332
+ ),
2333
+ CHARACTER_LIMIT
2334
+ );
2335
+ return {
2336
+ content: [{ type: "text", text }],
2337
+ structuredContent: output
2338
+ };
2339
+ } catch (err) {
2340
+ if (controller.signal.aborted) {
2341
+ return formatToolError(
2342
+ new Error(
2343
+ `Peer group analysis timed out after ${Math.floor(ANALYSIS_TIMEOUT_MS2 / 1e3)} seconds. Narrow the peer group criteria and try again.`
2344
+ )
2345
+ );
2346
+ }
2347
+ return formatToolError(err);
2348
+ } finally {
2349
+ clearTimeout(timeoutId);
2350
+ }
2351
+ }
2352
+ );
2353
+ }
2354
+
1778
2355
  // src/index.ts
1779
2356
  function createServer() {
1780
2357
  const server = new import_mcp.McpServer({
@@ -1789,6 +2366,7 @@ function createServer() {
1789
2366
  registerSodTools(server);
1790
2367
  registerDemographicsTools(server);
1791
2368
  registerAnalysisTools(server);
2369
+ registerPeerGroupTools(server);
1792
2370
  return server;
1793
2371
  }
1794
2372
  async function runStdio() {