earnrate 0.2.2 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/data/merchants/overrides.yaml +4 -1
- package/dist/data/file-store.js +3 -1
- package/dist/data/store.js +9 -1
- package/dist/engine/recommend.js +14 -1
- package/dist/engine/setup.js +1 -1
- package/dist/engine/wallet-change.js +2 -3
- package/dist/engine/wallet-summary.js +105 -3
- package/dist/format/wallet.js +18 -8
- package/dist/mcp/context.js +7 -3
- package/dist/mcp/schemas.js +5 -0
- package/dist/mcp/server.js +3 -3
- package/dist/mcp/tools/account.js +33 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -167,7 +167,7 @@ Spotted a wrong rate, or want a card added? [Open an issue](https://github.com/c
|
|
|
167
167
|
|
|
168
168
|
## Use it in an AI assistant (MCP)
|
|
169
169
|
|
|
170
|
-
**In Claude, nothing to install:** add a custom connector (Settings → Connectors) with the address `https://mcp.earnrate.dev`. It works in Claude on the web, desktop and phone.
|
|
170
|
+
**In Claude, nothing to install:** add a custom connector (Settings → Connectors) with the address `https://mcp.earnrate.dev`. It works in Claude on the web, desktop and phone. When you add it, an earnrate page opens once, where you start an anonymous wallet: no email, just a recovery code you keep. Then ask anything: with no cards saved it answers from every card. Name your cards for answers about yours, and save them to track caps and welcome bonuses across your Claude apps. From the chat you can export the wallet for the command line, switch to another one, or delete it. In Claude Code:
|
|
171
171
|
|
|
172
172
|
```sh
|
|
173
173
|
claude mcp add --transport http earnrate https://mcp.earnrate.dev
|
|
@@ -113,7 +113,10 @@
|
|
|
113
113
|
category: warehouse_club
|
|
114
114
|
in_store:
|
|
115
115
|
accepts: [visa]
|
|
116
|
-
|
|
116
|
+
online:
|
|
117
|
+
accepts: [visa, mastercard]
|
|
118
|
+
# customerservice.costco.com/app/answers/answer_view/a_id/719 (2026-10-31)
|
|
119
|
+
note: Warehouse registers take Visa only for credit; Costco.com takes Visa and Mastercard.
|
|
117
120
|
- id: costco-gas
|
|
118
121
|
name: Costco Gas
|
|
119
122
|
aliases: [costco gas station]
|
package/dist/data/file-store.js
CHANGED
|
@@ -69,7 +69,9 @@ export class FileWalletStore {
|
|
|
69
69
|
return this.change(this.existing((w) => updateInWallet(this.catalog, w, input, today)), (text, change) => updateCardText(text ?? '', input.id, change.fields));
|
|
70
70
|
}
|
|
71
71
|
removeCard(cardId) {
|
|
72
|
-
return this.change(this.existing((w) =>
|
|
72
|
+
return this.change(this.existing((w) => w.cards.length === 1 && w.cards[0]?.id === cardId
|
|
73
|
+
? failed(`${cardId} is the last card in ${this.where}. To start over, delete that file; to swap cards, add the new one first.`)
|
|
74
|
+
: removeFromWallet(w, cardId)), (text) => removeCardText(text ?? '', cardId));
|
|
73
75
|
}
|
|
74
76
|
setMemberships(memberships) {
|
|
75
77
|
return this.change(this.existing((w) => withMemberships(this.catalog, w, memberships)), (text, change) => setMembershipsText(text ?? '', change.wallet.memberships));
|
package/dist/data/store.js
CHANGED
|
@@ -30,7 +30,11 @@ export class JsonWalletStore {
|
|
|
30
30
|
const change = apply(before);
|
|
31
31
|
if (!change.ok)
|
|
32
32
|
return failed(change.error);
|
|
33
|
-
|
|
33
|
+
// An empty wallet is stored as none: the schema needs a card.
|
|
34
|
+
if (change.wallet.cards.length === 0)
|
|
35
|
+
await this.backend.remove();
|
|
36
|
+
else
|
|
37
|
+
await this.backend.write(JSON.stringify(change.wallet));
|
|
34
38
|
return {
|
|
35
39
|
ok: true,
|
|
36
40
|
wallet: change.wallet,
|
|
@@ -62,6 +66,10 @@ export class JsonWalletStore {
|
|
|
62
66
|
erase() {
|
|
63
67
|
return this.serialized(() => this.backend.remove());
|
|
64
68
|
}
|
|
69
|
+
// Nothing to sign out of: the backend is the caller's own storage.
|
|
70
|
+
disconnect() {
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
65
73
|
record(purchase, merchant) {
|
|
66
74
|
return this.serialized(async () => {
|
|
67
75
|
const wallet = await this.load();
|
package/dist/engine/recommend.js
CHANGED
|
@@ -23,6 +23,11 @@ const PERIOD_WORDS = {
|
|
|
23
23
|
const displayName = (e) => e.held.checkout_label ?? e.card.name;
|
|
24
24
|
// "Worth X or more" must hold at X itself, so round up; "less than X" rounds down.
|
|
25
25
|
const roundUp = (n) => String(Math.ceil(n * 100 - EPSILON) / 100);
|
|
26
|
+
// At exactly the break-even value the two cards tie, so only an exact
|
|
27
|
+
// threshold reads "more than".
|
|
28
|
+
const worthAbove = (n) => Math.abs(n * 100 - Math.round(n * 100)) < EPSILON
|
|
29
|
+
? `more than ${num(n)}¢`
|
|
30
|
+
: `${roundUp(n)}¢ or more`;
|
|
26
31
|
const roundDown = (n) => String(Math.floor(n * 100 + EPSILON) / 100);
|
|
27
32
|
function compareEntries(a, b) {
|
|
28
33
|
const byReturn = b.effectiveReturnPct - a.effectiveReturnPct;
|
|
@@ -218,7 +223,7 @@ function sensitivity(catalog, entries) {
|
|
|
218
223
|
if (o.range && oRate > 0) {
|
|
219
224
|
const threshold = winner.effectiveReturnPct / oRate;
|
|
220
225
|
if (threshold > o.centsPerUnit && threshold <= o.range[1]) {
|
|
221
|
-
consider((threshold - o.centsPerUnit) / o.centsPerUnit, `${displayName(other)} beats ${displayName(winner)} here if ${currencyLabel(catalog, o.currency)} points are worth ${
|
|
226
|
+
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)}¢).`);
|
|
222
227
|
}
|
|
223
228
|
}
|
|
224
229
|
}
|
|
@@ -283,6 +288,14 @@ export function recommend(catalog, wallet, purchase, merchant = resolveMerchant(
|
|
|
283
288
|
}
|
|
284
289
|
if (!winner) {
|
|
285
290
|
warnings.push(`None of your cards work at ${merchant.resolvedName}.`);
|
|
291
|
+
const online = merchant.channel === 'in_store' && merchant.merchantId !== null
|
|
292
|
+
? resolveMerchantById(catalog, merchant.merchantId, 'online')
|
|
293
|
+
: null;
|
|
294
|
+
const alt = online
|
|
295
|
+
? rankEntries(catalog, wallet, { ...purchase, channel: 'online' }, online, online.category).entries[0]
|
|
296
|
+
: undefined;
|
|
297
|
+
if (alt)
|
|
298
|
+
warnings.push(`Ordering online instead? ${displayName(alt)} works there (${num(alt.effectiveReturnPct)}%).`);
|
|
286
299
|
}
|
|
287
300
|
else if (merchant.accepts === null &&
|
|
288
301
|
(winner.card.network === 'amex' || winner.card.network === 'discover')) {
|
package/dist/engine/setup.js
CHANGED
|
@@ -49,7 +49,7 @@ function needsAnniversary(card) {
|
|
|
49
49
|
return (card.caps.some((c) => c.period === 'cardmember_year') ||
|
|
50
50
|
card.rules.some((r) => typeof r.cap === 'object' && r.cap.period === 'cardmember_year'));
|
|
51
51
|
}
|
|
52
|
-
function activationRules(card, today) {
|
|
52
|
+
export function activationRules(card, today) {
|
|
53
53
|
return card.rules
|
|
54
54
|
.filter((r) => r.requires?.activation === true &&
|
|
55
55
|
(r.valid_to === undefined || r.valid_to >= today) &&
|
|
@@ -208,9 +208,8 @@ export function removeFromWallet(wallet, id) {
|
|
|
208
208
|
if (!wallet.cards.some((c) => c.id === id)) {
|
|
209
209
|
return failed(`${id} isn't in the wallet. Cards: ${list(wallet.cards.map((c) => c.id))}.`);
|
|
210
210
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
211
|
+
// Removing the last card leaves an empty wallet, which each store keeps
|
|
212
|
+
// in its own way (or refuses, for a wallet file).
|
|
214
213
|
return {
|
|
215
214
|
ok: true,
|
|
216
215
|
wallet: { ...wallet, cards: wallet.cards.filter((c) => c.id !== id) },
|
|
@@ -1,10 +1,98 @@
|
|
|
1
1
|
import { bonusStatus } from "./bonus.js";
|
|
2
|
-
import { capUsed, pickedCategories } from "./evaluate.js";
|
|
2
|
+
import { activationGroup, capUsed, isActivated, pickedCategories, } from "./evaluate.js";
|
|
3
3
|
import { daysBetween } from "./periods.js";
|
|
4
4
|
import { STALE_AFTER_DAYS } from "./recommend.js";
|
|
5
5
|
import { rotationGap } from "./rotation.js";
|
|
6
|
+
import { activationRules } from "./setup.js";
|
|
7
|
+
import { categoryPhrase, earnText } from "./text.js";
|
|
6
8
|
import { cardValuation } from "./valuation.js";
|
|
7
|
-
|
|
9
|
+
const NUMBERS = {
|
|
10
|
+
one: '1',
|
|
11
|
+
two: '2',
|
|
12
|
+
three: '3',
|
|
13
|
+
four: '4',
|
|
14
|
+
five: '5',
|
|
15
|
+
six: '6',
|
|
16
|
+
};
|
|
17
|
+
// "five-percent" reads "5%"; other ids read as words.
|
|
18
|
+
export function choiceName(id) {
|
|
19
|
+
return id
|
|
20
|
+
.replace(/\b(one|two|three|four|five|six)-percent\b/g, (_, n) => `${NUMBERS[n] ?? n}%`)
|
|
21
|
+
.replace(/[-_.]/g, ' ');
|
|
22
|
+
}
|
|
23
|
+
const listed = (parts) => parts.length <= 2
|
|
24
|
+
? parts.join(' and ')
|
|
25
|
+
: `${parts.slice(0, -1).join(', ')} and ${parts.at(-1) ?? ''}`;
|
|
26
|
+
function merchantName(catalog, id) {
|
|
27
|
+
const all = [...catalog.merchants.overrides, ...catalog.merchants.seed];
|
|
28
|
+
return all.find((m) => m.id === id)?.name ?? id.replace(/[-_.]/g, ' ');
|
|
29
|
+
}
|
|
30
|
+
function optionName(catalog, card, choiceId, option) {
|
|
31
|
+
const o = card.choices.find((c) => c.id === choiceId)?.options[option];
|
|
32
|
+
if (!o)
|
|
33
|
+
return option.replace(/[-_.]/g, ' ');
|
|
34
|
+
const parts = [
|
|
35
|
+
...o.categories.map((c) => categoryPhrase(catalog, c)),
|
|
36
|
+
...o.merchants.map((m) => merchantName(catalog, m)),
|
|
37
|
+
];
|
|
38
|
+
if (o.online)
|
|
39
|
+
parts.push('online shopping');
|
|
40
|
+
return parts.length > 0 ? listed(parts) : option.replace(/[-_.]/g, ' ');
|
|
41
|
+
}
|
|
42
|
+
function ruleWhat(catalog, rule) {
|
|
43
|
+
if (rule.match.choice !== undefined)
|
|
44
|
+
return `your ${choiceName(rule.match.choice)} picks`;
|
|
45
|
+
const parts = [
|
|
46
|
+
...(rule.match.categories ?? []).map((c) => categoryPhrase(catalog, c)),
|
|
47
|
+
...(rule.match.merchants ?? []).map((m) => merchantName(catalog, m)),
|
|
48
|
+
];
|
|
49
|
+
return parts.length > 0 ? listed(parts) : rule.id.replace(/[-_.]/g, ' ');
|
|
50
|
+
}
|
|
51
|
+
function periodEnd(period) {
|
|
52
|
+
const quarter = /^(\d{4})-Q([1-4])$/.exec(period);
|
|
53
|
+
if (quarter) {
|
|
54
|
+
const end = ['03-31', '06-30', '09-30', '12-31'][Number(quarter[2]) - 1];
|
|
55
|
+
return `${quarter[1] ?? ''}-${end ?? ''}`;
|
|
56
|
+
}
|
|
57
|
+
const month = /^(\d{4})-(\d{2})$/.exec(period);
|
|
58
|
+
if (month) {
|
|
59
|
+
const last = new Date(Date.UTC(Number(month[1]), Number(month[2]), 0));
|
|
60
|
+
return last.toISOString().slice(0, 10);
|
|
61
|
+
}
|
|
62
|
+
return /^\d{4}$/.test(period) ? `${period}-12-31` : null;
|
|
63
|
+
}
|
|
64
|
+
const applies = (rule, today) => (rule.valid_from === undefined || rule.valid_from <= today) &&
|
|
65
|
+
(rule.valid_to === undefined || rule.valid_to >= today);
|
|
66
|
+
function headlineRates(catalog, card, currency, today) {
|
|
67
|
+
const best = card.rules
|
|
68
|
+
.filter((r) => applies(r, today) && r.rate > card.base_rate)
|
|
69
|
+
.sort((a, b) => b.rate - a.rate)
|
|
70
|
+
.slice(0, 4)
|
|
71
|
+
.map((r) => `${earnText(catalog, r.rate, currency)} on ${ruleWhat(catalog, r)}${r.requires?.activation ? ' (once activated)' : ''}`);
|
|
72
|
+
return [
|
|
73
|
+
...best,
|
|
74
|
+
`${earnText(catalog, card.base_rate, currency)} on everything else`,
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
function toActivate(catalog, card, held, today) {
|
|
78
|
+
const seen = new Set();
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const id of activationRules(card, today)) {
|
|
81
|
+
if (seen.has(id) || isActivated(card, held, id))
|
|
82
|
+
continue;
|
|
83
|
+
const group = activationGroup(card, id);
|
|
84
|
+
for (const g of group)
|
|
85
|
+
seen.add(g);
|
|
86
|
+
const rules = card.rules.filter((r) => group.includes(r.id));
|
|
87
|
+
const first = rules[0];
|
|
88
|
+
const when = first?.valid_from !== undefined && first.valid_to !== undefined
|
|
89
|
+
? ` (${first.valid_from} to ${first.valid_to})`
|
|
90
|
+
: '';
|
|
91
|
+
out.push(`${rules.map((r) => ruleWhat(catalog, r)).join(', ')}${when}`);
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
function capSummaries(catalog, card, held, today) {
|
|
8
96
|
const caps = new Map();
|
|
9
97
|
for (const shared of card.caps)
|
|
10
98
|
caps.set(shared.id, { cap: shared, rules: [] });
|
|
@@ -20,8 +108,15 @@ function capSummaries(card, held, today) {
|
|
|
20
108
|
}
|
|
21
109
|
return [...caps].map(([key, { cap, rules }]) => {
|
|
22
110
|
const { period: currentPeriod, spent } = capUsed(cap, key, held, today);
|
|
111
|
+
const covered = card.rules.filter((r) => rules.includes(r.id));
|
|
112
|
+
const shared = card.caps.find((c) => c.id === key);
|
|
113
|
+
const label = shared?.rotating
|
|
114
|
+
? 'the rotating bonus categories'
|
|
115
|
+
: [...new Set(covered.map((r) => ruleWhat(catalog, r)))].join(', ') ||
|
|
116
|
+
key.replace(/[-_.]/g, ' ');
|
|
23
117
|
return {
|
|
24
118
|
key,
|
|
119
|
+
label,
|
|
25
120
|
rules,
|
|
26
121
|
amount: cap.amount,
|
|
27
122
|
period: cap.period,
|
|
@@ -52,15 +147,22 @@ export function walletSummary(catalog, wallet, today) {
|
|
|
52
147
|
verifiedBy: card.verified_by,
|
|
53
148
|
dataIsStale: daysBetween(card.last_verified, today) > STALE_AFTER_DAYS,
|
|
54
149
|
rotationGap: rotationGap(card, today),
|
|
55
|
-
caps: capSummaries(card, held, today),
|
|
150
|
+
caps: capSummaries(catalog, card, held, today),
|
|
56
151
|
chosen: held.chosen
|
|
57
152
|
? {
|
|
58
153
|
period: held.chosen.period,
|
|
59
154
|
current: pickedCategories(card, held, today).staleChoice === null,
|
|
155
|
+
until: periodEnd(held.chosen.period),
|
|
60
156
|
picks: held.chosen.picks,
|
|
157
|
+
pickNames: Object.fromEntries(Object.entries(held.chosen.picks).map(([choice, options]) => [
|
|
158
|
+
choiceName(choice),
|
|
159
|
+
options.map((o) => optionName(catalog, card, choice, o)),
|
|
160
|
+
])),
|
|
61
161
|
}
|
|
62
162
|
: null,
|
|
163
|
+
rates: headlineRates(catalog, card, valuation.currency, today),
|
|
63
164
|
activated: held.activated,
|
|
165
|
+
toActivate: toActivate(catalog, card, held, today),
|
|
64
166
|
anniversary: held.anniversary ?? null,
|
|
65
167
|
});
|
|
66
168
|
}
|
package/dist/format/wallet.js
CHANGED
|
@@ -6,29 +6,39 @@ const NETWORKS = {
|
|
|
6
6
|
discover: 'Discover',
|
|
7
7
|
store: 'store card',
|
|
8
8
|
};
|
|
9
|
-
const
|
|
9
|
+
const day = (date) => new Date(`${date}T12:00:00Z`).toLocaleDateString('en-US', {
|
|
10
|
+
month: 'short',
|
|
11
|
+
day: 'numeric',
|
|
12
|
+
year: 'numeric',
|
|
13
|
+
timeZone: 'UTC',
|
|
14
|
+
});
|
|
10
15
|
export function formatWalletSummary(summary) {
|
|
11
16
|
const cards = summary.cards.map((c) => {
|
|
17
|
+
const label = c.checkoutLabel
|
|
18
|
+
? `, shown at checkout as "${c.checkoutLabel}"`
|
|
19
|
+
: '';
|
|
12
20
|
const lines = [
|
|
13
|
-
`- ${c.
|
|
21
|
+
`- ${c.cardName} (${c.cardId})${label}: ${NETWORKS[c.network] ?? c.network}, ${c.currency} at ${String(c.centsPerUnit)}¢. Data checked ${c.lastVerified}${c.dataIsStale ? ' (old; check the issuer)' : ''}.`,
|
|
22
|
+
` Earns: ${c.rates.join('; ')}.`,
|
|
14
23
|
];
|
|
15
24
|
if (c.onlyAt)
|
|
16
25
|
lines.push(` Works only at ${c.onlyAt.join(', ')}.`);
|
|
17
26
|
if (c.chosen) {
|
|
18
|
-
const picks = Object.entries(c.chosen.
|
|
19
|
-
.map(([choice,
|
|
27
|
+
const picks = Object.entries(c.chosen.pickNames)
|
|
28
|
+
.map(([choice, names]) => `${choice}: ${names.join(', ')}`)
|
|
20
29
|
.join('; ');
|
|
30
|
+
const until = c.chosen.until ? `, through ${day(c.chosen.until)}` : '';
|
|
21
31
|
lines.push(c.chosen.current
|
|
22
|
-
? ` Picks for ${c.chosen.period}: ${picks}.
|
|
32
|
+
? ` Picks for ${c.chosen.period}${until}: ${picks}. Pick again after that.`
|
|
23
33
|
: ` Picks are for ${c.chosen.period}, which is over: ask for this period's picks.`);
|
|
24
34
|
}
|
|
25
|
-
if (c.
|
|
26
|
-
lines.push(`
|
|
35
|
+
if (c.toActivate.length > 0)
|
|
36
|
+
lines.push(` Not activated yet: ${c.toActivate.join('; ')}. Activating once with the issuer covers each group.`);
|
|
27
37
|
if (c.rotationGap !== null)
|
|
28
38
|
lines.push(` Rotating categories for ${c.rotationGap} aren't in earnrate yet.`);
|
|
29
39
|
for (const cap of c.caps)
|
|
30
40
|
if (cap.remaining !== null)
|
|
31
|
-
lines.push(` Cap ${cap.
|
|
41
|
+
lines.push(` Cap on ${cap.label}: ${money(cap.spent)} of ${money(cap.amount)} used for ${cap.currentPeriod ?? ''} (cap key ${cap.key}).`);
|
|
32
42
|
const b = c.bonus;
|
|
33
43
|
if (b?.state === 'open' && b.spend !== null)
|
|
34
44
|
lines.push(` Welcome bonus: ${money(b.spent)} of ${money(b.spend)} spent, due ${b.deadline ?? ''}.`);
|
package/dist/mcp/context.js
CHANGED
|
@@ -7,7 +7,7 @@ export const fail = (text) => ({
|
|
|
7
7
|
content: [{ type: 'text', text }],
|
|
8
8
|
isError: true,
|
|
9
9
|
});
|
|
10
|
-
export const ALL_CARDS_NOTE = "
|
|
10
|
+
export const ALL_CARDS_NOTE = "No cards are saved or named, so this covers every card earnrate knows. That's a complete answer. If the user wants the best of their own cards, they can name them (pass them as `cards`); saving them is optional.";
|
|
11
11
|
// What every tool shares: the saved wallet, merchant resolution, and which
|
|
12
12
|
// cards an answer is about.
|
|
13
13
|
export function makeContext(deps) {
|
|
@@ -15,7 +15,9 @@ export function makeContext(deps) {
|
|
|
15
15
|
const theWallet = deps.savedWallets === 'file'
|
|
16
16
|
? `the wallet at ${deps.store.where}`
|
|
17
17
|
: 'the saved wallet';
|
|
18
|
-
const noWallet =
|
|
18
|
+
const noWallet = deps.savedWallets === 'file'
|
|
19
|
+
? `There's no saved wallet yet (${deps.store.where}). Answers don't need one: they cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.`
|
|
20
|
+
: "The user's saved wallet has no cards yet. Answers cover every card, or the cards the user names. If the user wants their cards remembered, find each with search_cards and save it with add_card.";
|
|
19
21
|
// Read on every call, so edits to the wallet apply without a restart.
|
|
20
22
|
async function wallet() {
|
|
21
23
|
let loaded;
|
|
@@ -79,7 +81,9 @@ export function makeContext(deps) {
|
|
|
79
81
|
};
|
|
80
82
|
const text = [
|
|
81
83
|
message,
|
|
82
|
-
|
|
84
|
+
output.cardIds.length > 0
|
|
85
|
+
? `Wallet: ${output.cardIds.join(', ')}.`
|
|
86
|
+
: 'The wallet has no cards now; answers cover every card.',
|
|
83
87
|
...(notes ? [formatNotes(result.notes)] : []),
|
|
84
88
|
].join('\n');
|
|
85
89
|
return {
|
package/dist/mcp/schemas.js
CHANGED
|
@@ -106,6 +106,7 @@ export const WalletSummarySchema = z.object({
|
|
|
106
106
|
rotationGap: z.string().nullable(),
|
|
107
107
|
caps: z.array(z.object({
|
|
108
108
|
key: z.string(),
|
|
109
|
+
label: z.string(),
|
|
109
110
|
rules: z.array(z.string()),
|
|
110
111
|
amount: z.number(),
|
|
111
112
|
period: CapPeriod,
|
|
@@ -117,10 +118,14 @@ export const WalletSummarySchema = z.object({
|
|
|
117
118
|
.object({
|
|
118
119
|
period: z.string(),
|
|
119
120
|
current: z.boolean(),
|
|
121
|
+
until: z.string().nullable(),
|
|
120
122
|
picks: z.record(z.string(), z.array(z.string())),
|
|
123
|
+
pickNames: z.record(z.string(), z.array(z.string())),
|
|
121
124
|
})
|
|
122
125
|
.nullable(),
|
|
126
|
+
rates: z.array(z.string()),
|
|
123
127
|
activated: z.array(z.string()),
|
|
128
|
+
toActivate: z.array(z.string()),
|
|
124
129
|
anniversary: z.string().nullable(),
|
|
125
130
|
})),
|
|
126
131
|
});
|
package/dist/mcp/server.js
CHANGED
|
@@ -15,8 +15,7 @@ export const ANSWER_TOOLS = [
|
|
|
15
15
|
'resolve_merchant',
|
|
16
16
|
'search_cards',
|
|
17
17
|
];
|
|
18
|
-
// The tools that read or change a saved wallet.
|
|
19
|
-
// sign-in before running one.
|
|
18
|
+
// The tools that read or change a saved wallet.
|
|
20
19
|
export const SAVED_WALLET_TOOLS = [
|
|
21
20
|
'list_wallet',
|
|
22
21
|
'record_purchase',
|
|
@@ -27,10 +26,11 @@ export const SAVED_WALLET_TOOLS = [
|
|
|
27
26
|
'export_wallet',
|
|
28
27
|
'import_wallet',
|
|
29
28
|
'delete_wallet',
|
|
29
|
+
'switch_wallet',
|
|
30
30
|
];
|
|
31
31
|
const SAVING = {
|
|
32
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: "
|
|
33
|
+
account: "The user signed in to earnrate when they connected it, to an anonymous saved wallet (no email or name). It may have no cards yet; answers then cover every card. Save cards with add_card only when the user agrees. The wallet can change outside this conversation when the user signs in again on earnrate's page, so read it with list_wallet rather than from memory. To use a different wallet or start a new one, call switch_wallet; the user then connects on earnrate's page with that wallet's recovery code. Never ask for the recovery code in the conversation; it's typed only on earnrate's page. To change the code, the user opens https://mcp.earnrate.dev/change-code. earnrate.dev and the earnrate command line keep their own separate wallets; export_wallet and import_wallet move one between them.",
|
|
34
34
|
};
|
|
35
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
|
|
|
@@ -54,8 +54,12 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
54
54
|
});
|
|
55
55
|
server.registerTool('delete_wallet', {
|
|
56
56
|
title: 'Delete the wallet',
|
|
57
|
-
description:
|
|
58
|
-
inputSchema: z.object({
|
|
57
|
+
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: offer export_wallet first, and pass confirm only after the user confirms. It refuses when the wallet has no cards, since there's nothing to delete.",
|
|
58
|
+
inputSchema: z.object({
|
|
59
|
+
confirm: z
|
|
60
|
+
.literal(true)
|
|
61
|
+
.describe('true, only after the user confirms the deletion.'),
|
|
62
|
+
}),
|
|
59
63
|
outputSchema: z.object({ deleted: z.boolean() }),
|
|
60
64
|
annotations: {
|
|
61
65
|
readOnlyHint: false,
|
|
@@ -64,15 +68,41 @@ export function registerAccountTools(server, catalog, store, ctx) {
|
|
|
64
68
|
openWorldHint: false,
|
|
65
69
|
},
|
|
66
70
|
}, async () => {
|
|
71
|
+
const current = await store.load().catch(() => null);
|
|
72
|
+
if (current === null || current.cards.length === 0)
|
|
73
|
+
return fail("This saved wallet has no cards, so there's nothing to delete. If the user meant a different wallet, it may already be deleted.");
|
|
67
74
|
await store.erase();
|
|
68
75
|
return {
|
|
69
76
|
content: [
|
|
70
77
|
{
|
|
71
78
|
type: 'text',
|
|
72
|
-
text:
|
|
79
|
+
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.",
|
|
73
80
|
},
|
|
74
81
|
],
|
|
75
82
|
structuredContent: { deleted: true },
|
|
76
83
|
};
|
|
77
84
|
});
|
|
85
|
+
server.registerTool('switch_wallet', {
|
|
86
|
+
title: 'Switch wallets',
|
|
87
|
+
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. Use it when the user wants another wallet. Never ask for the recovery code in the conversation.",
|
|
88
|
+
inputSchema: z.object({}),
|
|
89
|
+
outputSchema: z.object({ disconnected: z.boolean() }),
|
|
90
|
+
annotations: {
|
|
91
|
+
readOnlyHint: false,
|
|
92
|
+
destructiveHint: false,
|
|
93
|
+
idempotentHint: true,
|
|
94
|
+
openWorldHint: false,
|
|
95
|
+
},
|
|
96
|
+
}, async () => {
|
|
97
|
+
await store.disconnect();
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
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.",
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
structuredContent: { disconnected: true },
|
|
106
|
+
};
|
|
107
|
+
});
|
|
78
108
|
}
|