earnrate 0.2.9 → 0.2.11

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 (49) hide show
  1. package/README.md +14 -6
  2. package/data/CHANGELOG.md +56 -0
  3. package/data/VERSION +1 -1
  4. package/data/cards/amex-blue-cash-preferred.yaml +3 -1
  5. package/data/cards/amex-delta-skymiles-gold.yaml +120 -0
  6. package/data/cards/amex-delta-skymiles-platinum.yaml +118 -0
  7. package/data/cards/amex-delta-skymiles-reserve.yaml +80 -0
  8. package/data/cards/amex-hilton-honors-aspire.yaml +140 -0
  9. package/data/cards/amex-hilton-honors-surpass.yaml +151 -0
  10. package/data/cards/amex-hilton-honors.yaml +117 -0
  11. package/data/cards/amex-marriott-bonvoy-bevy.yaml +116 -0
  12. package/data/cards/amex-marriott-bonvoy-brilliant.yaml +91 -0
  13. package/data/cards/amex-platinum.yaml +14 -0
  14. package/data/cards/bank-of-america-atmos-rewards-ascent.yaml +13 -0
  15. package/data/cards/bank-of-america-atmos-rewards-business.yaml +13 -0
  16. package/data/cards/bank-of-america-atmos-rewards-summit.yaml +16 -0
  17. package/data/cards/capital-one-quicksilver.yaml +80 -0
  18. package/data/cards/chase-aeroplan.yaml +28 -0
  19. package/data/cards/chase-ihg-one-rewards.yaml +1 -1
  20. package/data/cards/chase-marriott-bonvoy-bold.yaml +123 -0
  21. package/data/cards/chase-marriott-bonvoy-boundless.yaml +76 -0
  22. package/data/cards/chase-sapphire-reserve.yaml +29 -0
  23. package/data/cards/chase-united-business.yaml +10 -0
  24. package/data/cards/chase-united-club-business.yaml +19 -0
  25. package/data/cards/chase-united-club.yaml +31 -0
  26. package/data/cards/chase-united-explorer.yaml +19 -0
  27. package/data/cards/chase-united-gateway.yaml +18 -0
  28. package/data/cards/chase-united-quest.yaml +20 -1
  29. package/data/cards/citi-aadvantage-business.yaml +12 -1
  30. package/data/cards/citi-aadvantage-platinum-select.yaml +12 -1
  31. package/data/cards/discover-it-cash-back.yaml +169 -0
  32. package/data/currencies.yaml +26 -0
  33. package/data/issuers.yaml +3 -0
  34. package/data/merchants/overrides.yaml +24 -2
  35. package/data/merchants/seed.yaml +40 -6
  36. package/dist/engine/cheat-sheet.js +3 -0
  37. package/dist/engine/explore.js +8 -4
  38. package/dist/engine/milestones.js +10 -2
  39. package/dist/engine/recommend.js +42 -16
  40. package/dist/engine/rule-text.js +12 -4
  41. package/dist/format/recommendation.js +8 -2
  42. package/dist/format/wallet.js +6 -2
  43. package/dist/mcp/prompts.js +90 -0
  44. package/dist/mcp/schemas.js +3 -0
  45. package/dist/mcp/server.js +19 -1
  46. package/dist/mcp/tools/account.js +5 -4
  47. package/dist/mcp/tools/answers.js +7 -0
  48. package/dist/mcp/tools/wallet.js +1 -0
  49. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { CAP_ADJECTIVES, categoryPhrase, codesAs, currencyLabel, currencyName, e
11
11
  import { cardValuation } from "./valuation.js";
12
12
  export const STALE_AFTER_DAYS = 180;
13
13
  // The engine only tells the U.S. from everywhere else.
14
+ export const FOREIGN_ASSUMPTION = 'Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.';
14
15
  export const isForeign = (purchase) => purchase.country !== 'US';
15
16
  // Any purchase outside the U.S., when no country is named: ZZ is a code ISO
16
17
  // leaves unassigned, and nothing prints it.
@@ -87,6 +88,7 @@ function rankEntries(catalog, wallet, purchase, merchant, category) {
87
88
  perDollar,
88
89
  fee,
89
90
  effectiveReturnPct: perDollar * valuation.centsPerUnit - fee,
91
+ rateReturnPct: evaluation.best.rate * valuation.centsPerUnit - fee,
90
92
  upside: perDollar * (valuation.range?.[1] ?? valuation.centsPerUnit) - fee,
91
93
  });
92
94
  }
@@ -111,7 +113,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
111
113
  lines.push(`${money(cap.remaining)} earns ${earnText(catalog, best.nominalRate, currency)} before the cap, then ${money(over)} earns ${earnText(catalog, best.afterCap ?? cap.cap.then_rate, currency)}: ${num(best.rate)} per dollar overall.`);
112
114
  }
113
115
  for (const hit of entry.milestones)
114
- lines.push(reachedText(card, hit));
116
+ lines.push(reachedText(card, hit, valuation.centsPerUnit));
115
117
  if (valuation.pooledFrom !== null) {
116
118
  const partners = card.pools_into?.when_wallet_has ?? [];
117
119
  const heldPartners = wallet.cards
@@ -122,7 +124,7 @@ function reasoningFor(catalog, wallet, entry, amount) {
122
124
  lines.push(`Pools into ${currencyName(catalog, currency)} because you hold ${heldPartners.join(' and ')}.`);
123
125
  }
124
126
  if (currency !== 'cash') {
125
- lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.perDollar * valuation.centsPerUnit)} back.`);
127
+ lines.push(`Valued at ${num(valuation.centsPerUnit)}¢ per point: ${pct(entry.evaluation.best.rate * valuation.centsPerUnit)} back.`);
126
128
  }
127
129
  return lines;
128
130
  }
@@ -131,7 +133,7 @@ function warningsFor(catalog, entry, purchase) {
131
133
  const { best, nominal } = evaluation;
132
134
  const warnings = [];
133
135
  if (entry.fee > 0)
134
- warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.effectiveReturnPct)}.`);
136
+ warnings.push(`Charges a ${pct(entry.fee)} foreign transaction fee here, so it nets ${pct(entry.rateReturnPct)}.`);
135
137
  if (isForeign(purchase) && card.foreign_transaction_fee === null)
136
138
  warnings.push("Its foreign transaction fee isn't on file, so none is subtracted. Check the issuer's terms before using it abroad.");
137
139
  const capLabel = (e) => e.cap
@@ -184,11 +186,19 @@ function toRanked(catalog, wallet, entry, purchase) {
184
186
  rate: evaluation.best.rate,
185
187
  currency: valuation.currency,
186
188
  centsPerUnit: valuation.centsPerUnit,
187
- effectiveReturnPct: entry.effectiveReturnPct,
189
+ effectiveReturnPct: entry.rateReturnPct,
188
190
  foreignFee: entry.fee > 0 ? entry.fee : null,
189
191
  earned: purchase.amount === null
190
192
  ? null
191
- : Math.round(purchase.amount * entry.effectiveReturnPct) / 100,
193
+ : Math.round(purchase.amount * entry.rateReturnPct) / 100,
194
+ milestoneBonus: entry.milestones.length === 0
195
+ ? null
196
+ : {
197
+ points: milestonePoints(entry.milestones),
198
+ dollars: roundCents((milestonePoints(entry.milestones) *
199
+ entry.valuation.centsPerUnit) /
200
+ 100),
201
+ },
192
202
  capped: evaluation.best.cap !== null,
193
203
  reasoning: reasoningFor(catalog, wallet, entry, purchase.amount),
194
204
  warnings: warningsFor(catalog, entry, purchase),
@@ -210,11 +220,13 @@ function tieNote(entries) {
210
220
  tied.some((e) => e.evaluation.best.cap !== null)
211
221
  ? 'because its rate has no cap'
212
222
  : 'by a fixed order; either works';
213
- return `${names} also ${verb} ${pct(winner.effectiveReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
223
+ return `${names} also ${verb} ${pct(winner.rateReturnPct)} here. ${displayName(winner)} wins the tie ${why}.`;
214
224
  }
215
225
  // Finds the closest point inside a currency's value range where the winner
216
226
  // stops winning, varying one currency at a time. Returns are linear in the
217
227
  // cents-per-point value, so each pair of cards flips at most once.
228
+ // "Chase Ultimate Rewards points", but not "IHG One Rewards points points".
229
+ const pointsName = (label) => /\b(points|miles)$/i.test(label) ? label : `${label} points`;
218
230
  function sensitivity(catalog, entries) {
219
231
  const [winner, ...rest] = entries;
220
232
  if (!winner)
@@ -235,13 +247,13 @@ function sensitivity(catalog, entries) {
235
247
  if (w.range && wRate > 0) {
236
248
  const threshold = (other.effectiveReturnPct + winner.fee) / wRate;
237
249
  if (threshold < w.centsPerUnit && threshold >= w.range[0]) {
238
- consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, w.currency)} points are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
250
+ consider((w.centsPerUnit - threshold) / w.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, w.currency))} are worth less than ${roundDown(threshold)}¢ (you value them at ${num(w.centsPerUnit)}¢).`);
239
251
  }
240
252
  }
241
253
  if (o.range && oRate > 0) {
242
254
  const threshold = (winner.effectiveReturnPct + other.fee) / oRate;
243
255
  if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
244
- consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
256
+ consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${pointsName(currencyLabel(catalog, o.currency))} are worth ${worthAbove(threshold)} (you value them at ${num(o.centsPerUnit)}¢).`);
245
257
  }
246
258
  }
247
259
  }
@@ -256,7 +268,19 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
256
268
  assumptions.push(`Ranks purchases that code as ${categoryPhrase(catalog, merchant.category)}.`);
257
269
  }
258
270
  else if (merchant.category === 'other') {
259
- assumptions.push(`Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`);
271
+ // A card whose rule names this store by name (Audible on Amex's
272
+ // streaming list) earns its bonus there anyway.
273
+ const naming = entries.flatMap((e) => {
274
+ const rule = e.evaluation.best.rule;
275
+ return rule &&
276
+ merchant.merchantId !== null &&
277
+ (rule.match.merchants ?? []).includes(merchant.merchantId)
278
+ ? [`${displayName(e)} (${ruleWhat(catalog, rule)})`]
279
+ : [];
280
+ });
281
+ assumptions.push(naming.length === 0
282
+ ? `Assumes ${merchant.resolvedName} falls outside the usual bonus categories.`
283
+ : `Assumes ${merchant.resolvedName} falls outside the usual bonus categories, except on cards that name it: ${listed(naming)}.`);
260
284
  }
261
285
  else {
262
286
  assumptions.push(codesAs(catalog, merchant));
@@ -274,7 +298,7 @@ function assumptionsFor(catalog, merchant, purchase, entries) {
274
298
  if (values !== null)
275
299
  assumptions.push(values);
276
300
  if (isForeign(purchase))
277
- assumptions.push('Treats this as a purchase outside the U.S.: each card is ranked after its foreign transaction fee.');
301
+ assumptions.push(FOREIGN_ASSUMPTION);
278
302
  for (const e of entries)
279
303
  for (const hit of e.milestones)
280
304
  assumptions.push(`Counts the ${money(hit.before)} tracked on ${displayName(e)} in ${hit.period} toward its ${wholeDollars(hit.spend)} spend bonus; spending earnrate wasn't told about isn't counted.`);
@@ -314,9 +338,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
314
338
  const twin = alts.find((e) => e.card.id !== winner.card.id &&
315
339
  alt.effectiveReturnPct - e.effectiveReturnPct <= EPSILON);
316
340
  if (alt.card.id !== winner.card.id)
317
- warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.effectiveReturnPct)}).`);
341
+ warnings.push(`${as}, ${displayName(alt)} wins (${pct(alt.rateReturnPct)}).`);
318
342
  else if (twin)
319
- warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.effectiveReturnPct)}).`);
343
+ warnings.push(`${as}, ${displayName(twin)} earns as much (${pct(twin.rateReturnPct)}).`);
320
344
  }
321
345
  }
322
346
  if (!winner) {
@@ -335,7 +359,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
335
359
  ? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
336
360
  : undefined;
337
361
  if (alt)
338
- warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.effectiveReturnPct)}).`);
362
+ warnings.push(`Ordering online instead? ${displayName(alt)} works there (${pct(alt.rateReturnPct)}).`);
339
363
  }
340
364
  else if (merchant.accepts === null &&
341
365
  (winner.card.network === 'amex' || winner.card.network === 'discover')) {
@@ -360,7 +384,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
360
384
  const alt = rankEntries(catalog, wallet, purchase, via, via.category)
361
385
  .entries[0];
362
386
  if (alt && alt.effectiveReturnPct > winner.effectiveReturnPct + EPSILON) {
363
- notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.effectiveReturnPct)}.`);
387
+ notes.push(`If you book it as ${via.resolvedName}, ${displayName(alt)} earns ${pct(alt.rateReturnPct)}.`);
364
388
  }
365
389
  }
366
390
  const open = bonusPick(catalog, wallet, entries, purchase.date);
@@ -376,7 +400,7 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
376
400
  const bonus = open && (open.behind || ties) ? open : null;
377
401
  const best = winner?.effectiveReturnPct ?? 0;
378
402
  if (open && !bonus && isClose(open.entry.effectiveReturnPct, best)) {
379
- notes.push(`${displayName(open.entry)} earns ${pct(open.entry.effectiveReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
403
+ notes.push(`${displayName(open.entry)} earns ${pct(open.entry.rateReturnPct)} here, ${num(gap)} ${gap === 1 ? 'point' : 'points'} less than ${pct(best)}, and would help its welcome bonus (${money(open.status.remaining ?? 0)} more by ${open.status.deadline ?? ''}). You're on pace, so this uses the higher rate.`);
380
404
  }
381
405
  const chosen = bonus?.entry ?? winner;
382
406
  // A tie among the top cards doesn't matter when a welcome bonus decides.
@@ -393,7 +417,9 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
393
417
  const daysLeft = status.daysLeft ?? 0;
394
418
  const costHere = ties || top.earned === null || first.earned === null
395
419
  ? null
396
- : roundCents(first.earned - top.earned);
420
+ : roundCents(first.earned +
421
+ (first.milestoneBonus?.dollars ?? 0) -
422
+ (top.earned + (top.milestoneBonus?.dollars ?? 0)));
397
423
  bonusResult = {
398
424
  cardId: top.cardId,
399
425
  checkoutLabel: top.checkoutLabel,
@@ -34,10 +34,18 @@ export function ruleParts(catalog, rule) {
34
34
  if (rule.match.choice !== undefined)
35
35
  return [`your ${choiceName(rule.match.choice)} picks`];
36
36
  const merchants = rule.match.merchants ?? [];
37
- // A long list of one kind of store reads as "select streaming services".
38
- const kinds = new Set(merchants.map((m) => findMerchant(catalog, m)?.category));
39
- const [kind] = kinds;
40
- const merchantParts = merchants.length > 4 && kinds.size === 1 && kind !== undefined
37
+ // A long list of mostly one kind of store reads as "select streaming
38
+ // services": Amex's streaming list also names Audible, which isn't one.
39
+ const counts = new Map();
40
+ for (const m of merchants) {
41
+ const category = findMerchant(catalog, m)?.category;
42
+ if (category)
43
+ counts.set(category, (counts.get(category) ?? 0) + 1);
44
+ }
45
+ const [kind, count = 0] = [...counts].sort((a, b) => b[1] - a[1])[0] ?? [];
46
+ const merchantParts = merchants.length > 4 &&
47
+ kind !== undefined &&
48
+ count >= merchants.length * 0.75
41
49
  ? [`select ${categoryPhrase(catalog, kind)}`]
42
50
  : merchants.map((m) => merchantName(catalog, m));
43
51
  const parts = [
@@ -30,7 +30,10 @@ function cardLines(cards, style, bonusPick) {
30
30
  const pctWidth = Math.max(...cards.map((c) => pct(c.effectiveReturnPct).length));
31
31
  const lines = [];
32
32
  cards.forEach((c) => {
33
- const earned = c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`;
33
+ const earned = (c.earned === null ? '' : ` ${dollarsAndCents(c.earned)}`) +
34
+ (c.milestoneBonus === null
35
+ ? ''
36
+ : ` + ${money(c.milestoneBonus.dollars)} spend bonus`);
34
37
  const pick = c.cardId === bonusPick ? ' ← the pick, for its welcome bonus' : '';
35
38
  lines.push(` ${String(c.rank).padStart(String(rankWidth).length)}. ${label(c).padEnd(nameWidth)} ${pct(c.effectiveReturnPct).padStart(pctWidth)}${earned}${pick}`);
36
39
  for (const r of c.reasoning)
@@ -53,9 +56,12 @@ export function formatRecommendation(rec, purchase, { all = false, style = plain
53
56
  const noCard = noCardText(rec.merchant.resolvedName);
54
57
  if (rec.decision && top) {
55
58
  const earned = top.earned === null ? '' : `, about ${dollarsAndCents(top.earned)}`;
59
+ const milestone = top.milestoneBonus === null
60
+ ? ''
61
+ : `, plus a spend bonus worth about ${money(top.milestoneBonus.dollars)}`;
56
62
  const confidence = rec.decision.confidence;
57
63
  const why = rec.bonus ? ' for its welcome bonus' : '';
58
- lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}.`)} ${capitalize(confidence)} confidence.`);
64
+ lines.push(`${style('bold', `Use ${label(top)}${why}: ${pct(top.effectiveReturnPct)} back${earned}${milestone}.`)} ${capitalize(confidence)} confidence.`);
59
65
  if (rec.bonus && rec.notes[0])
60
66
  lines.push(rec.notes[0]);
61
67
  }
@@ -36,7 +36,7 @@ export function formatWalletSummary(summary) {
36
36
  for (const cap of c.caps)
37
37
  lines.push(cap.remaining === null
38
38
  ? ` Cap on ${cap.label}: ${money(cap.amount)} ${CAP_PERIODS[cap.period]}; give the card's anniversary to track it (cap key ${cap.key}).`
39
- : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
39
+ : ` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used ${cap.period === 'cardmember_year' ? 'this cardmember year' : `for ${cap.currentPeriod ?? ''}`}${cap.until === null ? '' : `, through ${shortDate(cap.until)}`} (cap key ${cap.key}).`);
40
40
  for (const m of c.milestones)
41
41
  lines.push(` Spend bonus (${m.id}): ${m.text}`);
42
42
  if (c.anniversary !== null)
@@ -76,7 +76,11 @@ export function formatBooking(amount, cardId, cap, bonus, milestones = []) {
76
76
  messages.push([
77
77
  `Spend on ${cardId} this year: ${money(first.after)}.`,
78
78
  ...reached.map((m) => `That reaches its ${money(m.spend)} spend bonus.`),
79
- ...(next ? [`${money(next.spend - next.after)} to its next one.`] : []),
79
+ ...(next
80
+ ? [
81
+ `${money(next.spend - next.after)} to go for the ${money(next.spend)} spend bonus.`,
82
+ ]
83
+ : []),
80
84
  ].join(' '));
81
85
  }
82
86
  return messages.join(' ');
@@ -0,0 +1,90 @@
1
+ import { z } from 'zod';
2
+ // Ready-made questions a client can offer: the user's question, then what
3
+ // to call and how to answer. Rates and card facts come only from the tools.
4
+ export const PROMPTS = ['which-card', 'cheat-sheet', 'this-quarter', 'set-up'];
5
+ const FROM_SAVED_OR_ALL = "the user's saved cards if there are any, otherwise every card earnrate knows";
6
+ const ask = (question, guidance) => ({
7
+ messages: [
8
+ {
9
+ role: 'user',
10
+ content: {
11
+ type: 'text',
12
+ text: `${question}\n\n${guidance.join(' ')}`,
13
+ },
14
+ },
15
+ ],
16
+ });
17
+ const dollars = (amount) => (/^\d/.test(amount) ? `$${amount}` : amount);
18
+ export function registerPrompts(server, deps, ctx) {
19
+ server.registerPrompt('which-card', {
20
+ title: 'Which credit card should I use?',
21
+ description: 'The card that earns the most at a store or for a kind of purchase, with the reason. From your saved cards, or every card if you have none saved.',
22
+ argsSchema: z.object({
23
+ store: z
24
+ .string()
25
+ .trim()
26
+ .min(1)
27
+ .describe('The store, or the kind of purchase: Costco, groceries'),
28
+ amount: z
29
+ .string()
30
+ .optional()
31
+ .describe('How much you are spending, in US dollars'),
32
+ country: z
33
+ .string()
34
+ .optional()
35
+ .describe('Where you are paying, if not the U.S.: FR, Japan'),
36
+ }),
37
+ }, (args) => {
38
+ const amount = args.amount?.trim() || null;
39
+ const country = args.country?.trim() || null;
40
+ const call = [
41
+ `merchant ${JSON.stringify(args.store)}`,
42
+ ...(amount === null
43
+ ? []
44
+ : [`amount ${JSON.stringify(amount)} as a number of US dollars`]),
45
+ ...(country === null
46
+ ? []
47
+ : [`country as the two-letter code for ${JSON.stringify(country)}`]),
48
+ ];
49
+ return ask(`Which credit card should I use at ${args.store}${amount === null ? '' : ` for ${dollars(amount)}`}${country === null ? '' : ` in ${country}`}?`, [
50
+ `Call earnrate's best_card_for_purchase with ${call.join(', ')}.`,
51
+ `It answers from ${FROM_SAVED_OR_ALL}, so don't ask for their cards first.`,
52
+ 'Say which card to use and why, with the assumptions and warnings it gives.',
53
+ ...(country === null
54
+ ? [
55
+ 'If the user mentions paying abroad or in another currency, ask where, or pass `country` as its two-letter code.',
56
+ ]
57
+ : []),
58
+ ]);
59
+ });
60
+ server.registerPrompt('cheat-sheet', {
61
+ title: 'Which card for everyday spending?',
62
+ description: 'The card to use for each everyday kind of purchase right now, from your saved cards or every card.',
63
+ }, () => ask('Which card should I use for everyday spending?', [
64
+ "Call earnrate's cheat_sheet and show the card for each kind of purchase, with its notes.",
65
+ `It uses ${FROM_SAVED_OR_ALL}.`,
66
+ ]));
67
+ server.registerPrompt('this-quarter', {
68
+ title: 'What should I do with my cards this quarter?',
69
+ description: 'Bonuses to activate, categories to pick, caps running low and welcome bonuses to finish, from your saved cards.',
70
+ }, () => ask('What should I do with my cards this quarter?', [
71
+ "Call earnrate's list_wallet: it starts with a to-do list. Walk through it, soonest first.",
72
+ ctx.walletUse,
73
+ 'If no cards are saved, say in a sentence or two what saving them adds (reminders to activate bonuses, pick categories, and watch caps and welcome bonuses), without pressing the user to do it, and offer the cheat sheet from every card instead.',
74
+ ]));
75
+ server.registerPrompt('set-up', {
76
+ title: 'Set up my cards',
77
+ description: 'Save the cards you hold, by name, with their category picks, memberships and activations.',
78
+ }, () => ask('Help me set up my cards in earnrate.', [
79
+ 'Ask which cards the user has; their names are enough. Never ask for card numbers, expiry dates or security codes.',
80
+ ...(deps.savedWallets === 'account'
81
+ ? [
82
+ "Never ask for the recovery code either; it's typed only on earnrate's page.",
83
+ ]
84
+ : []),
85
+ 'Find each card with search_cards and check the match with the user, then save it with add_card.',
86
+ ctx.walletUse,
87
+ 'When a card needs category picks, a membership like Amazon Prime, activations or its anniversary, ask rather than guessing, and save the answers with update_card or set_memberships.',
88
+ 'When done, call list_wallet and show its to-do list.',
89
+ ]));
90
+ }
@@ -40,6 +40,9 @@ const RankedCardSchema = z.object({
40
40
  effectiveReturnPct: z.number(),
41
41
  earned: z.number().nullable(),
42
42
  foreignFee: z.number().nullable(),
43
+ milestoneBonus: z
44
+ .object({ points: z.number(), dollars: z.number() })
45
+ .nullable(),
43
46
  capped: z.boolean(),
44
47
  reasoning: z.array(z.string()),
45
48
  warnings: z.array(z.string()),
@@ -2,6 +2,7 @@ import { McpServer } from '@modelcontextprotocol/server';
2
2
  import { MCP_SERVER_NAME } from "../brand.js";
3
3
  import { makeContext } from "./context.js";
4
4
  import { makeInputs } from "./inputs.js";
5
+ import { registerPrompts } from "./prompts.js";
5
6
  import { registerAccountTools } from "./tools/account.js";
6
7
  import { registerAnswerTools } from "./tools/answers.js";
7
8
  import { registerWalletTools } from "./tools/wallet.js";
@@ -45,11 +46,28 @@ The wallet lists cards by product id, never card numbers; never ask for card num
45
46
 
46
47
  ${WHICH_WALLET[savedWallets]} The earnrate extension on the user's computer and the hosted earnrate connector keep separate wallets with the same tools. If both are connected, say which wallet an answer comes from, and ask the user which one to use before saving or recording anything.`;
47
48
  export function createEarnrateServer(deps) {
48
- const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
49
+ const server = new McpServer({
50
+ name: MCP_SERVER_NAME,
51
+ version: deps.version,
52
+ websiteUrl: 'https://earnrate.dev',
53
+ icons: [
54
+ {
55
+ src: 'https://earnrate.dev/icon-192.png',
56
+ mimeType: 'image/png',
57
+ sizes: ['192x192'],
58
+ },
59
+ {
60
+ src: 'https://earnrate.dev/favicon.svg',
61
+ mimeType: 'image/svg+xml',
62
+ sizes: ['any'],
63
+ },
64
+ ],
65
+ }, { instructions: instructions(deps.savedWallets) });
49
66
  const ctx = makeContext(deps);
50
67
  const inputs = makeInputs(deps.catalog);
51
68
  registerAnswerTools(server, deps, ctx, inputs);
52
69
  registerWalletTools(server, deps, ctx, inputs);
70
+ registerPrompts(server, deps, ctx);
53
71
  if (deps.savedWallets === 'account')
54
72
  registerAccountTools(server, deps.catalog, deps.store, ctx);
55
73
  return server;
@@ -26,6 +26,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
26
26
  annotations: {
27
27
  title: 'Export the wallet',
28
28
  readOnlyHint: true,
29
+ destructiveHint: false,
29
30
  openWorldHint: false,
30
31
  },
31
32
  }, async () => {
@@ -82,7 +83,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
82
83
  });
83
84
  server.registerTool('delete_wallet', {
84
85
  title: 'Delete the wallet',
85
- description: "Deletes the saved wallet and its recovery code now and for good, and signs out every Claude app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
86
+ description: "Deletes the saved wallet and its recovery code now and for good, and signs out every app connected to it, this one included. earnrate.dev and the earnrate command line keep their own wallets and aren't affected. Only when the user asks to delete it: show it with list_wallet, offer export_wallet, and pass confirm, cards and saved only after the user confirms. It refuses if the wallet no longer holds what list_wallet showed, so a retried call can't delete a different wallet.",
86
87
  inputSchema: z.object({
87
88
  confirm: z
88
89
  .literal(true)
@@ -121,7 +122,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
121
122
  content: [
122
123
  {
123
124
  type: 'text',
124
- text: "Deleted the saved wallet and its recovery code, and signed out every Claude app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
125
+ text: "Deleted the saved wallet and its recovery code, and signed out every app connected to it, this one included. To keep using earnrate here, the user connects again on earnrate's page and starts a new wallet.",
125
126
  },
126
127
  ],
127
128
  structuredContent: { deleted: true },
@@ -129,7 +130,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
129
130
  });
130
131
  server.registerTool('switch_wallet', {
131
132
  title: 'Switch wallets',
132
- description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another Claude account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
133
+ description: "Disconnects this app from its saved wallet so the user can connect a different one with its recovery code, or start a new one, on earnrate's page. Nothing is deleted. It also signs out anyone else connected to this wallet through the same app (another account the recovery code was shared with): say so before calling it. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
133
134
  inputSchema: z.object({}),
134
135
  outputSchema: z.object({ disconnected: z.boolean() }),
135
136
  annotations: {
@@ -145,7 +146,7 @@ export function registerAccountTools(server, catalog, store, ctx) {
145
146
  content: [
146
147
  {
147
148
  type: 'text',
148
- text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other Claude accounts connected to this wallet through the same app are signed out too.",
149
+ text: "Disconnected from this saved wallet; nothing was deleted. earnrate's sign-in page opens on the next earnrate request (or reconnect earnrate in the app's connector settings). There the user chooses \"Already have a wallet?\" and types the other wallet's recovery code, or starts a new wallet. Other accounts connected to this wallet through the same app are signed out too.",
149
150
  },
150
151
  ],
151
152
  structuredContent: { disconnected: true },
@@ -54,6 +54,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
54
54
  annotations: {
55
55
  title: 'Best card for a purchase',
56
56
  readOnlyHint: true,
57
+ destructiveHint: false,
57
58
  openWorldHint: deps.llm !== null,
58
59
  },
59
60
  }, async ({ merchant, amount, channel, date, country, category, cards, memberships, valuation, }) => {
@@ -127,6 +128,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
127
128
  annotations: {
128
129
  title: 'Resolve a merchant',
129
130
  readOnlyHint: true,
131
+ destructiveHint: false,
130
132
  openWorldHint: deps.llm !== null,
131
133
  },
132
134
  }, async ({ merchant, channel, category }) => {
@@ -166,6 +168,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
166
168
  annotations: {
167
169
  title: 'Compare welcome offers',
168
170
  readOnlyHint: true,
171
+ destructiveHint: false,
169
172
  openWorldHint: false,
170
173
  },
171
174
  }, async (args) => {
@@ -219,6 +222,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
219
222
  annotations: {
220
223
  title: 'Best cards for a purchase',
221
224
  readOnlyHint: true,
225
+ destructiveHint: false,
222
226
  openWorldHint: false,
223
227
  },
224
228
  }, async (args) => {
@@ -266,6 +270,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
266
270
  annotations: {
267
271
  title: 'Simulate a year of spending',
268
272
  readOnlyHint: true,
273
+ destructiveHint: false,
269
274
  openWorldHint: false,
270
275
  },
271
276
  }, async (args) => {
@@ -349,6 +354,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
349
354
  annotations: {
350
355
  title: 'Cheat sheet',
351
356
  readOnlyHint: true,
357
+ destructiveHint: false,
352
358
  openWorldHint: false,
353
359
  },
354
360
  }, async (args) => {
@@ -391,6 +397,7 @@ export function registerAnswerTools(server, deps, ctx, inputs) {
391
397
  annotations: {
392
398
  title: 'Search cards',
393
399
  readOnlyHint: true,
400
+ destructiveHint: false,
394
401
  openWorldHint: false,
395
402
  },
396
403
  }, async ({ query }) => {
@@ -63,6 +63,7 @@ export function registerWalletTools(server, deps, ctx, inputs) {
63
63
  annotations: {
64
64
  title: 'List wallet',
65
65
  readOnlyHint: true,
66
+ destructiveHint: false,
66
67
  openWorldHint: false,
67
68
  },
68
69
  }, async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Which credit card earns the most at a merchant, from your cards or every card, and why",
5
5
  "keywords": [
6
6
  "credit cards",