untappd-mcp 2.3.2 → 2.5.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +185 -5
- package/dist/bundle.js +6579 -1752
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/db.js +100 -10
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1424 -24
- package/dist/cache/sync-beers.js +51 -7
- package/dist/cache/sync-brewery-likes.js +124 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +38 -0
- package/dist/compact.js +47 -42
- package/dist/index.js +27 -4
- package/dist/menu/match.js +178 -0
- package/dist/menu/pick.js +520 -0
- package/dist/menu/venue-menu.js +108 -0
- package/dist/quota.js +14 -0
- package/dist/ratelimit.js +236 -0
- package/dist/tools/beer.js +4 -1
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +506 -62
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/menu.js +104 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +91 -115
- package/dist/version.js +1 -1
- package/mint.yaml +17 -0
- package/package.json +5 -1
- package/server.json +16 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
package/dist/cache/sync.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
2
|
import { mapCheckinRow } from './store.js';
|
|
3
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
3
4
|
const PAGE_LIMIT = 50; // Untappd's max page size for /user/checkins.
|
|
4
5
|
// Backfill counts as complete only when the cache holds ~this fraction of the
|
|
5
6
|
// API's reported total_checkins — allowing drift for deleted check-ins. Below
|
|
@@ -41,7 +42,10 @@ async function fetchTotalCheckins(client, encodedUser) {
|
|
|
41
42
|
const t = info?.user?.stats?.total_checkins;
|
|
42
43
|
return typeof t === 'number' ? t : null;
|
|
43
44
|
}
|
|
44
|
-
catch {
|
|
45
|
+
catch (e) {
|
|
46
|
+
// A 429 here means every later call would 429 too — let the caller stop.
|
|
47
|
+
if (e instanceof RateLimitError)
|
|
48
|
+
throw e;
|
|
45
49
|
return null;
|
|
46
50
|
}
|
|
47
51
|
}
|
|
@@ -74,7 +78,42 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
|
|
|
74
78
|
const encodedUser = encodeURIComponent(rawUsername);
|
|
75
79
|
const now = () => new Date().toISOString();
|
|
76
80
|
const priorState = await cache.getState(rawUsername);
|
|
77
|
-
|
|
81
|
+
// The stop-before-429 guard, consulted before EVERY call. Once it (or a real
|
|
82
|
+
// 429) stops the run, nothing else is fetched; progress is persisted per page.
|
|
83
|
+
let rateStop;
|
|
84
|
+
const gate = async () => {
|
|
85
|
+
if (rateStop)
|
|
86
|
+
return false;
|
|
87
|
+
const v = await checkRateBudget(client, cache);
|
|
88
|
+
if (!v.ok)
|
|
89
|
+
rateStop = stopFromVerdict(v);
|
|
90
|
+
return v.ok;
|
|
91
|
+
};
|
|
92
|
+
/** Fetch a page, or return null (and record why) when rate limited. */
|
|
93
|
+
const guardedFetch = async (maxId) => {
|
|
94
|
+
if (!(await gate()))
|
|
95
|
+
return null;
|
|
96
|
+
try {
|
|
97
|
+
return await nextFetch(maxId);
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
if (!(e instanceof RateLimitError))
|
|
101
|
+
throw e;
|
|
102
|
+
rateStop = stopFrom429(e);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
let total = priorState?.total_checkins ?? null;
|
|
107
|
+
if (await gate()) {
|
|
108
|
+
try {
|
|
109
|
+
total = await fetchTotalCheckins(client, encodedUser);
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
if (!(e instanceof RateLimitError))
|
|
113
|
+
throw e;
|
|
114
|
+
rateStop = stopFrom429(e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
78
117
|
const cachedAtStart = await cache.cachedCount(rawUsername);
|
|
79
118
|
// Self-heal a cache wrongly flagged complete (the historical bug): if state
|
|
80
119
|
// claims done but coverage is well short of total, treat it like a forced reset.
|
|
@@ -176,7 +215,10 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
|
|
|
176
215
|
let caughtUp = false;
|
|
177
216
|
let phase1Pages = 0;
|
|
178
217
|
for (; phase1Pages < phase1Budget;) {
|
|
179
|
-
const
|
|
218
|
+
const fetched = await guardedFetch(maxId);
|
|
219
|
+
if (fetched === null)
|
|
220
|
+
break; // rate limited: catch-up resumes next run
|
|
221
|
+
const page = fetched;
|
|
180
222
|
pages++;
|
|
181
223
|
phase1Pages++;
|
|
182
224
|
if (page.items.length === 0) {
|
|
@@ -231,7 +273,10 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
|
|
|
231
273
|
let maxId = oldestMaxId ?? undefined;
|
|
232
274
|
let phase2Pages = 0;
|
|
233
275
|
for (; phase2Pages < phase2Budget;) {
|
|
234
|
-
const
|
|
276
|
+
const fetched = await guardedFetch(maxId);
|
|
277
|
+
if (fetched === null)
|
|
278
|
+
break; // rate limited: backfill resumes from oldest_max_id next run
|
|
279
|
+
const page = fetched;
|
|
235
280
|
pages++;
|
|
236
281
|
phase2Pages++;
|
|
237
282
|
if (page.items.length === 0) {
|
|
@@ -287,14 +332,16 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
|
|
|
287
332
|
// Re-running user/checkins only helps while there is more of the OWN history to
|
|
288
333
|
// page. A truncated (non-self) history can't advance that way — the caller
|
|
289
334
|
// should switch to untappd_sync_user_beers for has-had coverage.
|
|
290
|
-
const anotherRunNeeded = catchupInProgress || (!backfillComplete && !checkinsTruncated);
|
|
291
|
-
const note =
|
|
292
|
-
?
|
|
293
|
-
:
|
|
294
|
-
? '
|
|
295
|
-
:
|
|
296
|
-
? '
|
|
297
|
-
:
|
|
335
|
+
const anotherRunNeeded = catchupInProgress || (!backfillComplete && !checkinsTruncated) || rateStop !== undefined;
|
|
336
|
+
const note = rateStop
|
|
337
|
+
? `Check-in sync paused (${cached}${total !== null ? `/${total}` : ''} cached). ${resumeAdvice(rateStop, 'untappd_sync_checkins')}`
|
|
338
|
+
: checkinsTruncated
|
|
339
|
+
? 'Untappd only returns the most recent check-ins for this account (history_truncated), so the full check-in history cannot be paged. Use untappd_sync_user_beers to cache the complete distinct-beers list for has-had coverage.'
|
|
340
|
+
: catchupInProgress
|
|
341
|
+
? 'More new check-ins remain than fit in this run — run untappd_sync_checkins again to finish catching up.'
|
|
342
|
+
: backfillComplete
|
|
343
|
+
? 'Full check-in history is cached. Re-run occasionally to pick up new check-ins.'
|
|
344
|
+
: `Backfill incomplete — run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
|
|
298
345
|
return {
|
|
299
346
|
username: rawUsername,
|
|
300
347
|
rows_added: added,
|
|
@@ -308,6 +355,8 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
|
|
|
308
355
|
history_truncated: checkinsTruncated,
|
|
309
356
|
forced: doReset,
|
|
310
357
|
another_run_needed: anotherRunNeeded,
|
|
358
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
359
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
311
360
|
last_synced_at: now(),
|
|
312
361
|
note,
|
|
313
362
|
};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
|
+
import { mapCheckinRow } from './store.js';
|
|
3
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
4
|
+
export const VENUE_DRINKS_DEFAULT_CALLS = 10;
|
|
5
|
+
export const VENUE_DRINKS_MAX_CALLS_CAP = 50;
|
|
6
|
+
const TOOL = 'untappd_sync_venue_drinks';
|
|
7
|
+
/** A check-in that no longer exists or isn't visible answers 404 (not a transient failure). */
|
|
8
|
+
function isGone(e) {
|
|
9
|
+
return /\b404\b|not found|does not exist/i.test(messageOf(e));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Opt-in "what did they drink there": for the user's cached user_venues rows,
|
|
13
|
+
* fetch the first and most-recent visit check-ins via checkin/view (works for
|
|
14
|
+
* any public check-in) and store the beer + date in `venue_drinks`. A PARTIAL
|
|
15
|
+
* picture by design — two check-ins per venue at most. Budgeted by `maxCalls`,
|
|
16
|
+
* with the stop-before-429 guard before every call; progress is stored per
|
|
17
|
+
* call, so a re-run continues with what is still pending.
|
|
18
|
+
*/
|
|
19
|
+
export async function syncVenueDrinks(client, cache, username, opts) {
|
|
20
|
+
const budget = Math.max(1, Math.min(VENUE_DRINKS_MAX_CALLS_CAP, Math.floor(opts.maxCalls)));
|
|
21
|
+
const filter = { venueIds: opts.venueIds, which: opts.which ?? 'both' };
|
|
22
|
+
const candidates = await cache.venueDrinkCandidates(username, filter);
|
|
23
|
+
let calls = 0;
|
|
24
|
+
let stored = 0;
|
|
25
|
+
let unavailable = 0;
|
|
26
|
+
let errors = 0;
|
|
27
|
+
let firstError;
|
|
28
|
+
let rateStop;
|
|
29
|
+
for (const c of candidates) {
|
|
30
|
+
if (calls >= budget)
|
|
31
|
+
break;
|
|
32
|
+
const verdict = await checkRateBudget(client, cache);
|
|
33
|
+
if (!verdict.ok) {
|
|
34
|
+
rateStop = stopFromVerdict(verdict);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
calls++;
|
|
38
|
+
let data;
|
|
39
|
+
try {
|
|
40
|
+
data = await client.get(`/checkin/view/${c.checkin_id}`);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e instanceof RateLimitError) {
|
|
44
|
+
rateStop = stopFrom429(e);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (isGone(e)) {
|
|
48
|
+
unavailable++;
|
|
49
|
+
await cache.upsertVenueDrinks(username, [{ venue_id: c.venue_id, checkin_id: c.checkin_id, bid: null, beer_name: null, brewery_id: null, brewery_name: null, created_at: null }], new Date().toISOString());
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
errors++;
|
|
53
|
+
firstError ??= messageOf(e);
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Only the beer, brewery and date are kept — never the comment (other people's free text).
|
|
58
|
+
const r = mapCheckinRow(username, data?.checkin);
|
|
59
|
+
const row = {
|
|
60
|
+
venue_id: c.venue_id,
|
|
61
|
+
checkin_id: c.checkin_id,
|
|
62
|
+
bid: r?.bid ?? null,
|
|
63
|
+
beer_name: r?.beer_name ?? null,
|
|
64
|
+
brewery_id: r?.brewery_id ?? null,
|
|
65
|
+
brewery_name: r?.brewery_name ?? null,
|
|
66
|
+
created_at: r?.created_at ?? null,
|
|
67
|
+
};
|
|
68
|
+
if (row.bid === null && row.beer_name === null) {
|
|
69
|
+
// An unexpected shape — don't store a tombstone for it; count and retry later.
|
|
70
|
+
errors++;
|
|
71
|
+
firstError ??= `checkin/view ${c.checkin_id} had no beer`;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
await cache.upsertVenueDrinks(username, [row], new Date().toISOString());
|
|
75
|
+
stored++;
|
|
76
|
+
}
|
|
77
|
+
const pending = (await cache.venueDrinkCandidates(username, filter)).length;
|
|
78
|
+
const another = pending > 0;
|
|
79
|
+
let note;
|
|
80
|
+
if (rateStop)
|
|
81
|
+
note = `Paused with ${pending} check-in(s) pending. ${resumeAdvice(rateStop, TOOL)}`;
|
|
82
|
+
else if (another)
|
|
83
|
+
note = `${pending} check-in(s) still pending — run ${TOOL} again to continue (max_calls per run).`;
|
|
84
|
+
else
|
|
85
|
+
note = 'Every selected venue\'s first/recent visit drink is cached; untappd_cache_has_visited lists them under matches[].drinks.';
|
|
86
|
+
if (errors > 0)
|
|
87
|
+
note += ` ${errors} fetch(es) failed and stay pending.`;
|
|
88
|
+
return {
|
|
89
|
+
username,
|
|
90
|
+
api_calls: calls,
|
|
91
|
+
stored,
|
|
92
|
+
unavailable,
|
|
93
|
+
errors,
|
|
94
|
+
pending,
|
|
95
|
+
another_run_needed: another,
|
|
96
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
97
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
98
|
+
...(firstError ? { first_error: firstError } : {}),
|
|
99
|
+
note,
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { loadDotenvSafely, readEnvVar, buildQueryString, formatApiError, createHelpfulError, McpToolError, RateLimitError, UnreachableError, } from '@chrischall/mcp-utils';
|
|
4
|
+
import { RateLimitTracker, computeRateLimitStatus, configuredHourlyLimit, observeResponse, } from './ratelimit.js';
|
|
4
5
|
// Load .env for local (Node) dev; silently skip if dotenv is unavailable (e.g.
|
|
5
6
|
// the mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and
|
|
6
7
|
// never lets .env override a host-provided value. Guard on `import.meta.url`:
|
|
@@ -151,6 +152,9 @@ export class UntappdClient {
|
|
|
151
152
|
userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
|
|
152
153
|
token;
|
|
153
154
|
loginInFlight = null;
|
|
155
|
+
/** Every response this process received within the rolling hour (see src/ratelimit.ts). */
|
|
156
|
+
rateLimit = new RateLimitTracker();
|
|
157
|
+
apiCallListeners = [];
|
|
154
158
|
tokenIsSupplied;
|
|
155
159
|
/**
|
|
156
160
|
* Defer the config error so the server can still start (and respond to the
|
|
@@ -206,6 +210,39 @@ export class UntappdClient {
|
|
|
206
210
|
get loginName() {
|
|
207
211
|
return this._loginName;
|
|
208
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Estimated calls left this hour, or null before this process has made any
|
|
215
|
+
* call in the window. Rests on a LOCAL count of calls (the v4 API sends
|
|
216
|
+
* `X-Ratelimit-Remaining: 0` on every success, so the header is only believed
|
|
217
|
+
* once it has reported a live non-zero value, or on a 429) — see
|
|
218
|
+
* src/ratelimit.ts. Consumers must still treat null as "unknown".
|
|
219
|
+
*/
|
|
220
|
+
get quotaRemaining() {
|
|
221
|
+
const obs = this.rateLimit.observations();
|
|
222
|
+
if (obs.length === 0)
|
|
223
|
+
return null;
|
|
224
|
+
return computeRateLimitStatus(obs, Date.now(), { configuredLimit: configuredHourlyLimit() }).estimated_remaining;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Subscribe to every API response (status + rate-limit headers). The stdio
|
|
228
|
+
* server uses it to persist the call log into the shared cache file, so other
|
|
229
|
+
* processes (and the next one) know what this hour has already spent.
|
|
230
|
+
*/
|
|
231
|
+
onApiCall(listener) {
|
|
232
|
+
this.apiCallListeners.push(listener);
|
|
233
|
+
}
|
|
234
|
+
noteQuota(res) {
|
|
235
|
+
const o = observeResponse(res.status, res.headers);
|
|
236
|
+
this.rateLimit.record(o);
|
|
237
|
+
for (const l of this.apiCallListeners) {
|
|
238
|
+
try {
|
|
239
|
+
l(o);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* a listener failure must never fail the API call */
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
209
246
|
/** App creds for a write; reached only once a token is in hand. */
|
|
210
247
|
requireAppCreds() {
|
|
211
248
|
if (!this.clientId || !this.clientSecret) {
|
|
@@ -315,6 +352,7 @@ export class UntappdClient {
|
|
|
315
352
|
body = f.toString();
|
|
316
353
|
}
|
|
317
354
|
const res = await this.send(method, `${BASE_URL}${path}${qs}`, { headers, body });
|
|
355
|
+
this.noteQuota(res);
|
|
318
356
|
// A 401 means the token went stale. Re-minting one needs a login, and a
|
|
319
357
|
// deployment that SUPPLIED the token may have no username/password to log
|
|
320
358
|
// in with — that fell through to `missingCredsError()` and told the
|
package/dist/compact.js
CHANGED
|
@@ -75,36 +75,6 @@ export function compactUserBeer(item) {
|
|
|
75
75
|
last_had: i.recent_created_at,
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Slim one `/brewery/beer_list/` item.
|
|
80
|
-
*
|
|
81
|
-
* This endpoint needs its OWN projector rather than reusing
|
|
82
|
-
* {@link compactBeerResult}, and the reason is easy to miss: the item shape IS
|
|
83
|
-
* `{beer:{…}, brewery:{…}}`-wrapped, so `beerCore` fits perfectly — but the two
|
|
84
|
-
* fields the search projector adds on top of it are named differently here.
|
|
85
|
-
* `/search/beer` says `checkin_count` / `have_had`; `/brewery/beer_list/` says
|
|
86
|
-
* `total_count` / `has_had`. Reusing the search projector would therefore have
|
|
87
|
-
* emitted a page of records carrying two silent `undefined`s — a beer with no
|
|
88
|
-
* check-ins that you have never had — and `projectItems`' drift guard cannot
|
|
89
|
-
* catch that, because the CONTAINER is exactly where it expects it to be.
|
|
90
|
-
* Established against a live capture of brewery 1142, not by inference.
|
|
91
|
-
*
|
|
92
|
-
* Rating rides along because the tool's own description promises "per-beer
|
|
93
|
-
* rating and check-in counts"; a compact rung that dropped them would
|
|
94
|
-
* contradict the thing the caller was told they were getting.
|
|
95
|
-
*/
|
|
96
|
-
export function compactBreweryBeer(item) {
|
|
97
|
-
const i = asDict(item) ?? {};
|
|
98
|
-
const beer = asDict(i.beer) ?? {};
|
|
99
|
-
return {
|
|
100
|
-
...beerCore(i),
|
|
101
|
-
rating: beer.rating_score,
|
|
102
|
-
rating_count: beer.rating_count,
|
|
103
|
-
checkin_count: i.total_count,
|
|
104
|
-
have_had: i.has_had,
|
|
105
|
-
your_count: i.total_user_count,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
78
|
/**
|
|
109
79
|
* Project `<container>.items` in a response with `mapFn`, preserving the rest of
|
|
110
80
|
* the container (pagination etc.). Drift-safe: returns the raw response
|
|
@@ -125,20 +95,55 @@ export const compactBeerSearch = (resp) => projectItems(resp, 'beers', compactBe
|
|
|
125
95
|
export const compactWishlist = (resp) => projectItems(resp, 'beers', compactWishlistBeer);
|
|
126
96
|
export const compactUserBeers = (resp) => projectItems(resp, 'beers', compactUserBeer);
|
|
127
97
|
/**
|
|
128
|
-
*
|
|
98
|
+
* The ONE compact shape of `untappd_brewery_beers`, whether its rows came from
|
|
99
|
+
* Untappd just now or from the local cache — so a caller never sees different
|
|
100
|
+
* keys depending on whether the cache happened to open. Both paths first map
|
|
101
|
+
* the page through `mapBreweryBeerItem` (store.ts), which is where the
|
|
102
|
+
* endpoint's own naming is read: `/brewery/beer_list/` says `total_count` (not
|
|
103
|
+
* `checkin_count`) and `has_had` (not `have_had`), established against a live
|
|
104
|
+
* capture of brewery 1142.
|
|
129
105
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
106
|
+
* It evolves the old live-only projection as a superset: every item keeps
|
|
107
|
+
* bid/name/style/abv/ibu/brewery/rating/rating_count/checkin_count and gains
|
|
108
|
+
* in_production (+ favorite when the cache marks one). Two old item fields are
|
|
109
|
+
* gone on purpose: `have_had` / `your_count` were the item's `has_had` /
|
|
110
|
+
* `total_user_count`, which describe the AUTHENTICATED account, never the user
|
|
111
|
+
* being asked about. The old top-level `total_count` and `beers.count` stay;
|
|
112
|
+
* Untappd's `sorting_options` (UI chrome duplicating the input enum) stays
|
|
113
|
+
* dropped; `pagination` is ours, so paging reads the same off either path.
|
|
134
114
|
*/
|
|
135
|
-
export function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
115
|
+
export function compactBreweryBeerList(r, page, favorites) {
|
|
116
|
+
const count = r.beers.length;
|
|
117
|
+
const more = r.total_count !== null ? page.offset + count < r.total_count : count > 0 && count >= page.limit;
|
|
118
|
+
return {
|
|
119
|
+
brewery_id: r.brewery_id,
|
|
120
|
+
brewery_name: r.brewery_name,
|
|
121
|
+
total_count: r.total_count,
|
|
122
|
+
source: r.source,
|
|
123
|
+
fetched_at: r.fetched_at,
|
|
124
|
+
expires_at: r.expires_at,
|
|
125
|
+
...(favorites ? { favorite: favorites.brewery } : {}),
|
|
126
|
+
pagination: { offset: page.offset, limit: page.limit, next_offset: more ? page.offset + count : null },
|
|
127
|
+
beers: {
|
|
128
|
+
count,
|
|
129
|
+
items: r.beers.map((b) => {
|
|
130
|
+
const fav = favorites?.beer(b.bid);
|
|
131
|
+
return {
|
|
132
|
+
bid: b.bid,
|
|
133
|
+
name: b.beer_name,
|
|
134
|
+
style: b.style,
|
|
135
|
+
abv: b.abv,
|
|
136
|
+
ibu: b.ibu,
|
|
137
|
+
brewery: r.brewery_name,
|
|
138
|
+
rating: b.rating_score,
|
|
139
|
+
rating_count: b.rating_count,
|
|
140
|
+
checkin_count: b.checkin_count,
|
|
141
|
+
in_production: b.in_production,
|
|
142
|
+
...(fav !== undefined ? { favorite: fav } : {}),
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
142
147
|
}
|
|
143
148
|
/**
|
|
144
149
|
* The rungs this server honours (`@chrischall/mcp-utils`' `view` vocabulary,
|
package/dist/index.js
CHANGED
|
@@ -14,24 +14,44 @@ import { registerWishlistTools } from './tools/wishlist.js';
|
|
|
14
14
|
import { registerCheckinTools } from './tools/checkin.js';
|
|
15
15
|
import { registerUtilityTools } from './tools/utilities.js';
|
|
16
16
|
import { registerCacheTools } from './tools/cache.js';
|
|
17
|
+
import { registerFavoriteTools } from './tools/favorites.js';
|
|
18
|
+
import { registerTripTools } from './tools/trip.js';
|
|
19
|
+
import { registerMenuTools } from './tools/menu.js';
|
|
17
20
|
import { logRegisteredTools } from './tools/diagnostics.js';
|
|
18
|
-
import { CheckinCache, defaultCachePath } from './cache/db.js';
|
|
21
|
+
import { CheckinCache, defaultCachePath, describeCachePath } from './cache/db.js';
|
|
19
22
|
// Build the env-based client once and inject it into each registrar. The
|
|
20
23
|
// constructor defers its config error, so the server still boots (and answers
|
|
21
24
|
// the host's install-time tools/list probe) when credentials are absent.
|
|
22
25
|
const client = new UntappdClient();
|
|
23
26
|
// The stdio server backs the cache with a local `node:sqlite` file, opened lazily
|
|
24
27
|
// on first cache-tool use so the server still boots when no path is configured.
|
|
28
|
+
// The path is resolved (and logged) once at startup, so it can't drift with the
|
|
29
|
+
// working directory a host happens to launch from.
|
|
30
|
+
const cachePath = defaultCachePath();
|
|
31
|
+
console.error(describeCachePath(cachePath));
|
|
25
32
|
let nodeCache;
|
|
26
|
-
const nodeCacheProvider = () =>
|
|
33
|
+
const nodeCacheProvider = () => {
|
|
34
|
+
if (!nodeCache) {
|
|
35
|
+
nodeCache = CheckinCache.open(cachePath);
|
|
36
|
+
// Hand the shared call log what this process already spent before the cache opened.
|
|
37
|
+
void nodeCache.recordApiCalls?.(client.rateLimit.observations()).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
return nodeCache;
|
|
40
|
+
};
|
|
41
|
+
// Persist every API response's rate-limit observation into the shared cache file
|
|
42
|
+
// (once it is open), so a second process — or the next one — counts this hour's
|
|
43
|
+
// calls too (src/ratelimit.ts). Recording never fails an API call.
|
|
44
|
+
client.onApiCall((o) => {
|
|
45
|
+
void nodeCache?.recordApiCalls?.([o]).catch(() => { });
|
|
46
|
+
});
|
|
27
47
|
await runMcp({
|
|
28
48
|
name: 'untappd-mcp',
|
|
29
49
|
version: VERSION,
|
|
30
50
|
banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
|
|
31
51
|
tools: [
|
|
32
52
|
(s) => registerBeerTools(s, client, nodeCacheProvider),
|
|
33
|
-
(s) => registerBreweryTools(s, client),
|
|
34
|
-
(s) => registerVenueTools(s, client),
|
|
53
|
+
(s) => registerBreweryTools(s, client, nodeCacheProvider),
|
|
54
|
+
(s) => registerVenueTools(s, client, nodeCacheProvider),
|
|
35
55
|
(s) => registerUserTools(s, client),
|
|
36
56
|
(s) => registerFeedTools(s, client),
|
|
37
57
|
(s) => registerResolveTools(s, client),
|
|
@@ -43,6 +63,9 @@ await runMcp({
|
|
|
43
63
|
// The cache is backed by a local `node:sqlite` file here; the provider is
|
|
44
64
|
// injectable, so another deployment can back the same tools differently.
|
|
45
65
|
(s) => registerCacheTools(s, client, nodeCacheProvider),
|
|
66
|
+
(s) => registerFavoriteTools(s, client, nodeCacheProvider),
|
|
67
|
+
(s) => registerTripTools(s, client, nodeCacheProvider),
|
|
68
|
+
(s) => registerMenuTools(s, client, nodeCacheProvider),
|
|
46
69
|
// Keep last: logs the full registered toolset (count + names) at startup.
|
|
47
70
|
(s) => logRegisteredTools(s, 'stdio'),
|
|
48
71
|
],
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Menu-line normalisation and candidate scoring for untappd_menu_pick.
|
|
2
|
+
//
|
|
3
|
+
// A tap list read off a photo is noisy ("HAP & HARRY'S TENNESSEE LAGER $6"),
|
|
4
|
+
// and Untappd's beer search ANDs every term — one stray word returns nothing
|
|
5
|
+
// (live: "i believe in nashville blonde" → 0 hits, "i believe in nashville" →
|
|
6
|
+
// the right beer). So a line is normalised to search-friendly tokens, searched,
|
|
7
|
+
// and every candidate is scored against those tokens. Pure functions only; the
|
|
8
|
+
// API calls live in pick.ts.
|
|
9
|
+
// Words that make a line generic ("whatever's on this week").
|
|
10
|
+
const GENERIC_WORDS = new Set([
|
|
11
|
+
'seasonal', 'seasonals', 'rotating', 'rotation', 'rotational', 'rotator', 'guest', 'featured', 'selection',
|
|
12
|
+
]);
|
|
13
|
+
// Words that carry no identity on a generic line ("ROTATING DRAFT BEER").
|
|
14
|
+
const GENERIC_FILLER = new Set([
|
|
15
|
+
'draft', 'draught', 'beer', 'beers', 'tap', 'taps', 'handle', 'cider', 'ciders', 'seltzer', 'seltzers',
|
|
16
|
+
'select', 'of', 'the', 'our', 'current', 'ask', 'your', 'server', 'bartender', 'on', 'brew', 'brews', 'release',
|
|
17
|
+
]);
|
|
18
|
+
// Serving words a menu adds that are never part of a beer's name.
|
|
19
|
+
const SERVING_WORDS = new Set(['draft', 'draught', 'pint', 'pints', 'pour', 'can', 'cans', 'bottle', 'bottles', 'glass']);
|
|
20
|
+
/**
|
|
21
|
+
* Bare style words. Untappd keeps the style out of most beer NAMES ("Fennario",
|
|
22
|
+
* style "IPA - American"), so a line's style word is dropped from the fallback
|
|
23
|
+
* search query and, when scoring, is explained by the candidate's style.
|
|
24
|
+
* Deliberately excludes differentiators like "double"/"imperial" — "Double
|
|
25
|
+
* Homestyle" is a different beer from "Homestyle".
|
|
26
|
+
*/
|
|
27
|
+
const STYLE_WORDS = new Set([
|
|
28
|
+
'ipa', 'ipas', 'lager', 'pilsner', 'pils', 'blonde', 'blond', 'ale', 'stout', 'porter', 'sour', 'saison', 'gose',
|
|
29
|
+
'hefeweizen', 'kolsch', 'cider', 'seltzer', 'pale', 'amber', 'wheat', 'witbier', 'helles', 'marzen', 'dunkel',
|
|
30
|
+
]);
|
|
31
|
+
// Brewery-name words that don't identify the brewery ("Brewing Company").
|
|
32
|
+
const BREWERY_NOISE = new Set([
|
|
33
|
+
'brewing', 'brewery', 'breweries', 'brewers', 'brewhouse', 'brew', 'company', 'co', 'beer', 'beers', 'ales',
|
|
34
|
+
'the', 'and', 'works', 'project', 'craft', 'inc', 'llc',
|
|
35
|
+
]);
|
|
36
|
+
/** Lowercase, strip accents/apostrophes, turn every other non-alphanumeric into a space. */
|
|
37
|
+
export function tokenize(s) {
|
|
38
|
+
return s
|
|
39
|
+
.normalize('NFKD')
|
|
40
|
+
.replace(/[̀-ͯ]/g, '')
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(/['’‘`´]/g, '') // harry's / harry’s → harrys, so either spelling matches
|
|
43
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
44
|
+
.split(' ')
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
// Prices, pour sizes and ABV a menu prints next to the name.
|
|
48
|
+
const NOISE_PATTERNS = [
|
|
49
|
+
/\$\s*\d+(?:[.,]\d{1,2})?/g, // $6, $ 7.50
|
|
50
|
+
/\b\d+(?:[.,]\d+)?\s*%(?:\s*abv)?/gi, // 6.5%, 7% ABV
|
|
51
|
+
/\babv\b\s*\d+(?:[.,]\d+)?/gi, // ABV 6.5
|
|
52
|
+
/\b\d+(?:[.,]\d+)?\s*(?:oz|ml|cl|l)\b/gi, // 16oz, 12 oz, 330ml
|
|
53
|
+
/\b\d+[.,]\d{2}\b/g, // 7.00
|
|
54
|
+
/\b\d{1,2}\s*\/\s*\d{1,2}\s*$/g, // trailing "6/9" price columns (a bare trailing number may be part of a name)
|
|
55
|
+
];
|
|
56
|
+
/** Normalise one raw menu line and classify it. */
|
|
57
|
+
export function normalizeLine(raw) {
|
|
58
|
+
let s = raw;
|
|
59
|
+
for (const re of NOISE_PATTERNS)
|
|
60
|
+
s = s.replace(re, ' ');
|
|
61
|
+
const tokens = [];
|
|
62
|
+
for (const t of tokenize(s)) {
|
|
63
|
+
// Collapse a duplicated word ("IPA IPA", "Lager Lager") from OCR or a
|
|
64
|
+
// style column printed after a name that already ends in the style.
|
|
65
|
+
if (tokens[tokens.length - 1] === t)
|
|
66
|
+
continue;
|
|
67
|
+
tokens.push(t);
|
|
68
|
+
}
|
|
69
|
+
// Generic only when nothing but filler follows the last generic word:
|
|
70
|
+
// "BLACK ABBEY SEASONAL" and "ROTATING DRAFT BEER" are, but "Widmer Rotator
|
|
71
|
+
// IPA" is a real beer whose name happens to contain one.
|
|
72
|
+
let lastGeneric = -1;
|
|
73
|
+
tokens.forEach((t, i) => {
|
|
74
|
+
if (GENERIC_WORDS.has(t))
|
|
75
|
+
lastGeneric = i;
|
|
76
|
+
});
|
|
77
|
+
if (lastGeneric !== -1 && tokens.slice(lastGeneric + 1).every((t) => GENERIC_FILLER.has(t))) {
|
|
78
|
+
const rest = tokens.filter((t) => !GENERIC_WORDS.has(t) && !GENERIC_FILLER.has(t));
|
|
79
|
+
return rest.length === 0
|
|
80
|
+
? { raw, kind: 'generic', tokens }
|
|
81
|
+
: { raw, kind: 'brewery_generic', tokens, breweryHint: rest.join(' ') };
|
|
82
|
+
}
|
|
83
|
+
const named = tokens.filter((t) => !SERVING_WORDS.has(t));
|
|
84
|
+
return { raw, kind: named.length ? 'beer' : 'generic', tokens: named };
|
|
85
|
+
}
|
|
86
|
+
/** Search queries to try for a line, most specific first (deduped). */
|
|
87
|
+
export function searchQueries(line) {
|
|
88
|
+
const full = line.tokens.join(' ');
|
|
89
|
+
const noStyle = line.tokens.filter((t) => !STYLE_WORDS.has(t)).join(' ');
|
|
90
|
+
return [...new Set([full, noStyle].filter(Boolean))];
|
|
91
|
+
}
|
|
92
|
+
const dict = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
|
|
93
|
+
const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
|
|
94
|
+
const strOr = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
|
95
|
+
/** Map a raw `/search/beer` item (`{checkin_count, beer, brewery}`) to a {@link Candidate}. */
|
|
96
|
+
export function candidateFromSearch(item) {
|
|
97
|
+
const i = dict(item);
|
|
98
|
+
const beer = dict(i.beer);
|
|
99
|
+
const brewery = dict(i.brewery);
|
|
100
|
+
const bid = numOr(beer.bid, null);
|
|
101
|
+
if (bid === null)
|
|
102
|
+
return null;
|
|
103
|
+
return {
|
|
104
|
+
bid,
|
|
105
|
+
name: strOr(beer.beer_name) ?? '',
|
|
106
|
+
brewery: strOr(brewery.brewery_name) ?? '',
|
|
107
|
+
brewery_id: numOr(brewery.brewery_id, null),
|
|
108
|
+
brewery_city: strOr(dict(brewery.location).brewery_city),
|
|
109
|
+
style: strOr(beer.beer_style),
|
|
110
|
+
abv: numOr(beer.beer_abv, null),
|
|
111
|
+
checkin_count: numOr(i.checkin_count, 0) ?? 0,
|
|
112
|
+
rating: numOr(beer.rating_score, null) || null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Score a candidate against a line's tokens. Higher is better. The terms:
|
|
117
|
+
* - beer-name tokens found in the line (×4) and brewery tokens found (×3);
|
|
118
|
+
* - each beer-name token NOT in the line costs 1.5 — the "Double Homestyle"
|
|
119
|
+
* / "Triple Homestyle" guard when the menu said "Homestyle";
|
|
120
|
+
* - each line token the candidate can't explain (by brewery, name or style)
|
|
121
|
+
* costs 1;
|
|
122
|
+
* - popularity, log10(check-ins) × 0.5 — the canonical 35k-check-in entry
|
|
123
|
+
* beats a 3-check-in duplicate with the same name;
|
|
124
|
+
* - +1 when the brewery's city is the venue's city (the tie-breaker).
|
|
125
|
+
*/
|
|
126
|
+
export function scoreCandidate(lineTokens, c, venueCity) {
|
|
127
|
+
const line = new Set(lineTokens);
|
|
128
|
+
const breweryAll = tokenize(c.brewery);
|
|
129
|
+
const breweryId = breweryAll.filter((t) => !BREWERY_NOISE.has(t));
|
|
130
|
+
const brewery = breweryId.length ? breweryId : breweryAll;
|
|
131
|
+
const name = tokenize(c.name);
|
|
132
|
+
const style = tokenize(c.style ?? '');
|
|
133
|
+
const frac = (ts) => (ts.length ? ts.filter((t) => line.has(t)).length / ts.length : 0);
|
|
134
|
+
const brewery_match = frac(brewery);
|
|
135
|
+
const name_match = frac(name);
|
|
136
|
+
const extraName = name.filter((t) => !line.has(t)).length;
|
|
137
|
+
const explained = new Set([...breweryAll, ...name, ...style, ...BREWERY_NOISE]);
|
|
138
|
+
const unexplained = lineTokens.filter((t) => !explained.has(t)).length;
|
|
139
|
+
const sameCity = venueCity && c.brewery_city && tokenize(venueCity).join(' ') === tokenize(c.brewery_city).join(' ') ? 1 : 0;
|
|
140
|
+
const score = 4 * name_match +
|
|
141
|
+
3 * brewery_match -
|
|
142
|
+
1.5 * extraName -
|
|
143
|
+
unexplained +
|
|
144
|
+
0.5 * Math.log10(c.checkin_count + 1) +
|
|
145
|
+
sameCity;
|
|
146
|
+
return { ...c, score: Math.round(score * 1000) / 1000, name_match, brewery_match };
|
|
147
|
+
}
|
|
148
|
+
/** Rank candidates for a line, best first. Duplicate bids keep their best score. */
|
|
149
|
+
export function rankCandidates(lineTokens, cands, venueCity) {
|
|
150
|
+
const best = new Map();
|
|
151
|
+
for (const c of cands) {
|
|
152
|
+
const s = scoreCandidate(lineTokens, c, venueCity);
|
|
153
|
+
const prev = best.get(c.bid);
|
|
154
|
+
if (!prev || s.score > prev.score)
|
|
155
|
+
best.set(c.bid, s);
|
|
156
|
+
}
|
|
157
|
+
return [...best.values()].sort((a, b) => b.score - a.score || b.checkin_count - a.checkin_count);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Is the top candidate a confident match? Most of the beer's name must be on
|
|
161
|
+
* the line, and the total must clear a floor that a name-only hit from an
|
|
162
|
+
* unrelated brewery doesn't reach.
|
|
163
|
+
*/
|
|
164
|
+
export function isConfident(top) {
|
|
165
|
+
return !!top && top.name_match >= 0.5 && top.score >= 4.5;
|
|
166
|
+
}
|
|
167
|
+
// Name/style hints that a brewery's beer is a seasonal or limited release.
|
|
168
|
+
const SEASONAL_RE = /season|oktoberfest|okto|marzen|festbier|\bfest\b|pumpkin|winter|summer|spring|autumn|\bfall\b|harvest|holiday|christmas|xmas|noel|snow|maibock|wet hop|fresh hop|shandy|radler|limited|rotat|anniversary/i;
|
|
169
|
+
/** Does this beer look like a seasonal / limited release? */
|
|
170
|
+
export function looksSeasonal(name, style) {
|
|
171
|
+
return SEASONAL_RE.test(tokenize(name).join(' ')) || SEASONAL_RE.test(tokenize(style ?? '').join(' '));
|
|
172
|
+
}
|
|
173
|
+
/** Score a `/search/brewery` result's name against a brewery hint (fraction of hint tokens matched). */
|
|
174
|
+
export function breweryNameMatch(hint, breweryName) {
|
|
175
|
+
const hintTokens = tokenize(hint).filter((t) => !BREWERY_NOISE.has(t));
|
|
176
|
+
const name = new Set(tokenize(breweryName));
|
|
177
|
+
return hintTokens.length ? hintTokens.filter((t) => name.has(t)).length / hintTokens.length : 0;
|
|
178
|
+
}
|