rightcard-mcp 0.1.0

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/dist/model.js ADDED
@@ -0,0 +1,133 @@
1
+ // Mirrors Models/CreditCard.swift, Models/SpendCategory.swift, SupabaseModels.swift.
2
+ // Every rule here has a Swift twin; parity is enforced by test/parity.test.ts.
3
+ export const ALL_CATEGORIES = ["dining", "grocery", "travel", "gas", "online", "drugstore", "transit", "streaming", "general"];
4
+ export const CATEGORY_DISPLAY = {
5
+ dining: "Dining", grocery: "Grocery", travel: "Travel", gas: "Gas", online: "Online",
6
+ drugstore: "Drugstores", transit: "Transit", streaming: "Streaming", general: "General",
7
+ };
8
+ export const CURRENCY_DISPLAY = {
9
+ points: "points", miles: "miles", cashback: "cash back",
10
+ };
11
+ /** Supabase `cards` row → CreditCard (SupabaseCard.toCreditCard). */
12
+ export function cardFromRow(row) {
13
+ const multipliers = {};
14
+ const sm = row.standard_multipliers ?? {};
15
+ for (const [k, v] of Object.entries(sm)) {
16
+ const key = k === "other" ? "general" : k;
17
+ multipliers[key] = fieldFromJSON(v);
18
+ }
19
+ if (multipliers["general"] === undefined)
20
+ multipliers["general"] = Number(row.base_rate ?? 1);
21
+ const cur = row.reward_currency;
22
+ return {
23
+ id: row.id,
24
+ displayName: row.name,
25
+ issuer: row.issuer ?? null,
26
+ rewardCurrency: cur === "points" || cur === "miles" || cur === "cashback" ? cur : null,
27
+ multipliers,
28
+ annualFee: row.annual_fee ?? null,
29
+ verifyStatus: row.verify_status ?? null,
30
+ };
31
+ }
32
+ function fieldFromJSON(v) {
33
+ if (typeof v === "number")
34
+ return v;
35
+ if (v && typeof v === "object") {
36
+ const values = {};
37
+ let def = 1;
38
+ for (const [k, x] of Object.entries(v)) {
39
+ if (k === "default")
40
+ def = Number(x);
41
+ else
42
+ values[k] = Number(x);
43
+ }
44
+ return { default: def, values };
45
+ }
46
+ return 1;
47
+ }
48
+ function normalizeSubKey(subKey) {
49
+ if (!subKey || subKey.trim() === "")
50
+ return null;
51
+ let s = subKey.trim().toLowerCase().replace(/-/g, "_").replace(/ /g, "_");
52
+ if (s === "instore" || s === "in_store" || s === "in_store_")
53
+ return "in_store";
54
+ return s;
55
+ }
56
+ function fieldValue(f, subKey) {
57
+ if (typeof f === "number")
58
+ return f;
59
+ if (subKey == null)
60
+ return f.default;
61
+ const k = subKey.trim().toLowerCase().replace(/-/g, "_").replace(/ /g, "_");
62
+ return f.values[k] ?? f.default;
63
+ }
64
+ /** CreditCard.multiplier(for:subKey:) — direct key → "other" for general → "general" → 1.0 */
65
+ export function multiplier(card, category, subKey) {
66
+ const key = category; // multiplierKey == rawValue for every case
67
+ const sub = normalizeSubKey(subKey);
68
+ const direct = card.multipliers[key];
69
+ if (direct !== undefined)
70
+ return fieldValue(direct, sub);
71
+ if (key === "general" && card.multipliers["other"] !== undefined)
72
+ return fieldValue(card.multipliers["other"], null);
73
+ if (card.multipliers["general"] !== undefined)
74
+ return fieldValue(card.multipliers["general"], null);
75
+ return 1.0;
76
+ }
77
+ /** CreditCard.hasAnyReward */
78
+ export function hasAnyReward(card) {
79
+ return Object.values(card.multipliers).some((f) => typeof f === "number" ? f > 1.0 : f.default > 1.0 || Object.values(f.values).some((v) => v > 1.0));
80
+ }
81
+ export function overrideFromRow(r) {
82
+ return {
83
+ id: String(r.id), cardId: r.card_id, category: r.category, bonusRate: Number(r.bonus_rate),
84
+ startDate: r.start_date, endDate: r.end_date, requiresActivation: !!r.requires_activation,
85
+ label: r.label ?? null, merchant: r.merchant ?? null,
86
+ };
87
+ }
88
+ /** UTC-day canon (SupabaseDateFormat.utcCalendar): compare yyyy-MM-dd strings as UTC days. */
89
+ export function utcDay(now) {
90
+ return now.toISOString().slice(0, 10);
91
+ }
92
+ export function overrideIsActive(o, now) {
93
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(o.startDate) || !/^\d{4}-\d{2}-\d{2}$/.test(o.endDate))
94
+ return false;
95
+ const today = utcDay(now);
96
+ return today >= o.startDate && today <= o.endDate;
97
+ }
98
+ /** SupabaseOverride.isPermanentMerchantBenefit: merchant-scoped, no activation, ends > 2 years out. */
99
+ export function isPermanentMerchantBenefit(o, now) {
100
+ if (!o.merchant || o.merchant === "" || o.requiresActivation)
101
+ return false;
102
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(o.endDate))
103
+ return false;
104
+ const twoYears = new Date(Date.UTC(now.getUTCFullYear() + 2, now.getUTCMonth(), now.getUTCDate()));
105
+ const end = new Date(o.endDate + "T00:00:00Z");
106
+ return end.getTime() > twoYears.getTime();
107
+ }
108
+ export function merchantFromRow(r) {
109
+ const cat = ALL_CATEGORIES.includes(r.category) ? r.category : "general";
110
+ return {
111
+ id: r.id, displayName: r.display_name, category: cat, subKey: r.sub_key ?? null,
112
+ isOnline: !!r.is_online, aliases: Array.isArray(r.aliases) ? r.aliases : [], merchantType: r.merchant_type ?? null,
113
+ };
114
+ }
115
+ /** Merchant.exclusionCaveat */
116
+ export function exclusionCaveat(m) {
117
+ switch (m.merchantType) {
118
+ case "superstore":
119
+ return `${m.displayName} usually codes as a superstore (not a supermarket), so grocery bonuses often don't post — this is your best guaranteed card.`;
120
+ case "warehouse_club":
121
+ return `${m.displayName} codes as a warehouse club, so category bonuses usually don't post — this is your best guaranteed card.`;
122
+ default:
123
+ return null;
124
+ }
125
+ }
126
+ /** Merchant.isCuratedBrand */
127
+ export function isCuratedBrand(m) {
128
+ if (m.aliases.length > 0)
129
+ return true;
130
+ if (m.merchantType && m.merchantType !== "standard")
131
+ return true;
132
+ return false;
133
+ }
@@ -0,0 +1,27 @@
1
+ import { type Merchant } from "./model.js";
2
+ /** Merchant.searchNormalized */
3
+ export declare function searchNormalized(s: string): string;
4
+ export declare function compactForm(normalized: string): string;
5
+ export declare function entityKey(s: string): string;
6
+ /** Merchant.withinOneEdit — Damerau-Levenshtein ≤ 1 */
7
+ export declare function withinOneEdit(aStr: string, bStr: string): boolean;
8
+ export interface SearchForms {
9
+ raw: string;
10
+ norm: string;
11
+ compact: string;
12
+ }
13
+ export declare const searchForms: (raw: string) => SearchForms;
14
+ /** Merchant.matchScore(queryNorm:queryCompact:rawQuery:candidates:) */
15
+ export declare function matchScore(q: string, qc: string, rawQuery: string, candidates: SearchForms[]): number | null;
16
+ export interface IndexEntry {
17
+ merchant: Merchant;
18
+ candidates: SearchForms[];
19
+ entityKey: string;
20
+ displayNorm: string;
21
+ }
22
+ export declare function buildIndex(directory: Merchant[]): IndexEntry[];
23
+ /** MerchantSearchViewModel.rankedBrandMatches(query:index:offerKeys:limit:) */
24
+ export declare function rankedBrandMatches(query: string, index: IndexEntry[], offerKeys?: Set<string>, limit?: number): {
25
+ merchant: Merchant;
26
+ hasOffer: boolean;
27
+ }[];
package/dist/search.js ADDED
@@ -0,0 +1,209 @@
1
+ // Mirrors Merchant.searchNormalized / compactForm / matchScore / entityKey / withinOneEdit
2
+ // and MerchantSearchViewModel.rankedBrandMatches (ranking + family collapse).
3
+ import { merchantMatches } from "./matching.js";
4
+ import { isCuratedBrand } from "./model.js";
5
+ const normCache = new Map();
6
+ /** Merchant.searchNormalized */
7
+ export function searchNormalized(s) {
8
+ const hit = normCache.get(s);
9
+ if (hit !== undefined)
10
+ return hit;
11
+ // diacritic-insensitive + case-insensitive fold
12
+ let t = s.normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase();
13
+ for (const junk of ["’", "'", "®", "™", "."])
14
+ t = t.split(junk).join("");
15
+ t = t.replace(/&/g, " and ");
16
+ t = t.replace(/(?<=[\p{L}\p{N}])\+(?=[\p{L}\p{N}])/gu, " and ");
17
+ t = t.replace(/(?<=\s)\+(?=\s)/g, " and ");
18
+ t = t.replace(/\+/g, " plus ");
19
+ for (const dash of ["-", "–", "—", "/"])
20
+ t = t.split(dash).join(" ");
21
+ let words = t.split(/\s+/).filter((w) => w.length > 0);
22
+ if (words.length > 1 && words[0] === "the")
23
+ words = words.slice(1);
24
+ const result = words.join(" ");
25
+ if (normCache.size > 8192)
26
+ normCache.clear();
27
+ normCache.set(s, result);
28
+ return result;
29
+ }
30
+ export function compactForm(normalized) {
31
+ return normalized.split(" ").filter((w) => w !== "and" && w.length > 0).join("");
32
+ }
33
+ export function entityKey(s) {
34
+ return searchNormalized(s).replace(/ /g, "");
35
+ }
36
+ /** Merchant.withinOneEdit — Damerau-Levenshtein ≤ 1 */
37
+ export function withinOneEdit(aStr, bStr) {
38
+ if (aStr === bStr)
39
+ return true;
40
+ const a = [...aStr], b = [...bStr];
41
+ if (Math.abs(a.length - b.length) > 1)
42
+ return false;
43
+ if (a.length === b.length) {
44
+ const diffs = [];
45
+ for (let i = 0; i < a.length; i++)
46
+ if (a[i] !== b[i]) {
47
+ diffs.push(i);
48
+ if (diffs.length > 2)
49
+ return false;
50
+ }
51
+ if (diffs.length === 1)
52
+ return true;
53
+ return diffs.length === 2 && diffs[1] === diffs[0] + 1 && a[diffs[0]] === b[diffs[1]] && a[diffs[1]] === b[diffs[0]];
54
+ }
55
+ const [long, short] = a.length > b.length ? [a, b] : [b, a];
56
+ let i = 0, j = 0, skipped = false;
57
+ while (i < long.length && j < short.length) {
58
+ if (long[i] === short[j]) {
59
+ i++;
60
+ j++;
61
+ }
62
+ else if (!skipped) {
63
+ skipped = true;
64
+ i++;
65
+ }
66
+ else
67
+ return false;
68
+ }
69
+ return true;
70
+ }
71
+ export const searchForms = (raw) => {
72
+ const norm = searchNormalized(raw);
73
+ return { raw, norm, compact: compactForm(norm) };
74
+ };
75
+ /** Merchant.matchScore(queryNorm:queryCompact:rawQuery:candidates:) */
76
+ export function matchScore(q, qc, rawQuery, candidates) {
77
+ if ([...q].length < 2)
78
+ return null;
79
+ let best = -1;
80
+ for (const c of candidates) {
81
+ const n = c.norm;
82
+ if (n.length === 0)
83
+ continue;
84
+ if (n === q) {
85
+ best = Math.max(best, 100);
86
+ continue;
87
+ }
88
+ if (qc.length > 0 && c.compact === qc)
89
+ best = Math.max(best, 95);
90
+ if (n.startsWith(q))
91
+ best = Math.max(best, 85);
92
+ if ([...qc].length >= 3 && c.compact.startsWith(qc))
93
+ best = Math.max(best, 80);
94
+ if (q.startsWith(n + " "))
95
+ best = Math.max(best, 75);
96
+ const words = n.split(" ");
97
+ if (words.slice(1).some((w) => w.startsWith(q)))
98
+ best = Math.max(best, 70);
99
+ if (n.includes(q))
100
+ best = Math.max(best, 55);
101
+ if (best < 45 && merchantMatches(c.raw, rawQuery))
102
+ best = Math.max(best, 45);
103
+ if (best < 30 && [...q].length >= 5) {
104
+ if (withinOneEdit(n, q) || words.some((w) => [...w].length >= 5 && withinOneEdit(w, q)))
105
+ best = Math.max(best, 30);
106
+ }
107
+ }
108
+ return best >= 0 ? best : null;
109
+ }
110
+ export function buildIndex(directory) {
111
+ return directory.map((m) => ({
112
+ merchant: m,
113
+ candidates: [m.displayName, ...m.aliases].map(searchForms),
114
+ entityKey: entityKey(m.displayName),
115
+ displayNorm: searchNormalized(m.displayName),
116
+ }));
117
+ }
118
+ /** Swift localizedCaseInsensitiveCompare ascending (approximation: locale compare, case-insensitive). */
119
+ function nameCompare(a, b) {
120
+ return a.localeCompare(b, "en", { sensitivity: "accent" });
121
+ }
122
+ /** MerchantSearchViewModel.rankedBrandMatches(query:index:offerKeys:limit:) */
123
+ export function rankedBrandMatches(query, index, offerKeys = new Set(), limit = 6) {
124
+ const q = searchNormalized(query);
125
+ if ([...q].length < 2)
126
+ return [];
127
+ const qc = compactForm(q);
128
+ const ranked = [];
129
+ for (const e of index) {
130
+ const score = matchScore(q, qc, query, e.candidates);
131
+ if (score === null)
132
+ continue;
133
+ const hasOffer = offerKeys.has(e.entityKey);
134
+ const specific = (e.merchant.category !== "general" || isCuratedBrand(e.merchant)) ? 1 : 0;
135
+ const boost = hasOffer && score >= 70 ? 400 : 0;
136
+ ranked.push([e, score * 2 + specific + boost, hasOffer]);
137
+ }
138
+ ranked.sort((a, b) => a[1] !== b[1] ? b[1] - a[1] : nameCompare(a[0].merchant.displayName, b[0].merchant.displayName));
139
+ const qKey = q.replace(/ /g, "");
140
+ const glueSuffixes = new Set(["com", "net", "online", "us", "usa", "store", "shop"]);
141
+ const extendsName = (key, norm, base) => {
142
+ if (norm.startsWith(base.norm + " "))
143
+ return true;
144
+ if (key.startsWith(base.key) && glueSuffixes.has(key.slice(base.key.length)))
145
+ return true;
146
+ let glued = "";
147
+ const words = norm.split(" ");
148
+ for (let i = 0; i < words.length; i++) {
149
+ glued += words[i].replace(/[^\p{L}\p{N}]/gu, "");
150
+ if (glued === base.key)
151
+ return i < words.length - 1;
152
+ if (!base.key.startsWith(glued))
153
+ return false;
154
+ }
155
+ return false;
156
+ };
157
+ const kept = [];
158
+ let repCategory = null;
159
+ outer: for (const row of ranked) {
160
+ if (kept.length >= limit)
161
+ break;
162
+ const e = row[0];
163
+ for (let i = 0; i < kept.length; i++) {
164
+ const k = kept[i];
165
+ if (k[0].entityKey === e.entityKey)
166
+ continue outer;
167
+ if (e.merchant.category === "general" && e.entityKey === "new" + k[0].entityKey)
168
+ continue outer;
169
+ if (k[0].merchant.category === "general" && k[0].entityKey === "new" + e.entityKey) {
170
+ kept[i] = row;
171
+ continue outer;
172
+ }
173
+ }
174
+ const inQueryFamily = e.displayNorm === q || extendsName(e.entityKey, e.displayNorm, { key: qKey, norm: q });
175
+ if (inQueryFamily) {
176
+ if (row[2]) {
177
+ kept.push(row);
178
+ if (repCategory === null)
179
+ repCategory = e.merchant.category;
180
+ }
181
+ else if (e.displayNorm === q && isCuratedBrand(e.merchant)) {
182
+ kept.push(row);
183
+ if (repCategory === null)
184
+ repCategory = e.merchant.category;
185
+ }
186
+ else if (repCategory !== null) {
187
+ if (e.merchant.category === repCategory || e.merchant.category === "general")
188
+ continue;
189
+ kept.push(row);
190
+ }
191
+ else {
192
+ repCategory = e.merchant.category;
193
+ kept.push(row);
194
+ }
195
+ continue;
196
+ }
197
+ if (!row[2]) {
198
+ for (const k of kept) {
199
+ if (extendsName(e.entityKey, e.displayNorm, { key: k[0].entityKey, norm: k[0].displayNorm })) {
200
+ const sameAnswer = e.merchant.category === k[0].merchant.category || e.merchant.category === "general";
201
+ if (sameAnswer)
202
+ continue outer;
203
+ }
204
+ }
205
+ }
206
+ kept.push(row);
207
+ }
208
+ return kept.map((r) => ({ merchant: r[0].merchant, hasOffer: r[2] }));
209
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/stdio.js ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ // Local stdio server: `npx rightcard-mcp`. Reads the public catalog once an hour,
3
+ // answers over stdin/stdout, sends nothing anywhere else.
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { buildServer } from "./tools.js";
6
+ import { catalogCache } from "./data.js";
7
+ const server = buildServer(catalogCache());
8
+ await server.connect(new StdioServerTransport());
@@ -0,0 +1,3 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import type { Catalog } from "./data.js";
3
+ export declare function buildServer(getCatalog: () => Promise<Catalog>): McpServer;
package/dist/tools.js ADDED
@@ -0,0 +1,205 @@
1
+ // The MCP surface (RIGHTCARD_MCP_SPEC.md §5). Read-only. Every answer carries `basis`
2
+ // and `caveats`; verified rows only in rankings; quarantined cards are NAMED, never ranked.
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+ import { ALL_CATEGORIES, exclusionCaveat, hasAnyReward, multiplier, overrideIsActive, isPermanentMerchantBenefit } from "./model.js";
6
+ import { recommend, formatPercent, formatMultiplier } from "./engine.js";
7
+ import { rankedBrandMatches, searchNormalized } from "./search.js";
8
+ import { makeValuation, centsPerPoint, withPooling, effectiveProgram, PROGRAM_DISPLAY, TRAVEL_CPP } from "./valuation.js";
9
+ import { specFor, isConfigurable } from "./configurable.js";
10
+ const CategorySchema = z.enum(ALL_CATEGORIES);
11
+ const PRIVACY = "RightCard never receives a bank login, balances, or transactions. This tool takes a list of card ids and a store name, returns an answer, and stores nothing.";
12
+ function cardSummary(c) {
13
+ return {
14
+ id: c.id, name: c.displayName, issuer: c.issuer, reward_currency: c.rewardCurrency,
15
+ verify_status: c.verifyStatus ?? "unknown", annual_fee: c.annualFee ?? null,
16
+ };
17
+ }
18
+ function resolveWallet(catalog, ids) {
19
+ const cards = [];
20
+ const unknown = [];
21
+ const unverified = [];
22
+ for (const id of ids) {
23
+ const c = catalog.cardsById.get(id);
24
+ if (!c) {
25
+ unknown.push(id);
26
+ continue;
27
+ }
28
+ if (c.verifyStatus !== "verified" && !isConfigurable(c))
29
+ unverified.push(id);
30
+ cards.push(c);
31
+ }
32
+ return { cards, unknown, unverified };
33
+ }
34
+ function resolveMerchant(catalog, name) {
35
+ return rankedBrandMatches(name, catalog.index)[0]?.merchant ?? null;
36
+ }
37
+ function shapeAnswer(r, merchant, mode, unverified) {
38
+ const caveats = [];
39
+ if (merchant) {
40
+ const cv = exclusionCaveat(merchant);
41
+ if (cv)
42
+ caveats.push(cv);
43
+ }
44
+ if (r.tieNote)
45
+ caveats.push(r.tieNote);
46
+ if (r.portalNote)
47
+ caveats.push(r.portalNote);
48
+ if (unverified.length)
49
+ caveats.push(`Not yet verified by two independent sources (ranked on published data, treat with care): ${unverified.join(", ")}.`);
50
+ return {
51
+ card_id: r.cardId, card_name: r.cardName, rate_display: r.rateDisplay,
52
+ value_per_dollar_cents: r.loggableRate, basis: r.valuationBasis, valuation: mode,
53
+ reason: r.reason, details: r.breakdownLines, value_math: r.valueMath,
54
+ subcategory_split: r.subcategoryBreakdown, caveats, privacy: PRIVACY,
55
+ };
56
+ }
57
+ export function buildServer(getCatalog) {
58
+ const server = new McpServer({ name: "rightcard", version: "0.1.0" }, {
59
+ instructions: "RightCard answers 'which of these credit cards should pay here?' from bank-published, two-source-verified reward data. " +
60
+ "Pass the user's card ids (from search_cards) plus a store name or a category. Cash value is the default; ask for valuation='points' to value points at conservative per-program travel values. " +
61
+ "Every answer includes honest caveats: superstores/warehouse clubs that do not post grocery bonuses, rotating quarters that need activation, ties. " + PRIVACY,
62
+ });
63
+ server.registerTool("best_card", {
64
+ title: "Best card for a store or category",
65
+ description: "Which of the given cards earns the most at a store (merchant) or for a spend category, today. Returns the card, the honest rate, why, and caveats (merchant-code traps, activation, ties). Base rates + rotating bonuses + built-in card benefits only; a user's personal bank offers live on their phone and are not consulted here.",
66
+ inputSchema: {
67
+ wallet: z.array(z.string()).min(1).describe("Card ids the person holds (from search_cards)."),
68
+ merchant: z.string().optional().describe("Store name, e.g. 'Costco', 'Whole Foods', 'Uber'. Use this OR category."),
69
+ category: CategorySchema.optional().describe("Spend category when there is no specific store."),
70
+ valuation: z.enum(["cash", "points"]).default("cash").describe("'cash' counts every point at 1¢ (default). 'points' values points per program at conservative travel values."),
71
+ on: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Date (YYYY-MM-DD, UTC) for rotating windows; defaults to today."),
72
+ chosen_categories: z.record(z.string(), z.array(CategorySchema)).optional().describe("For choose-your-category cards (Citi Custom Cash, BofA Customized, Venmo): card id → the categories the person set."),
73
+ },
74
+ }, async ({ wallet, merchant, category, valuation, on, chosen_categories }) => {
75
+ const catalog = await getCatalog();
76
+ const { cards, unknown, unverified } = resolveWallet(catalog, wallet);
77
+ if (cards.length === 0)
78
+ return err(`No known cards in wallet. Unknown ids: ${unknown.join(", ")}. Use search_cards to find ids.`);
79
+ let cat = category;
80
+ let subKey = null;
81
+ let dir = null;
82
+ if (merchant) {
83
+ dir = resolveMerchant(catalog, merchant);
84
+ cat = dir?.category ?? cat ?? "general";
85
+ subKey = dir?.subKey ?? null;
86
+ }
87
+ if (!cat)
88
+ return err("Pass a merchant or a category.");
89
+ const mode = valuation === "points" ? "smart" : "cash";
90
+ const configs = {};
91
+ for (const [k, v] of Object.entries(chosen_categories ?? {}))
92
+ configs[k] = new Set(v);
93
+ const now = on ? new Date(on + "T00:00:00Z") : new Date();
94
+ const r = recommend({ cards, category: cat, subKey, merchantName: merchant ?? null, overrides: catalog.overrides,
95
+ valuation: makeValuation(mode), cardConfigs: configs, now });
96
+ if (!r)
97
+ return err("No recommendation could be computed.");
98
+ const answer = {
99
+ ...shapeAnswer(r, dir, valuation, unverified),
100
+ merchant: dir ? { id: dir.id, name: dir.displayName, category: dir.category, merchant_type: dir.merchantType } : null,
101
+ category: cat, unknown_card_ids: unknown,
102
+ };
103
+ return ok(answer);
104
+ });
105
+ server.registerTool("lookup_merchant", {
106
+ title: "How a store is coded",
107
+ description: "The store's reward category as RightCard's directory files it, its merchant type, and the merchant-code caveat when a category bonus usually will not post there (superstores like Walmart/Target, warehouse clubs like Costco/Sam's). Same ranking the app uses.",
108
+ inputSchema: { name: z.string().min(2) },
109
+ }, async ({ name }) => {
110
+ const catalog = await getCatalog();
111
+ const rows = rankedBrandMatches(name, catalog.index);
112
+ if (rows.length === 0)
113
+ return ok({ query: name, found: false, note: "Not in the directory. The app would classify it by name and answer with the base rate." });
114
+ const top = rows[0].merchant;
115
+ return ok({
116
+ query: name, found: true,
117
+ match: { id: top.id, name: top.displayName, category: top.category, sub_key: top.subKey, merchant_type: top.merchantType, caveat: exclusionCaveat(top) },
118
+ family: rows.slice(1).map((r) => ({ id: r.merchant.id, name: r.merchant.displayName, category: r.merchant.category, merchant_type: r.merchant.merchantType })),
119
+ });
120
+ });
121
+ server.registerTool("search_cards", {
122
+ title: "Find card ids",
123
+ description: "Search RightCard's catalog by card or issuer name to get the ids to pass into best_card. Verified cards first; unverified cards are flagged, never hidden.",
124
+ inputSchema: { query: z.string().min(2), issuer: z.string().optional(), limit: z.number().int().min(1).max(50).default(12) },
125
+ }, async ({ query, issuer, limit }) => {
126
+ const catalog = await getCatalog();
127
+ const q = searchNormalized(query);
128
+ const toks = q.split(" ").filter((t) => t.length > 0);
129
+ let hits = catalog.cards.filter((c) => {
130
+ if (issuer && !(c.issuer ?? "").toLowerCase().includes(issuer.toLowerCase()))
131
+ return false;
132
+ const hay = searchNormalized(`${c.displayName} ${c.issuer ?? ""} ${c.id.replace(/_/g, " ")}`);
133
+ return toks.every((t) => hay.includes(t));
134
+ });
135
+ hits.sort((a, b) => (rank(a) - rank(b)) || a.displayName.localeCompare(b.displayName));
136
+ return ok({ query, results: hits.slice(0, limit).map(cardSummary), total: hits.length });
137
+ function rank(c) { return c.verifyStatus === "verified" ? 0 : (hasAnyReward(c) ? 1 : 2); }
138
+ });
139
+ server.registerTool("card", {
140
+ title: "A card's verified rates",
141
+ description: "Everything RightCard knows about one card: rates by category, base rate, currency, annual fee, verification status, live rotating windows, and the choose-your-category spec if it has one.",
142
+ inputSchema: { id: z.string() },
143
+ }, async ({ id }) => {
144
+ const catalog = await getCatalog();
145
+ const c = catalog.cardsById.get(id);
146
+ if (!c)
147
+ return err(`Unknown card id '${id}'. Use search_cards.`);
148
+ const now = new Date();
149
+ const rates = {};
150
+ for (const cat of ALL_CATEGORIES) {
151
+ const m = multiplier(c, cat, null);
152
+ rates[cat] = c.rewardCurrency === "cashback" || !c.rewardCurrency ? formatPercent(m) : formatMultiplier(m);
153
+ }
154
+ const rot = catalog.overrides.filter((o) => o.cardId === id).map((o) => ({
155
+ category: o.category, merchant: o.merchant, bonus_rate: o.bonusRate, start: o.startDate, end: o.endDate,
156
+ activation_required: o.requiresActivation, label: o.label,
157
+ status: isPermanentMerchantBenefit(o, now) ? "card benefit" : overrideIsActive(o, now) ? "live now" : (o.startDate > now.toISOString().slice(0, 10) ? "upcoming" : "past"),
158
+ }));
159
+ const spec = specFor(c);
160
+ const v = withPooling(makeValuation("smart"), [c]);
161
+ return ok({
162
+ ...cardSummary(c), rates_by_category: rates,
163
+ points_program: c.rewardCurrency && c.rewardCurrency !== "cashback" ? { name: PROGRAM_DISPLAY[effectiveProgram(v, c)], travel_cents_per_point: centsPerPoint(v, c) } : null,
164
+ rotating_and_benefits: rot,
165
+ choose_your_category: spec ? { bonus_rate: spec.bonusRate, slots: spec.slots, eligible: spec.eligible, note: spec.note } : null,
166
+ verification_note: c.verifyStatus === "verified" ? "Two independent sources agreed on these rates." : `verify_status=${c.verifyStatus ?? "unknown"}: not yet confirmed by two sources; shown, not vouched for.`,
167
+ });
168
+ });
169
+ server.registerTool("rotating_calendar", {
170
+ title: "Rotating 5% windows",
171
+ description: "Live and upcoming rotating-category windows (Freedom Flex, Discover it, Citi Dividend…) and permanent merchant benefits, from the same verified data as the app. Optionally filter by card id.",
172
+ inputSchema: { card_id: z.string().optional() },
173
+ }, async ({ card_id }) => {
174
+ const catalog = await getCatalog();
175
+ const today = new Date().toISOString().slice(0, 10);
176
+ const rows = catalog.overrides.filter((o) => !card_id || o.cardId === card_id).filter((o) => o.endDate >= today)
177
+ .map((o) => ({ card_id: o.cardId, card_name: catalog.cardsById.get(o.cardId)?.displayName ?? o.cardId,
178
+ category: o.category, merchant: o.merchant, bonus_rate: o.bonusRate, start: o.startDate, end: o.endDate,
179
+ activation_required: o.requiresActivation, label: o.label,
180
+ status: isPermanentMerchantBenefit(o, new Date()) ? "card benefit" : o.startDate <= today ? "live now" : "upcoming" }))
181
+ .sort((a, b) => a.start.localeCompare(b.start) || a.card_id.localeCompare(b.card_id));
182
+ return ok({ as_of: today, windows: rows, note: "A window appears only after it passed two-source verification. Nothing here is predicted." });
183
+ });
184
+ server.registerResource("catalog", "rightcard://catalog", { title: "Verified card catalog", mimeType: "application/json" }, async (uri) => {
185
+ const catalog = await getCatalog();
186
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(catalog.cards.map(cardSummary)) }] };
187
+ });
188
+ server.registerResource("valuation", "rightcard://valuation", { title: "How points are valued", mimeType: "application/json" }, async (uri) => ({
189
+ contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ mode_cash: "every point = 1¢", mode_points_travel_cents_per_point: TRAVEL_CPP, source: "Conservative blended baselines (The Points Guy, Bankrate, Frequent Miler), Q2 2026." }) }],
190
+ }));
191
+ server.registerPrompt("which-card", {
192
+ title: "Which card should I use here?",
193
+ description: "Ask RightCard for the best card at a store, given the cards you hold.",
194
+ argsSchema: { cards: z.string().describe("Your cards, in your own words"), store: z.string().describe("Where you're paying") },
195
+ }, ({ cards, store }) => ({
196
+ messages: [{ role: "user", content: { type: "text", text: `I hold these cards: ${cards}. First call search_cards to resolve each to an id, then call best_card with the wallet and merchant "${store}". Tell me the card, the rate, and repeat any caveat about the store's merchant code or activation exactly as returned.` } }],
197
+ }));
198
+ return server;
199
+ }
200
+ function ok(data) {
201
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }], structuredContent: data };
202
+ }
203
+ function err(message) {
204
+ return { isError: true, content: [{ type: "text", text: message }] };
205
+ }
@@ -0,0 +1,24 @@
1
+ import type { CreditCard } from "./model.js";
2
+ export type ValuationMode = "cash" | "smart";
3
+ export type RewardProgram = "cash" | "amexMR" | "chaseUR" | "capOneMiles" | "citiTYP" | "wellsFargo" | "bilt" | "tdRewards" | "scenePlus" | "rbcAvion" | "bmoRewards" | "hyatt" | "marriott" | "ihg" | "hilton" | "delta" | "united" | "american" | "southwest" | "jetBlue" | "alaska" | "avios" | "aeroplan" | "fixedValue" | "genericPoints" | "genericMiles";
4
+ export declare const PROGRAM_DISPLAY: Record<RewardProgram, string>;
5
+ export declare const TRAVEL_CPP: Record<RewardProgram, number>;
6
+ /** RewardProgram.cashOutCPP */
7
+ export declare function cashOutCPP(p: RewardProgram): number;
8
+ /** RewardProgram.infer(issuer:id:currency:) — order matters, mirrors the Swift needle list exactly. */
9
+ export declare function inferProgram(issuer: string | null, id: string, currency: CreditCard["rewardCurrency"]): RewardProgram;
10
+ export interface Valuation {
11
+ mode: ValuationMode;
12
+ pooledPrograms: Record<string, RewardProgram>;
13
+ }
14
+ export declare function makeValuation(mode?: ValuationMode): Valuation;
15
+ /** RewardValuation.pooledPrograms(wallet:) */
16
+ export declare function pooledPrograms(wallet: CreditCard[]): Record<string, RewardProgram>;
17
+ export declare function withPooling(v: Valuation, wallet: CreditCard[]): Valuation;
18
+ export declare function effectiveProgram(v: Valuation, card: CreditCard): RewardProgram;
19
+ export declare function centsPerPoint(v: Valuation, card: CreditCard): number;
20
+ export declare function valuesPointsAboveCash(v: Valuation, card: CreditCard): boolean;
21
+ export declare function earnUnitLabel(v: Valuation, card: CreditCard): string;
22
+ export declare function basisLabel(v: Valuation, card: CreditCard): string;
23
+ /** Swift String(format: "%.1f") */
24
+ export declare function fmt1(x: number): string;