@lendwise/mcp 0.1.2 → 0.1.3

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.
@@ -28,6 +28,8 @@ export interface RewardItem {
28
28
  export interface MarketState {
29
29
  supplyAssetsUsd: number | null;
30
30
  utilizationRate: number | null;
31
+ /** Borrow snapshots only — supply documents don't select it, so it's absent there. */
32
+ borrowAssetsUsd?: number | null;
31
33
  }
32
34
  export interface Quality {
33
35
  status: string;
@@ -42,6 +44,10 @@ export interface SnapshotRow {
42
44
  apy: ApyBreakdown;
43
45
  market: MarketState;
44
46
  quality: Quality;
47
+ /** Accepted collateral assets — borrow snapshots only, absent on supply rows. */
48
+ collaterals?: {
49
+ symbol: string;
50
+ }[];
45
51
  product: {
46
52
  protocol: {
47
53
  provider: string;
@@ -116,6 +122,17 @@ export interface ProductRow {
116
122
  }
117
123
  export declare const PRODUCT_FACETS = "\n query ProductFacets($filters: ProductFilters) {\n productFacets(filters: $filters) {\n assets {\n symbol\n count\n }\n chains {\n id\n name\n count\n }\n protocols {\n name\n count\n }\n }\n }\n";
118
124
  export declare const LATEST_SUPPLY_APY = "\n query LatestSupplyApy($filters: LatestFilters, $first: Int) {\n latestSupplyApy(\n filters: $filters\n first: $first\n orderBy: apyNet\n orderDirection: desc\n ) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n pagination {\n count\n countTotal\n limit\n skip\n }\n\n }\n }\n";
125
+ /**
126
+ * Current best borrow markets. Borrow net APY is a COST (base + fees − rewards),
127
+ * so the cheapest market has the LOWEST net — the opposite of supply.
128
+ *
129
+ * `orderDirection: asc` is set explicitly as defense in depth. Upstream now
130
+ * defaults `latestBorrowApy` to asc too (best-first, kind-aware), so this only
131
+ * restates that default — but pinning it here keeps the ranking correct even if
132
+ * that default is ever changed back, and makes the intent legible at the call
133
+ * site rather than depending on a server-side convention.
134
+ */
135
+ export declare const LATEST_BORROW_APY = "\n query LatestBorrowApy($filters: LatestBorrowFilters, $first: Int) {\n latestBorrowApy(\n filters: $filters\n first: $first\n orderBy: apyNet\n orderDirection: asc\n ) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n }\n market {\n supplyAssetsUsd\n borrowAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n collaterals {\n symbol\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n pagination {\n count\n countTotal\n limit\n skip\n }\n\n }\n }\n";
119
136
  /**
120
137
  * Latest snapshot for an explicit set of products — the optimizer's APY source.
121
138
  *
@@ -131,3 +148,10 @@ export declare const LATEST_SUPPLY_BY_PRODUCTS = "\n query LatestSupplyByProduc
131
148
  */
132
149
  export declare const MARKET_DETAILS = "\n query MarketDetails($productId: String!) {\n products(filters: { productId: $productId }, first: 1) {\n items {\n id\n kind\n asset {\n symbol\n name\n address\n decimals\n }\n protocol {\n provider\n type\n version\n name\n chain {\n id\n name\n }\n address\n meta\n }\n collaterals {\n symbol\n name\n ltv\n lltv\n canBeCollateral\n }\n }\n }\n latestSupplyApy(filters: { productId: $productId }, first: 1) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n rewardItems {\n token {\n symbol\n }\n apy\n source\n program\n }\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n\n }\n latestBorrowApy(filters: { productId: $productId }, first: 1) {\n \n items {\n hour\n productId\n asset\n chainId\n apy {\n base\n rewards\n fees\n net\n rewardItems {\n token {\n symbol\n }\n apy\n source\n program\n }\n }\n market {\n supplyAssetsUsd\n utilizationRate\n }\n quality {\n status\n count\n expectedCount\n }\n product {\n protocol {\n provider\n name\n chain {\n id\n name\n }\n }\n }\n }\n\n }\n }\n";
133
150
  export declare const MARKET_HISTORY = "\n query MarketHistory($productId: String!, $range: String!) {\n supplyApyDaily(\n filters: { productId: $productId, range: $range }\n first: 500\n orderBy: time\n orderDirection: asc\n ) {\n items {\n date\n apy {\n net\n base\n rewards\n }\n }\n pagination {\n count\n countTotal\n }\n }\n }\n";
151
+ /**
152
+ * The borrow-side twin of MARKET_HISTORY. Same daily shape and the same
153
+ * mean/stddev question — but for borrow the stat measures how much the *cost*
154
+ * moves, not the yield. A stable borrow rate is the signal a fixed-horizon
155
+ * borrower needs.
156
+ */
157
+ export declare const BORROW_HISTORY = "\n query BorrowHistory($productId: String!, $range: String!) {\n borrowApyDaily(\n filters: { productId: $productId, range: $range }\n first: 500\n orderBy: time\n orderDirection: asc\n ) {\n items {\n date\n apy {\n net\n base\n rewards\n }\n }\n pagination {\n count\n countTotal\n }\n }\n }\n";
@@ -81,6 +81,77 @@ export const LATEST_SUPPLY_APY = /* GraphQL */ `
81
81
  }
82
82
  }
83
83
  `;
84
+ /**
85
+ * Borrow rows carry two fields supply rows don't: `borrowAssetsUsd` (how much of
86
+ * the pool is drawn) and the `collaterals` accepted against the loan. Selecting
87
+ * them here — not on the shared supply fragment — is deliberate: a supply market
88
+ * state has no `borrowAssetsUsd`, so asking for it there is a schema error.
89
+ */
90
+ const BORROW_SNAPSHOT_FIELDS = /* GraphQL */ `
91
+ items {
92
+ hour
93
+ productId
94
+ asset
95
+ chainId
96
+ apy {
97
+ base
98
+ rewards
99
+ fees
100
+ net
101
+ }
102
+ market {
103
+ supplyAssetsUsd
104
+ borrowAssetsUsd
105
+ utilizationRate
106
+ }
107
+ quality {
108
+ status
109
+ count
110
+ expectedCount
111
+ }
112
+ collaterals {
113
+ symbol
114
+ }
115
+ product {
116
+ protocol {
117
+ provider
118
+ name
119
+ chain {
120
+ id
121
+ name
122
+ }
123
+ }
124
+ }
125
+ }
126
+ pagination {
127
+ count
128
+ countTotal
129
+ limit
130
+ skip
131
+ }
132
+ `;
133
+ /**
134
+ * Current best borrow markets. Borrow net APY is a COST (base + fees − rewards),
135
+ * so the cheapest market has the LOWEST net — the opposite of supply.
136
+ *
137
+ * `orderDirection: asc` is set explicitly as defense in depth. Upstream now
138
+ * defaults `latestBorrowApy` to asc too (best-first, kind-aware), so this only
139
+ * restates that default — but pinning it here keeps the ranking correct even if
140
+ * that default is ever changed back, and makes the intent legible at the call
141
+ * site rather than depending on a server-side convention.
142
+ */
143
+ export const LATEST_BORROW_APY = /* GraphQL */ `
144
+ query LatestBorrowApy($filters: LatestBorrowFilters, $first: Int) {
145
+ latestBorrowApy(
146
+ filters: $filters
147
+ first: $first
148
+ orderBy: apyNet
149
+ orderDirection: asc
150
+ ) {
151
+ ${BORROW_SNAPSHOT_FIELDS}
152
+ }
153
+ }
154
+ `;
84
155
  /**
85
156
  * Latest snapshot for an explicit set of products — the optimizer's APY source.
86
157
  *
@@ -205,3 +276,32 @@ export const MARKET_HISTORY = /* GraphQL */ `
205
276
  }
206
277
  }
207
278
  `;
279
+ /**
280
+ * The borrow-side twin of MARKET_HISTORY. Same daily shape and the same
281
+ * mean/stddev question — but for borrow the stat measures how much the *cost*
282
+ * moves, not the yield. A stable borrow rate is the signal a fixed-horizon
283
+ * borrower needs.
284
+ */
285
+ export const BORROW_HISTORY = /* GraphQL */ `
286
+ query BorrowHistory($productId: String!, $range: String!) {
287
+ borrowApyDaily(
288
+ filters: { productId: $productId, range: $range }
289
+ first: 500
290
+ orderBy: time
291
+ orderDirection: asc
292
+ ) {
293
+ items {
294
+ date
295
+ apy {
296
+ net
297
+ base
298
+ rewards
299
+ }
300
+ }
301
+ pagination {
302
+ count
303
+ countTotal
304
+ }
305
+ }
306
+ }
307
+ `;
@@ -1,5 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  export declare const findBestMarketsArgs: {
3
+ kind: z.ZodDefault<z.ZodEnum<{
4
+ supply: "supply";
5
+ borrow: "borrow";
6
+ }>>;
3
7
  asset: z.ZodOptional<z.ZodString>;
4
8
  chainId: z.ZodOptional<z.ZodNumber>;
5
9
  protocol: z.ZodOptional<z.ZodEnum<{
@@ -7,25 +11,34 @@ export declare const findBestMarketsArgs: {
7
11
  morpho: "morpho";
8
12
  compound: "compound";
9
13
  }>>;
14
+ collateral: z.ZodOptional<z.ZodString>;
10
15
  minTvlUsd: z.ZodDefault<z.ZodNumber>;
11
16
  limit: z.ZodDefault<z.ZodNumber>;
12
17
  };
13
18
  /**
14
- * Current best supply markets by net APY. Reads the server-side latest-snapshot
15
- * query, so the ranking and the TVL floor are both applied in Postgres — this
16
- * never pages the catalogue and reduces client-side.
19
+ * Current best markets, either side of the book.
20
+ *
21
+ * Both kinds read a server-side latest-snapshot query, so the ranking and the
22
+ * TVL floor are applied in Postgres — this never pages the catalogue. The two
23
+ * sides rank in opposite directions: supply by highest net APY (most earned),
24
+ * borrow by lowest (cheapest cost). That inversion lives in the GraphQL
25
+ * documents (`LATEST_BORROW_APY` sends `orderDirection: asc`), so here we only
26
+ * pick the document.
17
27
  */
18
28
  export declare function findBestMarkets(raw: unknown): Promise<{
19
29
  disclaimer: string;
20
30
  warning?: string | undefined;
21
31
  hint?: string | undefined;
22
32
  query: {
33
+ kind: "supply" | "borrow";
23
34
  asset: string | undefined;
24
35
  chainId: number | undefined;
25
36
  protocol: "aave" | "morpho" | "compound" | undefined;
37
+ collateral: string | undefined;
26
38
  minTvlUsd: number;
27
39
  limit: number;
28
40
  };
41
+ ranking: string;
29
42
  matched: number;
30
43
  returned: number;
31
44
  markets: import("./shared.js").DescribedMarket[];
@@ -1,9 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import { DEFAULT_MIN_TVL_USD, NOT_ADVICE } from '../config.js';
3
3
  import { gql } from '../graphql/client.js';
4
- import { LATEST_SUPPLY_APY, } from '../graphql/queries.js';
4
+ import { LATEST_BORROW_APY, LATEST_SUPPLY_APY, } from '../graphql/queries.js';
5
5
  import { describeRow, isUnreliable } from './shared.js';
6
6
  export const findBestMarketsArgs = {
7
+ kind: z
8
+ .enum(['supply', 'borrow'])
9
+ .default('supply')
10
+ .describe('supply = earn yield (best = highest net APY). borrow = take a loan ' +
11
+ '(best = LOWEST net APY, i.e. cheapest cost). Defaults to supply.'),
7
12
  asset: z
8
13
  .string()
9
14
  .optional()
@@ -13,30 +18,54 @@ export const findBestMarketsArgs = {
13
18
  .enum(['aave', 'morpho', 'compound'])
14
19
  .optional()
15
20
  .describe('Protocol provider.'),
21
+ collateral: z
22
+ .string()
23
+ .optional()
24
+ .describe('Borrow only: filter by collateral asset symbol (e.g. WETH). Valid values ' +
25
+ 'come from the `collaterals` on returned borrow rows, not guessed. ' +
26
+ 'Ignored when kind is supply.'),
16
27
  minTvlUsd: z
17
28
  .number()
18
29
  .nonnegative()
19
30
  .default(DEFAULT_MIN_TVL_USD)
20
- .describe('Minimum supplied TVL in USD. Defaults to $1M — in a thinner market the ' +
21
- 'headline APY is mostly noise. Lower it deliberately, not by accident.'),
31
+ .describe('Minimum supplied pool depth in USD. Defaults to $1M — in a thinner ' +
32
+ 'market the headline rate is mostly noise (and, for borrow, the pool ' +
33
+ 'may be too shallow to draw from). Lower it deliberately, not by accident.'),
22
34
  limit: z.number().int().min(1).max(50).default(10),
23
35
  };
24
36
  const Args = z.object(findBestMarketsArgs);
25
37
  /**
26
- * Current best supply markets by net APY. Reads the server-side latest-snapshot
27
- * query, so the ranking and the TVL floor are both applied in Postgres — this
28
- * never pages the catalogue and reduces client-side.
38
+ * Current best markets, either side of the book.
39
+ *
40
+ * Both kinds read a server-side latest-snapshot query, so the ranking and the
41
+ * TVL floor are applied in Postgres — this never pages the catalogue. The two
42
+ * sides rank in opposite directions: supply by highest net APY (most earned),
43
+ * borrow by lowest (cheapest cost). That inversion lives in the GraphQL
44
+ * documents (`LATEST_BORROW_APY` sends `orderDirection: asc`), so here we only
45
+ * pick the document.
29
46
  */
30
47
  export async function findBestMarkets(raw) {
31
- const { asset, chainId, protocol, minTvlUsd, limit } = Args.parse(raw);
32
- const data = await gql(LATEST_SUPPLY_APY, {
33
- filters: { asset, chainId, protocol, minTvlUsd },
34
- first: limit,
35
- });
36
- const { items, pagination } = data.latestSupplyApy;
48
+ const { kind, asset, chainId, protocol, collateral, minTvlUsd, limit } = Args.parse(raw);
49
+ const isBorrow = kind === 'borrow';
50
+ const document = isBorrow ? LATEST_BORROW_APY : LATEST_SUPPLY_APY;
51
+ // `collateral` is a borrow-only filter; sending it on the supply side would be
52
+ // rejected by the schema, so it's dropped there rather than passed through.
53
+ const filters = {
54
+ asset,
55
+ chainId,
56
+ protocol,
57
+ minTvlUsd,
58
+ ...(isBorrow ? { collateral } : {}),
59
+ };
60
+ const data = await gql(document, { filters, first: limit });
61
+ // Exactly one key is present — the one for the document we sent.
62
+ const { items, pagination } = (isBorrow ? data.latestBorrowApy : data.latestSupplyApy);
37
63
  const markets = items.map(describeRow);
38
64
  return {
39
- query: { asset, chainId, protocol, minTvlUsd, limit },
65
+ query: { kind, asset, chainId, protocol, collateral, minTvlUsd, limit },
66
+ ranking: isBorrow
67
+ ? 'By net borrow cost APY, cheapest first — lower is better.'
68
+ : 'By net supply APY, highest first.',
40
69
  matched: pagination.countTotal,
41
70
  returned: markets.length,
42
71
  markets,
@@ -62,6 +62,8 @@ export declare function getMarketDetails(raw: unknown): Promise<{
62
62
  };
63
63
  tvlUsd: number | null;
64
64
  utilizationRate: number | null;
65
+ borrowedUsd?: number | null;
66
+ collaterals?: string[];
65
67
  asOf: string;
66
68
  quality: {
67
69
  status: string;
@@ -1,6 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  export declare const getMarketHistoryArgs: {
3
3
  productId: z.ZodString;
4
+ kind: z.ZodDefault<z.ZodEnum<{
5
+ supply: "supply";
6
+ borrow: "borrow";
7
+ }>>;
4
8
  range: z.ZodDefault<z.ZodEnum<{
5
9
  "7d": "7d";
6
10
  "30d": "30d";
@@ -9,14 +13,17 @@ export declare const getMarketHistoryArgs: {
9
13
  }>>;
10
14
  };
11
15
  /**
12
- * Daily net-APY series plus its mean / stddev / min / max.
16
+ * Daily net-APY series plus its mean / stddev / min / max, for either side.
13
17
  *
14
18
  * The stats are the deliverable, not the series. A snapshot cannot distinguish a
15
- * durable 6% from a 12% reward spike that ends next week; a 180-day standard
16
- * deviation can. That is the signal a 6-month horizon actually needs.
19
+ * durable rate from a reward spike that ends next week; a 180-day standard
20
+ * deviation can. For supply that stability is the yield you can count on; for
21
+ * borrow it is the cost you can count on. Same maths, opposite reading — hence
22
+ * the kind-dependent interpretation copy.
17
23
  */
18
24
  export declare function getMarketHistory(raw: unknown): Promise<{
19
25
  productId: string;
26
+ kind: "supply" | "borrow";
20
27
  range: "7d" | "30d" | "90d" | "180d";
21
28
  observations: number;
22
29
  hint: string;
@@ -26,6 +33,7 @@ export declare function getMarketHistory(raw: unknown): Promise<{
26
33
  series?: undefined;
27
34
  } | {
28
35
  productId: string;
36
+ kind: "supply" | "borrow";
29
37
  range: "7d" | "30d" | "90d" | "180d";
30
38
  observations: number;
31
39
  netApy: {
@@ -1,10 +1,15 @@
1
1
  import { z } from 'zod';
2
2
  import { NOT_ADVICE } from '../config.js';
3
3
  import { gql } from '../graphql/client.js';
4
- import { MARKET_HISTORY } from '../graphql/queries.js';
4
+ import { BORROW_HISTORY, MARKET_HISTORY } from '../graphql/queries.js';
5
5
  import { stats } from '../stats.js';
6
6
  export const getMarketHistoryArgs = {
7
7
  productId: z.string().min(1).describe('Exact productId from find_best_markets.'),
8
+ kind: z
9
+ .enum(['supply', 'borrow'])
10
+ .default('supply')
11
+ .describe('Which side to chart. Must match the productId — a borrow productId ends ' +
12
+ 'in :borrow. Defaults to supply.'),
8
13
  range: z
9
14
  .enum(['7d', '30d', '90d', '180d'])
10
15
  .default('90d')
@@ -12,31 +17,40 @@ export const getMarketHistoryArgs = {
12
17
  };
13
18
  const Args = z.object(getMarketHistoryArgs);
14
19
  /**
15
- * Daily net-APY series plus its mean / stddev / min / max.
20
+ * Daily net-APY series plus its mean / stddev / min / max, for either side.
16
21
  *
17
22
  * The stats are the deliverable, not the series. A snapshot cannot distinguish a
18
- * durable 6% from a 12% reward spike that ends next week; a 180-day standard
19
- * deviation can. That is the signal a 6-month horizon actually needs.
23
+ * durable rate from a reward spike that ends next week; a 180-day standard
24
+ * deviation can. For supply that stability is the yield you can count on; for
25
+ * borrow it is the cost you can count on. Same maths, opposite reading — hence
26
+ * the kind-dependent interpretation copy.
20
27
  */
21
28
  export async function getMarketHistory(raw) {
22
- const { productId, range } = Args.parse(raw);
23
- const data = await gql(MARKET_HISTORY, { productId, range });
24
- const items = data.supplyApyDaily.items;
29
+ const { productId, kind, range } = Args.parse(raw);
30
+ const isBorrow = kind === 'borrow';
31
+ const document = isBorrow ? BORROW_HISTORY : MARKET_HISTORY;
32
+ const data = await gql(document, { productId, range });
33
+ // Exactly one key is present — the one for the document we sent.
34
+ const items = (isBorrow ? data.borrowApyDaily : data.supplyApyDaily).items;
25
35
  // Non-finite APYs (Postgres double precision can hold NaN) are dropped, not
26
36
  // returned — a NaN in a series poisons every stat computed from it.
27
37
  const netStats = stats(items.map((d) => d.apy.net));
28
38
  if (!netStats) {
29
39
  return {
30
40
  productId,
41
+ kind,
31
42
  range,
32
43
  observations: 0,
33
44
  hint: 'No usable daily history for this product in that range. It may be ' +
34
- 'newly tracked try a shorter range.',
45
+ 'newly tracked, or the productId may not match the requested kind ' +
46
+ '(a borrow productId ends in :borrow). Try a shorter range.',
35
47
  disclaimer: NOT_ADVICE,
36
48
  };
37
49
  }
50
+ const volatile = netStats.mean > 0 && netStats.stddev / Math.abs(netStats.mean) > 0.5;
38
51
  return {
39
52
  productId,
53
+ kind,
40
54
  range,
41
55
  observations: netStats.count,
42
56
  netApy: {
@@ -45,9 +59,12 @@ export async function getMarketHistory(raw) {
45
59
  min: netStats.min,
46
60
  max: netStats.max,
47
61
  },
48
- interpretation: netStats.mean > 0 && netStats.stddev / Math.abs(netStats.mean) > 0.5
49
- ? 'Volatile: the spread is large relative to the mean, so the current ' +
50
- 'APY is a poor predictor of what you would actually earn.'
62
+ interpretation: volatile
63
+ ? isBorrow
64
+ ? 'Volatile: the spread is large relative to the mean, so the current ' +
65
+ 'rate is a poor predictor of what you would actually pay.'
66
+ : 'Volatile: the spread is large relative to the mean, so the current ' +
67
+ 'APY is a poor predictor of what you would actually earn.'
51
68
  : 'Relatively stable over this window.',
52
69
  series: items.map((d) => ({
53
70
  date: d.date,
@@ -16,6 +16,10 @@ export interface DescribedMarket {
16
16
  };
17
17
  tvlUsd: number | null;
18
18
  utilizationRate: number | null;
19
+ /** Borrow rows only: value currently drawn from the pool. Absent for supply. */
20
+ borrowedUsd?: number | null;
21
+ /** Borrow rows only: accepted collateral symbols. Absent for supply. */
22
+ collaterals?: string[];
19
23
  /** Snapshot time — every response says how fresh it is. */
20
24
  asOf: string;
21
25
  quality: {
@@ -32,6 +32,15 @@ export function describeRow(row) {
32
32
  },
33
33
  tvlUsd: finite(row.market.supplyAssetsUsd),
34
34
  utilizationRate: finite(row.market.utilizationRate),
35
+ // Borrow-only fields, keyed off what the document actually selected rather
36
+ // than a passed-in kind: a supply document omits both, a borrow one carries
37
+ // them, so their presence on the row is the ground truth.
38
+ ...(row.market.borrowAssetsUsd !== undefined
39
+ ? { borrowedUsd: finite(row.market.borrowAssetsUsd) }
40
+ : {}),
41
+ ...(row.collaterals !== undefined
42
+ ? { collaterals: row.collaterals.map((c) => c.symbol) }
43
+ : {}),
35
44
  asOf: row.hour,
36
45
  quality: reliability(row),
37
46
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lendwise/mcp",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Unified view for lending markets, one standard — MCP server to compare and optimize DeFi supply/borrow across Aave, Morpho and Compound.",
5
5
  "license": "MIT",
6
6
  "type": "module",