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/README.md +144 -10
- package/dist/index.js +579 -1
- package/dist/server.js +579 -1
- package/package.json +3 -2
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
|
|
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() {
|