earnrate 0.1.4 → 0.2.1

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 (53) hide show
  1. package/README.md +52 -17
  2. package/data/CHANGELOG.md +8 -0
  3. package/data/VERSION +1 -1
  4. package/data/categories.yaml +22 -7
  5. package/dist/brand.js +2 -3
  6. package/dist/cli/args.js +67 -45
  7. package/dist/cli/cheatsheet.js +36 -35
  8. package/dist/cli/explore.js +47 -163
  9. package/dist/cli/import.js +3 -8
  10. package/dist/cli/offers.js +14 -56
  11. package/dist/cli/run.js +90 -20
  12. package/dist/cli/wallet.js +47 -6
  13. package/dist/data/file-store.js +95 -0
  14. package/dist/data/load.js +10 -2
  15. package/dist/data/store.js +92 -0
  16. package/dist/data/wallet-edit.js +93 -4
  17. package/dist/engine/bonus.js +15 -12
  18. package/dist/engine/cheat-sheet.js +59 -4
  19. package/dist/engine/evaluate.js +34 -10
  20. package/dist/engine/explore.js +16 -28
  21. package/dist/engine/offers.js +8 -21
  22. package/dist/engine/periods.js +9 -0
  23. package/dist/engine/recommend.js +17 -53
  24. package/dist/engine/record.js +34 -4
  25. package/dist/engine/simulate.js +33 -19
  26. package/dist/engine/text.js +53 -0
  27. package/dist/engine/transient.js +26 -0
  28. package/dist/engine/valuation.js +1 -1
  29. package/dist/engine/wallet-change.js +36 -15
  30. package/dist/engine/wallet-summary.js +4 -8
  31. package/dist/format/card-text.js +79 -0
  32. package/dist/format/cheat-sheet.js +19 -0
  33. package/dist/format/explore.js +114 -0
  34. package/dist/format/offers.js +33 -0
  35. package/dist/{cli/format.js → format/recommendation.js} +9 -11
  36. package/dist/format/wallet-yaml.js +45 -0
  37. package/dist/format/wallet.js +38 -0
  38. package/dist/import/analyze.js +17 -19
  39. package/dist/mcp/context.js +92 -0
  40. package/dist/mcp/inputs.js +148 -0
  41. package/dist/mcp/schemas.js +8 -2
  42. package/dist/mcp/serve.js +3 -1
  43. package/dist/mcp/server.js +43 -660
  44. package/dist/mcp/tools/account.js +71 -0
  45. package/dist/mcp/tools/answers.js +329 -0
  46. package/dist/mcp/tools/wallet.js +128 -0
  47. package/dist/merchants/from-env.js +1 -1
  48. package/dist/merchants/resolve.js +3 -3
  49. package/dist/schema/catalog.js +1 -0
  50. package/package.json +8 -3
  51. package/dist/data/wallet-setup.js +0 -60
  52. package/dist/data/wallet-write.js +0 -140
  53. /package/dist/{cli → format}/cards.js +0 -0
@@ -1,666 +1,49 @@
1
1
  import { McpServer } from '@modelcontextprotocol/server';
2
- import { z } from 'zod';
3
2
  import { MCP_SERVER_NAME } from "../brand.js";
4
- import { formatCardInfo, formatNotes } from "../cli/cards.js";
5
- import { formatRecommendation } from "../cli/format.js";
6
- import { formatCheatSheet } from "../cli/cheatsheet.js";
7
- import { formatAdditions, formatCatalogSimulation, formatExploration, } from "../cli/explore.js";
8
- import { formatOffers } from "../cli/offers.js";
9
- import { walletProblems } from "../data/integrity.js";
10
- import { loadWallet } from "../data/load.js";
11
- import { withWalletLock, writeBonusSpent, writeCapBooking, } from "../data/wallet-write.js";
12
- import { addCard, removeCard, setMemberships, updateCard, } from "../data/wallet-setup.js";
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";
16
- import { compareOffers } from "../engine/offers.js";
17
- import { VALUATION_PROFILES } from "../engine/profiles.js";
18
- import { capBooking } from "../engine/record.js";
19
- import { recommend } from "../engine/recommend.js";
20
- import { resolveSpendLines, simulateAdditions, simulateCatalog, } from "../engine/simulate.js";
21
- import { knownMemberships, searchCards } from "../engine/setup.js";
22
- import { walletSummary } from "../engine/wallet-summary.js";
23
- import { resolveMerchant, withCallerCategory } from "../merchants/resolve.js";
24
- import { IsoDate } from "../schema/common.js";
25
- import { CardSearchSchema, CheatSheetSchema, ExplorationSchema, MerchantResolutionSchema, SpendingSimulationSchema, RecommendationSchema, OfferComparisonSchema, RecordResultSchema, WalletChangeSchema, WalletSummarySchema, } from "./schemas.js";
26
- const Merchant = z
27
- .string()
28
- .min(1)
29
- .describe('Merchant name or web address as the user would say it or see it, e.g. "Whole Foods" or "delta.com".');
30
- const ChannelInput = z
31
- .enum(['online', 'in_store'])
32
- .optional()
33
- .describe('How the purchase is made. Omit if unknown; the answer says what it assumed.');
34
- const DateInput = IsoDate.optional().describe('Purchase date, YYYY-MM-DD. Defaults to today.');
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.
3
+ import { makeContext } from "./context.js";
4
+ import { makeInputs } from "./inputs.js";
5
+ import { registerAccountTools } from "./tools/account.js";
6
+ import { registerAnswerTools } from "./tools/answers.js";
7
+ import { registerWalletTools } from "./tools/wallet.js";
8
+ // The tools that answer without a saved wallet.
9
+ export const ANSWER_TOOLS = [
10
+ 'best_card_for_purchase',
11
+ 'best_cards_for',
12
+ 'simulate_spending',
13
+ 'compare_welcome_offers',
14
+ 'cheat_sheet',
15
+ 'resolve_merchant',
16
+ 'search_cards',
17
+ ];
18
+ // The tools that read or change a saved wallet. The hosted server asks for
19
+ // sign-in before running one.
20
+ export const SAVED_WALLET_TOOLS = [
21
+ 'list_wallet',
22
+ 'record_purchase',
23
+ 'add_card',
24
+ 'update_card',
25
+ 'remove_card',
26
+ 'set_memberships',
27
+ 'export_wallet',
28
+ 'import_wallet',
29
+ 'delete_wallet',
30
+ ];
31
+ const SAVING = {
32
+ file: 'Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools.',
33
+ account: "Only if they want earnrate to remember their cards, track spending caps or welcome bonuses, or record purchases, save a wallet with add_card and the other wallet tools. Saved wallets are anonymous: the first wallet tool asks the user to open an earnrate page, where they start a wallet (it shows a recovery code to keep; no email or name) or reconnect one with their code. Never ask for the recovery code in the conversation; it's typed only on that page. To change the code, the user opens https://mcp.earnrate.dev/change-code.",
34
+ };
35
+ const instructions = (savedWallets) => `earnrate answers which credit card earns the most for a purchase, from each issuer's own terms. Anyone can use it without entering anything: with no cards given, best_card_for_purchase, best_cards_for, cheat_sheet, simulate_spending and compare_welcome_offers answer from every card earnrate knows, and that's a complete answer. Never require the user's cards, and don't ask what they spend unless the answer depends on it; simulate_spending assumes typical amounts when none are given and says so.
36
36
 
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.`;
38
- const fail = (text) => ({
39
- content: [{ type: 'text', text }],
40
- isError: true,
41
- });
42
- const dollars = (n) => `$${n.toLocaleString('en-US', { maximumFractionDigits: 2 })}`;
37
+ Personalizing is optional. If the user names their cards, pass them as \`cards\` (ids from search_cards) for that answer; nothing is saved. ${SAVING[savedWallets]} When a card needs category picks, memberships like Amazon Prime, or activations, ask the user rather than guessing.
38
+
39
+ The wallet lists cards by product id, never card numbers; never ask for card numbers, expiry dates or security codes.`;
43
40
  export function createEarnrateServer(deps) {
44
- const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: INSTRUCTIONS });
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.`;
47
- // Read on every call, so edits to the wallet file apply without a restart.
48
- async function wallet() {
49
- let loaded;
50
- try {
51
- loaded = await loadWallet(deps.walletFile);
52
- }
53
- catch (e) {
54
- if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
55
- return noWallet;
56
- }
57
- return `Couldn't read the wallet at ${deps.walletFile}: ${e instanceof Error ? e.message : String(e)}`;
58
- }
59
- const problems = walletProblems(loaded, catalog);
60
- return problems.length > 0
61
- ? `The wallet at ${deps.walletFile} has problems:\n${problems.map((p) => `- ${p}`).join('\n')}`
62
- : loaded;
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
- }
74
- // The caller's category is free and comes before the paid Claude fallback;
75
- // neither ever overrides a merchant earnrate already knows.
76
- async function resolve(merchant, channel, category) {
77
- const known = resolveMerchant(catalog, merchant, channel);
78
- if (known.source !== 'none' || category !== null || !deps.llm) {
79
- return { merchant: withCallerCategory(known, category), notices: [] };
80
- }
81
- return deps.llm.resolve(catalog, merchant, channel);
82
- }
83
- const ids = catalog.categories.map((c) => c.id);
84
- const CategoryInput = z
85
- .enum(ids)
86
- .optional()
87
- .describe(`Only for a merchant earnrate may not know, and only if you're confident what kind of business it is: its spending category, e.g. grocery.supermarket for a supermarket, dining for a restaurant, gas for a gas station. Ignored when earnrate knows the merchant. Leave it out if unsure. Categories: ${catalog.categories.map((c) => `${c.id} (${c.name})`).join(', ')}.`);
88
- server.registerTool('best_card_for_purchase', {
89
- title: 'Best card for a purchase',
90
- description: "Picks the card in the user's wallet that earns the most rewards for a purchase, and explains why. Call it before paying. `decision.cardId` is the pick and `decision.checkoutLabel` is how the card appears at checkout. Also returns every usable card ranked with reasons, cards the merchant won't take, the assumptions made, warnings, and when a different point valuation would change the pick. Never needs or returns card numbers.",
91
- inputSchema: z.object({
92
- merchant: Merchant,
93
- amount: z
94
- .number()
95
- .positive()
96
- .optional()
97
- .describe('Purchase amount in US dollars. Improves cap math.'),
98
- channel: ChannelInput,
99
- date: DateInput,
100
- category: CategoryInput,
101
- }),
102
- outputSchema: RecommendationSchema,
103
- annotations: {
104
- readOnlyHint: true,
105
- openWorldHint: deps.llm !== null,
106
- },
107
- }, async ({ merchant, amount, channel, date, category }) => {
108
- const read = await readableWallet();
109
- if (typeof read === 'string')
110
- return fail(read);
111
- const w = read.wallet;
112
- const purchase = {
113
- merchant,
114
- amount: amount ?? null,
115
- channel: channel ?? null,
116
- date: date ?? deps.today(),
117
- country: 'US',
118
- };
119
- const resolved = await resolve(merchant, purchase.channel, category ?? null);
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
- }
131
- const output = {
132
- ...rec,
133
- warnings: [...rec.warnings, ...resolved.notices],
134
- };
135
- return {
136
- content: [
137
- { type: 'text', text: formatRecommendation(output, purchase) },
138
- ],
139
- structuredContent: output,
140
- };
141
- });
142
- server.registerTool('resolve_merchant', {
143
- title: 'Resolve a merchant',
144
- description: 'Shows how earnrate identifies a merchant: its spend category, likely MCC, which card networks it takes, confidence, and whether that came from curated data, Claude, or nowhere. Use it to explain or double-check a recommendation.',
145
- inputSchema: z.object({
146
- merchant: Merchant,
147
- channel: ChannelInput,
148
- category: CategoryInput,
149
- }),
150
- outputSchema: MerchantResolutionSchema,
151
- annotations: { readOnlyHint: true, openWorldHint: deps.llm !== null },
152
- }, async ({ merchant, channel, category }) => {
153
- const resolved = await resolve(merchant, channel ?? null, category ?? null);
154
- const m = resolved.merchant;
155
- const text = [
156
- `${m.resolvedName}: ${m.category}${m.likelyMcc === null ? '' : ` (MCC ${String(m.likelyMcc)})`}, ${m.confidence} confidence, from ${m.source}.`,
157
- ...(m.inference
158
- ? [`Claude's reasoning: ${m.inference.rationale}`]
159
- : []),
160
- ...resolved.notices,
161
- ].join('\n');
162
- return {
163
- content: [{ type: 'text', text }],
164
- structuredContent: m,
165
- };
166
- });
167
- server.registerTool('list_wallet', {
168
- title: 'List wallet',
169
- description: "Lists the user's cards: checkout label, network, rewards currency and its value, spending caps with how much is left this period, chosen bonus categories (and whether they're current), welcome-bonus progress, which stores a store card works at, and when each card's data was last verified.",
170
- outputSchema: WalletSummarySchema,
171
- annotations: { readOnlyHint: true, openWorldHint: false },
172
- }, async () => {
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
- };
182
- const lines = summary.cards.map((c) => {
183
- const caps = c.caps
184
- .filter((cap) => cap.remaining !== null)
185
- .map((cap) => `${cap.key} ${dollars(cap.spent)} of ${dollars(cap.amount)} used for ${cap.currentPeriod ?? ''}`);
186
- const chosen = c.chosen
187
- ? `; chosen categories for ${c.chosen.period}${c.chosen.current ? '' : ' (out of date)'}`
188
- : '';
189
- const onlyAt = c.onlyAt ? `; works only at ${c.onlyAt.join(', ')}` : '';
190
- const rotation = c.rotationGap === null
191
- ? ''
192
- : `; rotating categories for ${c.rotationGap} not in earnrate yet`;
193
- const b = c.bonus;
194
- const bonus = b?.state === 'open' && b.spend !== null
195
- ? `; welcome bonus ${dollars(b.spent)} of ${dollars(b.spend)} spent, due ${b.deadline ?? ''}`
196
- : b
197
- ? `; welcome bonus ${b.state}`
198
- : '';
199
- return `- ${c.checkoutLabel ?? c.cardName} (${c.cardId}): ${c.currency} at ${String(c.centsPerUnit)}¢${caps.length > 0 ? `; ${caps.join(', ')}` : ''}${chosen}${rotation}${onlyAt}${bonus}`;
200
- });
201
- return {
202
- content: [{ type: 'text', text: lines.join('\n') }],
203
- structuredContent: summary,
204
- };
205
- });
206
- server.registerTool('compare_welcome_offers', {
207
- title: 'Compare welcome offers',
208
- description: "Ranks the public welcome (sign-up) offers on cards the user doesn't have by first-year value: the bonus at the user's point values minus the first year's annual fee. Use it when the user asks which new card to get or which has the best sign-up bonus. It covers only the cards earnrate knows, offers vary by person, and each offer's note gives the issuer's eligibility rules; pass those caveats on. It doesn't account for what a card would earn on the user's spending.",
209
- inputSchema: z.object({
210
- kind: z
211
- .enum(['any', 'cash', 'points'])
212
- .optional()
213
- .describe('cash: cash back cards only. points: points and miles cards only.'),
214
- noAnnualFee: z
215
- .boolean()
216
- .optional()
217
- .describe('Only cards with no annual fee.'),
218
- business: z
219
- .boolean()
220
- .optional()
221
- .describe('Business cards instead of personal ones.'),
222
- includeHeld: z
223
- .boolean()
224
- .optional()
225
- .describe('Include cards already in the wallet.'),
226
- }),
227
- outputSchema: OfferComparisonSchema,
228
- annotations: { readOnlyHint: true, openWorldHint: false },
229
- }, async (args) => {
230
- const read = await readableWallet();
231
- if (typeof read === 'string')
232
- return fail(read);
233
- const comparison = compareOffers(catalog, read.wallet, {
234
- kind: args.kind ?? 'any',
235
- noAnnualFee: args.noAnnualFee ?? false,
236
- business: args.business ?? false,
237
- includeHeld: args.includeHeld ?? false,
238
- }, deps.today());
239
- const currencyName = (id) => catalog.currencies.find((c) => c.id === id)?.name ?? id;
240
- return {
241
- content: [
242
- { type: 'text', text: formatOffers(comparison, currencyName, false) },
243
- ],
244
- structuredContent: comparison,
245
- };
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
- });
417
- server.registerTool('record_purchase', {
418
- title: 'Record a purchase',
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.",
420
- inputSchema: z.object({
421
- cardId: z
422
- .string()
423
- .min(1)
424
- .describe('The card used, as a cardId from list_wallet or best_card_for_purchase.'),
425
- merchant: Merchant,
426
- amount: z.number().positive().describe('Amount spent in US dollars.'),
427
- channel: ChannelInput,
428
- date: DateInput,
429
- category: CategoryInput,
430
- }),
431
- outputSchema: RecordResultSchema,
432
- annotations: {
433
- readOnlyHint: false,
434
- destructiveHint: false,
435
- idempotentHint: false,
436
- openWorldHint: deps.llm !== null,
437
- },
438
- },
439
- // Queued per wallet file: parallel calls would otherwise each read the
440
- // old usage and the last write would drop the others' spend.
441
- (args) => withWalletLock(deps.walletFile, async () => {
442
- const { cardId, merchant, amount, channel, date, category } = args;
443
- const w = await wallet();
444
- if (typeof w === 'string')
445
- return fail(w);
446
- if (!w.cards.some((c) => c.id === cardId)) {
447
- return fail(`"${cardId}" isn't in the wallet. Cards: ${w.cards.map((c) => c.id).join(', ')}.`);
448
- }
449
- const purchase = {
450
- cardId,
451
- merchant,
452
- amount,
453
- channel: channel ?? null,
454
- date: date ?? deps.today(),
455
- };
456
- const resolved = await resolve(merchant, purchase.channel, category ?? null);
457
- const booking = capBooking(catalog, w, purchase, resolved.merchant);
458
- const held = w.cards.find((c) => c.id === cardId);
459
- const status = held
460
- ? bonusStatus(catalog, w, held, purchase.date)
461
- : null;
462
- const bonus = status &&
463
- status.spend !== null &&
464
- (status.remaining ?? 0) > 0 &&
465
- countsTowardBonus(status, purchase.date)
466
- ? {
467
- cardId,
468
- before: status.spent,
469
- after: Math.round((status.spent + amount) * 100) / 100,
470
- spend: status.spend,
471
- deadline: status.deadline,
472
- }
473
- : null;
474
- if (!booking && !bonus) {
475
- const output = {
476
- recorded: false,
477
- booking: null,
478
- bonus: null,
479
- message: `Nothing to track: ${cardId} has no spending cap or open welcome bonus for this purchase, so the wallet wasn't changed.`,
480
- };
481
- return {
482
- content: [{ type: 'text', text: output.message }],
483
- structuredContent: output,
484
- };
485
- }
486
- try {
487
- if (booking)
488
- await writeCapBooking(deps.walletFile, booking);
489
- if (bonus)
490
- await writeBonusSpent(deps.walletFile, cardId, bonus.after);
491
- }
492
- catch (e) {
493
- return fail(`Couldn't record it: ${e instanceof Error ? e.message : String(e)}`);
494
- }
495
- const messages = [];
496
- if (booking) {
497
- const left = Math.max(0, booking.capAmount - booking.after);
498
- messages.push(`Recorded ${dollars(amount)} on ${cardId} against "${booking.capKey}": ${dollars(booking.before)} → ${dollars(booking.after)} of ${dollars(booking.capAmount)} for ${booking.period}, ${dollars(left)} left.`);
499
- }
500
- if (bonus) {
501
- const left = Math.max(0, bonus.spend - bonus.after);
502
- messages.push(left === 0
503
- ? `Welcome bonus spend met on ${cardId}: ${dollars(bonus.after)} of ${dollars(bonus.spend)}.`
504
- : `Welcome bonus on ${cardId}: ${dollars(bonus.after)} of ${dollars(bonus.spend)} spent, ${dollars(left)} to go by ${bonus.deadline ?? 'the deadline'}.`);
505
- }
506
- const output = {
507
- recorded: true,
508
- booking,
509
- bonus,
510
- message: messages.join(' '),
511
- };
512
- return {
513
- content: [
514
- {
515
- type: 'text',
516
- text: [output.message, ...resolved.notices].join('\n'),
517
- },
518
- ],
519
- structuredContent: output,
520
- };
521
- }));
522
- const CardId = z
523
- .string()
524
- .min(1)
525
- .describe('The card id from search_cards, e.g. chase-sapphire-preferred.');
526
- const cardFields = {
527
- checkoutLabel: z
528
- .string()
529
- .min(1)
530
- .optional()
531
- .describe('How the card appears at checkout or in a browser wallet, e.g. "Amex Gold". Optional.'),
532
- picks: z
533
- .record(z.string(), z.array(z.string()).min(1))
534
- .optional()
535
- .describe('For cards where the cardholder picks bonus categories: choice id → option ids, from search_cards, e.g. {"five-percent": ["tv-internet-streaming", "fast-food"]}. Saved for the current period. Ask the user; don\'t guess.'),
536
- anniversary: z
537
- .string()
538
- .regex(/^\d{2}-\d{2}$/)
539
- .optional()
540
- .describe("The account's anniversary as MM-DD, for cards whose cap resets on it."),
541
- activated: z
542
- .array(z.string())
543
- .optional()
544
- .describe('Rule ids the user has activated with the issuer, such as rotating categories. Replaces the current list.'),
545
- };
546
- // A card change reports what that card still needs; other changes don't.
547
- function changed(result, message, cardChange = true) {
548
- if (!result.ok)
549
- return fail(result.error);
550
- const output = {
551
- message,
552
- created: result.created,
553
- cardIds: result.wallet.cards.map((c) => c.id),
554
- memberships: result.wallet.memberships,
555
- notes: result.notes,
556
- };
557
- const text = [
558
- message,
559
- `Wallet: ${output.cardIds.join(', ')}.`,
560
- ...(cardChange ? [formatNotes(result.notes)] : []),
561
- ].join('\n');
562
- return {
563
- content: [{ type: 'text', text }],
564
- structuredContent: output,
565
- };
566
- }
567
- const cardInput = (args) => ({
568
- id: args.cardId,
569
- ...(args.checkoutLabel === undefined
570
- ? {}
571
- : { checkoutLabel: args.checkoutLabel }),
572
- ...(args.picks === undefined ? {} : { picks: args.picks }),
573
- ...(args.anniversary === undefined
574
- ? {}
575
- : { anniversary: args.anniversary }),
576
- ...(args.activated === undefined ? {} : { activated: args.activated }),
577
- });
578
- const cardName = (id) => catalog.cards.find((c) => c.id === id)?.name ?? id;
579
- server.registerTool('search_cards', {
580
- title: 'Search cards',
581
- description: 'Finds credit cards in earnrate\'s catalog by name or issuer, e.g. "sapphire", "amex gold" or "bofa". Returns each card\'s id (for add_card), network, annual fee, whether it\'s already in the wallet, and what it needs set up: category picks and their options, memberships, activations. Leave the query out to list every card. US cards only.',
582
- inputSchema: z.object({
583
- query: z
584
- .string()
585
- .optional()
586
- .describe('Card name or issuer, as the user said it.'),
587
- }),
588
- outputSchema: CardSearchSchema,
589
- annotations: { readOnlyHint: true, openWorldHint: false },
590
- }, async ({ query }) => {
591
- const held = await loadWallet(deps.walletFile).catch(() => null);
592
- const result = searchCards(catalog, query ?? '', held, deps.today());
593
- const header = result.exact
594
- ? ''
595
- : result.cards.length > 0
596
- ? 'No card matched every word. Closest:\n'
597
- : `No card matched "${query ?? ''}". earnrate covers ${String(catalog.cards.length)} US cards; list them all by leaving the query out.`;
598
- return {
599
- content: [
600
- {
601
- type: 'text',
602
- text: header + result.cards.map(formatCardInfo).join('\n'),
603
- },
604
- ],
605
- structuredContent: result,
606
- };
607
- });
608
- server.registerTool('add_card', {
609
- title: 'Add a card',
610
- description: "Adds a card the user holds to their wallet by its id from search_cards, creating the wallet if there isn't one. The reply says what the card still needs (category picks, memberships, activations); ask the user about those. Stores only the card's product id and settings, never card numbers.",
611
- inputSchema: z.object({ cardId: CardId, ...cardFields }),
612
- outputSchema: WalletChangeSchema,
613
- annotations: {
614
- readOnlyHint: false,
615
- destructiveHint: false,
616
- idempotentHint: false,
617
- openWorldHint: false,
618
- },
619
- }, (args) => withWalletLock(deps.walletFile, async () => {
620
- const result = await addCard(deps.walletFile, catalog, cardInput(args), deps.today());
621
- return changed(result, `Added ${cardName(args.cardId)} (${args.cardId})${result.ok && result.created ? `, starting a new wallet at ${deps.walletFile}` : ''}.`);
622
- }));
623
- server.registerTool('update_card', {
624
- title: 'Update a card',
625
- description: 'Changes settings on a card already in the wallet: its category picks for this period, checkout label, anniversary or activated categories. Picks for the current period are merged with the ones already saved. Use it when the user picks new categories, such as each quarter on U.S. Bank Cash+.',
626
- inputSchema: z.object({ cardId: CardId, ...cardFields }),
627
- outputSchema: WalletChangeSchema,
628
- annotations: {
629
- readOnlyHint: false,
630
- destructiveHint: false,
631
- idempotentHint: true,
632
- openWorldHint: false,
633
- },
634
- }, (args) => withWalletLock(deps.walletFile, async () => changed(await updateCard(deps.walletFile, catalog, cardInput(args), deps.today()), `Updated ${cardName(args.cardId)} (${args.cardId}).`)));
635
- server.registerTool('remove_card', {
636
- title: 'Remove a card',
637
- description: "Removes a card from the wallet, with its tracked cap usage and bonus progress. Only when the user says they no longer have it or don't want it considered.",
638
- inputSchema: z.object({ cardId: CardId }),
639
- outputSchema: WalletChangeSchema,
640
- annotations: {
641
- readOnlyHint: false,
642
- destructiveHint: true,
643
- idempotentHint: true,
644
- openWorldHint: false,
645
- },
646
- }, ({ cardId }) => withWalletLock(deps.walletFile, async () => changed(await removeCard(deps.walletFile, catalog, cardId), `Removed ${cardName(cardId)} (${cardId}).`, false)));
647
- server.registerTool('set_memberships', {
648
- title: 'Set memberships',
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.",
650
- inputSchema: z.object({
651
- memberships: z
652
- .array(z.enum(membershipIds))
653
- .describe(`Every membership the user has. Known: ${membershipIds.join(', ')}.`),
654
- }),
655
- outputSchema: WalletChangeSchema,
656
- annotations: {
657
- readOnlyHint: false,
658
- destructiveHint: false,
659
- idempotentHint: true,
660
- openWorldHint: false,
661
- },
662
- }, (args) => withWalletLock(deps.walletFile, async () => changed(await setMemberships(deps.walletFile, catalog, args.memberships), args.memberships.length > 0
663
- ? `Memberships set to ${args.memberships.join(', ')}.`
664
- : 'Memberships cleared.', false)));
41
+ const server = new McpServer({ name: MCP_SERVER_NAME, version: deps.version }, { instructions: instructions(deps.savedWallets) });
42
+ const ctx = makeContext(deps);
43
+ const inputs = makeInputs(deps.catalog);
44
+ registerAnswerTools(server, deps, ctx, inputs);
45
+ registerWalletTools(server, deps, ctx, inputs);
46
+ if (deps.savedWallets === 'account')
47
+ registerAccountTools(server, deps.catalog, deps.store, ctx);
665
48
  return server;
666
49
  }