earnrate 0.1.2 → 0.1.4

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.
@@ -0,0 +1,179 @@
1
+ import { periodKey } from "./periods.js";
2
+ import { knownMemberships, searchCards, setupNotes, } from "./setup.js";
3
+ const failed = (error) => ({ ok: false, error });
4
+ const list = (ids) => ids.join(', ');
5
+ export function findCatalogCard(catalog, id, today) {
6
+ const card = catalog.cards.find((c) => c.id === id);
7
+ if (card)
8
+ return card;
9
+ const near = searchCards(catalog, id, null, today).cards.map((c) => c.id);
10
+ return near.length > 0
11
+ ? `No card '${id}' in earnrate. Closest: ${list(near)}.`
12
+ : `No card '${id}' in earnrate. Search the catalog to find its id.`;
13
+ }
14
+ export function isAnniversary(text) {
15
+ const m = /^(\d{2})-(\d{2})$/.exec(text);
16
+ if (!m)
17
+ return false;
18
+ const date = new Date(`2024-${text}T00:00:00Z`); // a leap year allows 02-29
19
+ return (!Number.isNaN(date.getTime()) && date.toISOString().slice(5, 10) === text);
20
+ }
21
+ // The period key picks are filed under: the card's choice period, or for
22
+ // picks that carry over, the current year (it's never compared).
23
+ function pickPeriod(card, today) {
24
+ const periods = [
25
+ ...new Set(card.choices.map((c) => c.period).filter((p) => p !== 'until_changed')),
26
+ ];
27
+ if (periods.length > 1)
28
+ return null;
29
+ return periodKey(periods[0] ?? 'calendar_year', today, null);
30
+ }
31
+ export function cardFields(card, held, input, today) {
32
+ const problems = [];
33
+ const fields = {};
34
+ if (input.checkoutLabel !== undefined) {
35
+ if (input.checkoutLabel.trim() === '')
36
+ problems.push('The checkout label is empty.');
37
+ // earnrate never stores card numbers; a last four is fine.
38
+ if (/\d{8,}/.test(input.checkoutLabel.replace(/[\s-]/g, '')))
39
+ problems.push('The checkout label looks like it has a card number in it. earnrate never stores card numbers; use a name like "Amex Gold".');
40
+ fields.checkout_label = input.checkoutLabel.trim();
41
+ }
42
+ if (input.anniversary !== undefined) {
43
+ if (!isAnniversary(input.anniversary)) {
44
+ problems.push(`"${input.anniversary}" isn't an anniversary. Use MM-DD, like 03-15.`);
45
+ }
46
+ fields.anniversary = input.anniversary;
47
+ }
48
+ if (input.activated !== undefined) {
49
+ const needing = card.rules
50
+ .filter((r) => r.requires?.activation)
51
+ .map((r) => r.id);
52
+ for (const id of input.activated) {
53
+ if (!needing.includes(id)) {
54
+ problems.push(needing.length > 0
55
+ ? `'${id}' isn't a ${card.id} rule that needs activating. Those are: ${list(needing)}.`
56
+ : `${card.id} has no rules that need activating.`);
57
+ }
58
+ }
59
+ fields.activated = [...new Set(input.activated)];
60
+ }
61
+ if (input.picks !== undefined) {
62
+ for (const [choiceId, picks] of Object.entries(input.picks)) {
63
+ const choice = card.choices.find((c) => c.id === choiceId);
64
+ if (!choice) {
65
+ problems.push(card.choices.length > 0
66
+ ? `${card.id} has no '${choiceId}' pick. Its picks are: ${list(card.choices.map((c) => c.id))}.`
67
+ : `${card.id} has no categories to pick.`);
68
+ continue;
69
+ }
70
+ const options = Object.keys(choice.options);
71
+ for (const p of picks) {
72
+ if (!options.includes(p)) {
73
+ problems.push(`'${p}' isn't a ${choiceId} option for ${card.id}. Options: ${list(options)}.`);
74
+ }
75
+ }
76
+ if (picks.length === 0 || picks.length > choice.pick) {
77
+ problems.push(`${choiceId} takes ${choice.pick === 1 ? '1 pick' : `1 to ${String(choice.pick)} picks`}, not ${String(picks.length)}.`);
78
+ }
79
+ }
80
+ const period = pickPeriod(card, today);
81
+ if (period === null) {
82
+ problems.push(`${card.id} has picks on different schedules; edit the wallet by hand.`);
83
+ }
84
+ else {
85
+ const kept = held?.chosen?.period === period ? held.chosen.picks : {};
86
+ fields.chosen = {
87
+ period,
88
+ picks: { ...kept, ...input.picks },
89
+ };
90
+ }
91
+ }
92
+ return problems.length > 0 ? problems.join('\n') : fields;
93
+ }
94
+ export const newHolding = (id) => ({
95
+ id,
96
+ cap_usage: {},
97
+ activated: [],
98
+ });
99
+ export const withFields = (held, f) => ({
100
+ ...held,
101
+ ...(f.checkout_label === undefined
102
+ ? {}
103
+ : { checkout_label: f.checkout_label }),
104
+ ...(f.anniversary === undefined ? {} : { anniversary: f.anniversary }),
105
+ ...(f.chosen === undefined ? {} : { chosen: f.chosen }),
106
+ ...(f.activated === undefined ? {} : { activated: f.activated }),
107
+ });
108
+ // `wallet` is null when there isn't one yet; adding a card starts it.
109
+ export function addToWallet(catalog, wallet, input, today) {
110
+ const card = findCatalogCard(catalog, input.id, today);
111
+ if (typeof card === 'string')
112
+ return failed(card);
113
+ if (wallet?.cards.some((c) => c.id === card.id)) {
114
+ return failed(`${card.id} is already in the wallet. Update it instead.`);
115
+ }
116
+ const fields = cardFields(card, null, input, today);
117
+ if (typeof fields === 'string')
118
+ return failed(fields);
119
+ const held = withFields(newHolding(card.id), fields);
120
+ const next = wallet
121
+ ? { ...wallet, cards: [...wallet.cards, held] }
122
+ : { valuations: {}, memberships: [], cards: [held] };
123
+ return {
124
+ ok: true,
125
+ wallet: next,
126
+ fields,
127
+ notes: setupNotes(catalog, next, held, today),
128
+ };
129
+ }
130
+ export function updateInWallet(catalog, wallet, input, today) {
131
+ const held = wallet.cards.find((c) => c.id === input.id);
132
+ const card = catalog.cards.find((c) => c.id === input.id);
133
+ if (!held || !card) {
134
+ return failed(`${input.id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
135
+ }
136
+ const fields = cardFields(card, held, input, today);
137
+ if (typeof fields === 'string')
138
+ return failed(fields);
139
+ if (Object.keys(fields).length === 0)
140
+ return failed('Nothing to change.');
141
+ const updated = withFields(held, fields);
142
+ const next = {
143
+ ...wallet,
144
+ cards: wallet.cards.map((c) => (c.id === held.id ? updated : c)),
145
+ };
146
+ return {
147
+ ok: true,
148
+ wallet: next,
149
+ fields,
150
+ notes: setupNotes(catalog, next, updated, today),
151
+ };
152
+ }
153
+ export function removeFromWallet(wallet, id) {
154
+ if (!wallet.cards.some((c) => c.id === id)) {
155
+ return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
156
+ }
157
+ if (wallet.cards.length === 1) {
158
+ return failed(`${id} is the last card in the wallet, and a wallet needs one. Add the new card first, then remove this one.`);
159
+ }
160
+ return {
161
+ ok: true,
162
+ wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
163
+ fields: {},
164
+ notes: [],
165
+ };
166
+ }
167
+ export function withMemberships(catalog, wallet, memberships) {
168
+ const known = knownMemberships(catalog);
169
+ const unknown = memberships.filter((m) => !known.includes(m));
170
+ if (unknown.length > 0) {
171
+ return failed(`No card rule uses ${unknown.map((m) => `'${m}'`).join(', ')}. Memberships earnrate knows: ${list(known)}.`);
172
+ }
173
+ return {
174
+ ok: true,
175
+ wallet: { ...wallet, memberships: [...new Set(memberships)] },
176
+ fields: {},
177
+ notes: [],
178
+ };
179
+ }
@@ -218,3 +218,86 @@ export const WalletChangeSchema = z.object({
218
218
  text: z.string(),
219
219
  })),
220
220
  });
221
+ const ExcludedCardSchema = z.object({
222
+ cardId: z.string(),
223
+ cardName: z.string(),
224
+ checkoutLabel: z.string().nullable(),
225
+ network: Network,
226
+ reason: z.string(),
227
+ });
228
+ const Picks = z.record(z.string(), z.array(z.string()));
229
+ export const ExplorationSchema = z.object({
230
+ merchant: MerchantResolutionSchema,
231
+ ranked: z.array(RankedCardSchema.extend({
232
+ issuer: z.string(),
233
+ network: CardNetwork,
234
+ annualFee: z.number().nullable(),
235
+ firstYearFee: z.number().nullable(),
236
+ picks: Picks,
237
+ requirements: z.object({
238
+ pick: z
239
+ .object({
240
+ choice: z.string(),
241
+ options: z.array(z.string()),
242
+ period: z.string(),
243
+ })
244
+ .nullable(),
245
+ activation: z.string().nullable(),
246
+ membership: z.string().nullable(),
247
+ cap: z
248
+ .object({
249
+ amount: z.number(),
250
+ period: z.string(),
251
+ thenRate: z.number(),
252
+ currency: z.string(),
253
+ })
254
+ .nullable(),
255
+ }),
256
+ conditions: z.array(z.string()),
257
+ evidence: z.object({ quote: z.string(), url: z.string() }).nullable(),
258
+ withMembership: z.array(z.object({ membership: z.string(), effectiveReturnPct: z.number() })),
259
+ })),
260
+ excluded: z.array(ExcludedCardSchema),
261
+ assumptions: z.array(z.string()),
262
+ warnings: z.array(z.string()),
263
+ });
264
+ const SimulationSchema = z.object({
265
+ cards: z.array(z.object({ cardId: z.string(), picks: Picks })),
266
+ spend: z.number(),
267
+ rewards: z.number(),
268
+ annualFees: z.number(),
269
+ net: z.number(),
270
+ lines: z.array(z.object({
271
+ label: z.string(),
272
+ spend: z.number(),
273
+ earned: z.number(),
274
+ returnPct: z.number(),
275
+ cards: z.array(z.string()),
276
+ })),
277
+ notes: z.array(z.string()),
278
+ });
279
+ export const SpendingSimulationSchema = z.object({
280
+ singles: z.array(SimulationSchema),
281
+ pairs: z.array(SimulationSchema.extend({ alternatives: z.array(z.string()) })),
282
+ current: SimulationSchema.nullable(),
283
+ additions: z.array(z.object({
284
+ cardId: z.string(),
285
+ picks: Picks,
286
+ extraRewards: z.number(),
287
+ annualFee: z.number(),
288
+ gain: z.number(),
289
+ })),
290
+ });
291
+ export const CheatSheetSchema = z.object({
292
+ quarter: z.string(),
293
+ date: z.string(),
294
+ rows: z.array(z.object({
295
+ label: z.string(),
296
+ cardId: z.string().nullable(),
297
+ cardName: z.string().nullable(),
298
+ effectiveReturnPct: z.number().nullable(),
299
+ matchedRule: z.string().nullable(),
300
+ note: z.string().nullable(),
301
+ })),
302
+ notes: z.array(z.string()),
303
+ });
@@ -3,20 +3,26 @@ import { z } from 'zod';
3
3
  import { MCP_SERVER_NAME } from "../brand.js";
4
4
  import { formatCardInfo, formatNotes } from "../cli/cards.js";
5
5
  import { formatRecommendation } from "../cli/format.js";
6
+ import { formatCheatSheet } from "../cli/cheatsheet.js";
7
+ import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../cli/explore.js";
6
8
  import { formatOffers } from "../cli/offers.js";
7
9
  import { walletProblems } from "../data/integrity.js";
8
10
  import { loadWallet } from "../data/load.js";
9
11
  import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
10
12
  import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
11
13
  import { bonusStatus, countsTowardBonus } from "../engine/bonus.js";
14
+ import { cheatSheet } from "../engine/cheat-sheet.js";
15
+ import { ALL_PERSONAL_CARDS, exploreCards } from "../engine/explore.js";
12
16
  import { compareOffers } from "../engine/offers.js";
17
+ import { VALUATION_PROFILES } from "../engine/profiles.js";
13
18
  import { capBooking } from "../engine/record.js";
14
19
  import { recommend } from "../engine/recommend.js";
20
+ import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
15
21
  import { knownMemberships, searchCards } from "../engine/setup.js";
16
22
  import { walletSummary } from "../engine/wallet-summary.js";
17
23
  import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
18
24
  import { IsoDate } from "../schema/common.js";
19
- import { CardSearchSchema, MerchantResolutionSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
25
+ import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
20
26
  const Merchant = z
21
27
  .string()
22
28
  .min(1)
@@ -26,7 +32,7 @@ const ChannelInput = z
26
32
  .optional()
27
33
  .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
28
34
  const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
29
- const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
35
+ const INSTRUCTIONS = `earnrate picks which of the user's credit cards earns the most for a purchase. best_cards_for and simulate_spending compare every card earnrate knows and need no wallet, for questions like "what's the best grocery card?" or "which cards fit my spending?". Its wallet lists the cards they hold by product id, never card numbers; never ask for card numbers, expiry dates or security codes.
30
36
 
31
37
  If a tool says there's no wallet yet, ask the user which cards they have, find each with search_cards, and add it with add_card. add_card and update_card reply with what a card still needs, such as category picks, memberships like Amazon Prime, or activated bonus categories. Ask the user about those and set them with update_card or set_memberships.`;
32
38
  const fail = (text) => ({
@@ -37,6 +43,7 @@ const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2
37
43
  export function createEarnrateServer(deps) {
38
44
  const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
39
45
  const { catalog } = deps;
46
+ const noWallet = `There's no wallet yet (${deps.walletFile}), which is normal before the user adds a card. Ask the user which credit cards they have, find each one with search_cards, and add it with add_card. best_cards_for and simulate_spending work without a wallet.`;
40
47
  // Read on every call, so edits to the wallet file apply without a restart.
41
48
  async function wallet() {
42
49
  let loaded;
@@ -45,7 +52,7 @@ export function createEarnrateServer(deps) {
45
52
  }
46
53
  catch (e) {
47
54
  if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
48
- return `There's no wallet yet (${deps.walletFile}). Ask the user which credit cards they have, find each one with search_cards, and add it with add_card.`;
55
+ return noWallet;
49
56
  }
50
57
  return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
51
58
  }
@@ -54,6 +61,16 @@ export function createEarnrateServer(deps) {
54
61
  ? `The wallet at ${deps.walletFile} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
55
62
  : loaded;
56
63
  }
64
+ // No wallet yet is the first-run state, not a failure: read-only tools
65
+ // answer from an empty wallet and say how to start one.
66
+ async function readableWallet() {
67
+ const w = await wallet();
68
+ if (typeof w !== 'string')
69
+ return { wallet: w, start: null };
70
+ return w === noWallet
71
+ ? { wallet: { valuations: {}, memberships: [], cards: [] }, start: w }
72
+ : w;
73
+ }
57
74
  // The caller's category is free and comes before the paid Claude fallback;
58
75
  // neither ever overrides a merchant earnrate already knows.
59
76
  async function resolve(merchant, channel, category) {
@@ -88,9 +105,10 @@ export function createEarnrateServer(deps) {
88
105
  openWorldHint: deps.llm !== null,
89
106
  },
90
107
  }, async ({ merchant, amount, channel, date, category }) => {
91
- const w = await wallet();
92
- if (typeof w === 'string')
93
- return fail(w);
108
+ const read = await readableWallet();
109
+ if (typeof read === 'string')
110
+ return fail(read);
111
+ const w = read.wallet;
94
112
  const purchase = {
95
113
  merchant,
96
114
  amount: amount ?? null,
@@ -100,6 +118,16 @@ export function createEarnrateServer(deps) {
100
118
  };
101
119
  const resolved = await resolve(merchant, purchase.channel, category ?? null);
102
120
  const rec = recommend(catalog, w, purchase, resolved.merchant);
121
+ if (read.start !== null) {
122
+ const output = {
123
+ ...rec,
124
+ warnings: [read.start, ...resolved.notices],
125
+ };
126
+ return {
127
+ content: [{ type: 'text', text: read.start }],
128
+ structuredContent: output,
129
+ };
130
+ }
103
131
  const output = {
104
132
  ...rec,
105
133
  warnings: [...rec.warnings, ...resolved.notices],
@@ -142,10 +170,15 @@ export function createEarnrateServer(deps) {
142
170
  outputSchema: WalletSummarySchema,
143
171
  annotations: { readOnlyHint: true, openWorldHint: false },
144
172
  }, async () => {
145
- const w = await wallet();
146
- if (typeof w === 'string')
147
- return fail(w);
148
- const summary = walletSummary(catalog, w, deps.today());
173
+ const read = await readableWallet();
174
+ if (typeof read === 'string')
175
+ return fail(read);
176
+ const summary = walletSummary(catalog, read.wallet, deps.today());
177
+ if (read.start !== null)
178
+ return {
179
+ content: [{ type: 'text', text: read.start }],
180
+ structuredContent: summary,
181
+ };
149
182
  const lines = summary.cards.map((c) => {
150
183
  const caps = c.caps
151
184
  .filter((cap) => cap.remaining !== null)
@@ -194,10 +227,10 @@ export function createEarnrateServer(deps) {
194
227
  outputSchema: OfferComparisonSchema,
195
228
  annotations: { readOnlyHint: true, openWorldHint: false },
196
229
  }, async (args) => {
197
- const w = await wallet();
198
- if (typeof w === 'string')
199
- return fail(w);
200
- const comparison = compareOffers(catalog, w, {
230
+ const read = await readableWallet();
231
+ if (typeof read === 'string')
232
+ return fail(read);
233
+ const comparison = compareOffers(catalog, read.wallet, {
201
234
  kind: args.kind ?? 'any',
202
235
  noAnnualFee: args.noAnnualFee ?? false,
203
236
  business: args.business ?? false,
@@ -211,6 +244,176 @@ export function createEarnrateServer(deps) {
211
244
  structuredContent: comparison,
212
245
  };
213
246
  });
247
+ const CatalogCategory = z
248
+ .enum(ids)
249
+ .describe(`A spending category: ${catalog.categories.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
250
+ const issuers = [...new Set(catalog.cards.map((c) => c.issuer))].sort();
251
+ const membershipIds = knownMemberships(catalog);
252
+ const CatalogFilters = {
253
+ kind: z
254
+ .enum(['any', 'cash', 'points'])
255
+ .optional()
256
+ .describe('cash: cash back cards only. points: points and miles cards only.'),
257
+ noAnnualFee: z
258
+ .boolean()
259
+ .optional()
260
+ .describe('Only cards with no annual fee.'),
261
+ business: z
262
+ .boolean()
263
+ .optional()
264
+ .describe('Business cards instead of personal ones.'),
265
+ issuer: z.enum(issuers).optional().describe("Only this issuer's cards."),
266
+ memberships: z
267
+ .array(z.enum(membershipIds))
268
+ .optional()
269
+ .describe("Memberships the user has, such as amazon-prime. Rates that need one they don't have are listed separately."),
270
+ valuation: z
271
+ .enum(['flat', 'points-heavy'])
272
+ .optional()
273
+ .describe('flat (default): points at their cash-out value, about 1¢. points-heavy: Amex MR and Chase UR at 1.7¢, for people who transfer points to airlines and hotels.'),
274
+ };
275
+ const settings = (args) => ({
276
+ filters: {
277
+ ...ALL_PERSONAL_CARDS,
278
+ kind: args.kind ?? 'any',
279
+ noAnnualFee: args.noAnnualFee ?? false,
280
+ business: args.business ?? false,
281
+ issuer: args.issuer ?? null,
282
+ },
283
+ memberships: args.memberships ?? [],
284
+ valuations: args.valuation ? VALUATION_PROFILES[args.valuation] : {},
285
+ });
286
+ server.registerTool('best_cards_for', {
287
+ title: 'Best cards for a purchase',
288
+ description: 'Ranks every card earnrate knows, not only the user\'s, for one purchase or category, each set up at its best (the right category pick, activated, cap unused), with its annual fee and what earning the rate takes. Use it for questions like "what\'s the best card for groceries?" or "which card should I get for Amazon?". It needs no wallet. For which of the user\'s own cards to use, call best_card_for_purchase instead. Mention the annual fee and any conditions alongside each card.',
289
+ inputSchema: z.object({
290
+ merchant: Merchant.optional(),
291
+ category: CatalogCategory.optional(),
292
+ amount: z
293
+ .number()
294
+ .positive()
295
+ .optional()
296
+ .describe('Purchase amount in US dollars, if known.'),
297
+ channel: ChannelInput,
298
+ ...CatalogFilters,
299
+ }),
300
+ outputSchema: ExplorationSchema,
301
+ annotations: { readOnlyHint: true, openWorldHint: false },
302
+ }, (args) => {
303
+ if (args.merchant === undefined && args.category === undefined)
304
+ return fail('Give a merchant or a category.');
305
+ const result = exploreCards(catalog, {
306
+ merchant: args.merchant ?? null,
307
+ category: args.merchant === undefined ? (args.category ?? null) : null,
308
+ amount: args.amount ?? null,
309
+ channel: args.channel ?? null,
310
+ date: deps.today(),
311
+ }, settings(args));
312
+ const trimmed = { ...result, ranked: result.ranked.slice(0, 10) };
313
+ return {
314
+ content: [{ type: 'text', text: formatExploration(trimmed, true) }],
315
+ structuredContent: trimmed,
316
+ };
317
+ });
318
+ server.registerTool('simulate_spending', {
319
+ title: 'Simulate a year of spending',
320
+ description: 'Spends the user\'s monthly amounts for a year across every card earnrate knows, and ranks single cards and two-card pairs by rewards minus annual fees. With compareToWallet, it starts from the user\'s wallet and ranks the cards that would add the most to it. Use it for "which card fits my spending?" or "is it worth adding a card?". Ask for rough monthly amounts if the user hasn\'t given them. It leaves out welcome bonuses.',
321
+ inputSchema: z.object({
322
+ spending: z
323
+ .array(z.object({
324
+ merchant: Merchant.optional(),
325
+ category: CatalogCategory.optional(),
326
+ monthly: z.number().positive().describe('US dollars a month.'),
327
+ }))
328
+ .min(1)
329
+ .describe('Monthly spending lines, each a category (grocery.supermarket, dining, gas, other) or a merchant (Amazon, Costco).'),
330
+ compareToWallet: z
331
+ .boolean()
332
+ .optional()
333
+ .describe("Rank the cards that would add the most to the user's wallet."),
334
+ ...CatalogFilters,
335
+ }),
336
+ outputSchema: SpendingSimulationSchema,
337
+ annotations: { readOnlyHint: true, openWorldHint: false },
338
+ }, async (args) => {
339
+ if (args.spending.some((l) => l.merchant === undefined && l.category === undefined))
340
+ return fail('Each spending line needs a merchant or a category.');
341
+ const lines = resolveSpendLines(catalog, args.spending.map((l) => ({
342
+ merchant: l.merchant ?? null,
343
+ category: l.merchant === undefined ? (l.category ?? null) : null,
344
+ monthly: l.monthly,
345
+ })));
346
+ const unknown = lines.filter((l) => l.merchant.source === 'none');
347
+ if (unknown.length > 0)
348
+ return fail(`earnrate doesn't know ${unknown.map((l) => `"${l.merchant.input}"`).join(', ')}. Give a category for those lines instead.`);
349
+ const options = { ...settings(args), start: deps.today() };
350
+ const read = args.compareToWallet ? await readableWallet() : null;
351
+ if (typeof read === 'string')
352
+ return fail(read);
353
+ if (read && read.start === null) {
354
+ const w = read.wallet;
355
+ const valued = args.valuation
356
+ ? {
357
+ ...w,
358
+ valuations: {
359
+ ...w.valuations,
360
+ ...VALUATION_PROFILES[args.valuation],
361
+ },
362
+ }
363
+ : w;
364
+ const result = simulateAdditions(catalog, valued, lines, options);
365
+ return {
366
+ content: [
367
+ { type: 'text', text: formatAdditions(result, catalog, false) },
368
+ ],
369
+ structuredContent: {
370
+ singles: [],
371
+ pairs: [],
372
+ current: result.current,
373
+ additions: result.additions
374
+ .slice(0, 10)
375
+ .map(({ simulation: _, ...a }) => a),
376
+ },
377
+ };
378
+ }
379
+ const result = simulateCatalog(catalog, lines, options);
380
+ const text = formatCatalogSimulation(result, catalog, false);
381
+ return {
382
+ content: [
383
+ {
384
+ type: 'text',
385
+ text: read?.start ? `${read.start}\n\n${text}` : text,
386
+ },
387
+ ],
388
+ structuredContent: {
389
+ singles: result.singles.slice(0, 10),
390
+ pairs: result.pairs.slice(0, 5),
391
+ current: null,
392
+ additions: [],
393
+ },
394
+ };
395
+ });
396
+ server.registerTool('cheat_sheet', {
397
+ title: 'Cheat sheet',
398
+ description: "The card in the user's wallet to use for each everyday kind of purchase right now: groceries, dining, gas, Amazon, Costco, travel, streaming, drugstores and everything else, with caps, picks and activations as the wallet has them. Use it when the user asks which card to use for what, or wants a quick reference for the quarter. For one specific store, call best_card_for_purchase instead. Pass on the notes: they say what the wallet still needs, like this quarter's picks.",
399
+ inputSchema: z.object({ date: DateInput }),
400
+ outputSchema: CheatSheetSchema,
401
+ annotations: { readOnlyHint: true, openWorldHint: false },
402
+ }, async (args) => {
403
+ const read = await readableWallet();
404
+ if (typeof read === 'string')
405
+ return fail(read);
406
+ const sheet = cheatSheet(catalog, read.wallet, args.date ?? deps.today());
407
+ if (read.start !== null)
408
+ return {
409
+ content: [{ type: 'text', text: read.start }],
410
+ structuredContent: { ...sheet, notes: [read.start] },
411
+ };
412
+ return {
413
+ content: [{ type: 'text', text: formatCheatSheet(sheet) }],
414
+ structuredContent: sheet,
415
+ };
416
+ });
214
417
  server.registerTool('record_purchase', {
215
418
  title: 'Record a purchase',
216
419
  description: "Records a purchase the user has already made, so spending caps and welcome-bonus progress stay accurate for future recommendations. It updates the user's wallet file. Only call it after the purchase has happened, and only once per purchase: calling it twice counts the spend twice. Purchases with no cap and no open welcome bonus are acknowledged and nothing is written.",
@@ -441,14 +644,13 @@ export function createEarnrateServer(deps) {
441
644
  openWorldHint: false,
442
645
  },
443
646
  }, ({ cardId }) => withWalletLock(deps.walletFile, async () => changed(await removeCard(deps.walletFile, catalog, cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
444
- const memberships = knownMemberships(catalog);
445
647
  server.registerTool('set_memberships', {
446
648
  title: 'Set memberships',
447
649
  description: "Sets the memberships that unlock some cards' rates, such as amazon-prime for Prime Visa's 5% or a Bank of America Preferred Rewards tier. Replaces the whole list, so include the ones to keep.",
448
650
  inputSchema: z.object({
449
651
  memberships: z
450
- .array(z.enum(memberships))
451
- .describe(`Every membership the user has. Known: ${memberships.join(', ')}.`),
652
+ .array(z.enum(membershipIds))
653
+ .describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`),
452
654
  }),
453
655
  outputSchema: WalletChangeSchema,
454
656
  annotations: {
@@ -133,6 +133,8 @@ export const CardSchema = z
133
133
  source_urls: z.array(z.url()).min(1),
134
134
  base_rate: z.number().nonnegative(),
135
135
  business: z.boolean().default(false),
136
+ // When the issuer stopped taking applications; cardholders keep earning.
137
+ applications_closed: IsoDate.optional(),
136
138
  annual_fee: z.number().nonnegative().optional(), // dollars
137
139
  // When the first year differs, usually a waived fee.
138
140
  first_year_fee: z.number().nonnegative().optional(),
@@ -6,6 +6,8 @@ export const CategorySchema = z.strictObject({
6
6
  // Typical MCCs, as hints for explanations and the LLM fallback. Matching is
7
7
  // by category, not by these.
8
8
  mccs: z.array(Mcc).default([]),
9
+ // Everyday words people search by ("groceries"), besides the name.
10
+ aliases: z.array(z.string().min(1)).default([]),
9
11
  note: z.string().optional(),
10
12
  });
11
13
  export const CategoriesFileSchema = z.array(CategorySchema).min(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "earnrate",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Picks the best credit card from your wallet for a merchant, and explains why",
5
5
  "keywords": [
6
6
  "credit cards",
@@ -40,11 +40,15 @@
40
40
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
41
41
  "prepack": "yarn build",
42
42
  "smoke:pack": "node scripts/smoke-pack.ts",
43
- "site:deploy": "WRANGLER_SEND_METRICS=false wrangler deploy",
43
+ "mcpb:pack": "node scripts/build-mcpb.ts",
44
+ "smoke:mcpb": "node scripts/smoke-mcpb.ts",
45
+ "site:build": "vite build site && node scripts/build-pages.ts",
46
+ "site:dev": "vite site",
47
+ "site:deploy": "yarn site:build && WRANGLER_SEND_METRICS=false wrangler deploy",
44
48
  "test": "vitest run",
45
49
  "test:watch": "vitest",
46
50
  "coverage": "vitest run --coverage --coverage.include=src/**",
47
- "typecheck": "tsc",
51
+ "typecheck": "tsc && tsc -p site",
48
52
  "lint": "eslint",
49
53
  "format": "prettier --write .",
50
54
  "format:check": "prettier --check .",
@@ -57,6 +61,7 @@
57
61
  "zod": "^4.6.7"
58
62
  },
59
63
  "devDependencies": {
64
+ "@anthropic-ai/mcpb": "^2.1.2",
60
65
  "@eslint/js": "^10.0.1",
61
66
  "@modelcontextprotocol/client": "2.3.1",
62
67
  "@types/node": "^24",
@@ -64,6 +69,7 @@
64
69
  "eslint": "^10.13.0",
65
70
  "eslint-config-prettier": "^10.1.8",
66
71
  "playwright": "1.64.1",
72
+ "preact": "^10.29.8",
67
73
  "prettier": "^3.9.10",
68
74
  "typescript": "~6.0.3",
69
75
  "typescript-eslint": "^8.72.0",