untappd-mcp 2.4.0 → 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 +74 -2
- package/dist/bundle.js +5438 -1664
- package/dist/cache/brewery-beers.js +116 -0
- 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 +1203 -12
- package/dist/cache/sync-beers.js +24 -2
- 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 +29 -15
- package/dist/compact.js +47 -42
- package/dist/index.js +20 -3
- package/dist/menu/pick.js +7 -1
- package/dist/ratelimit.js +236 -0
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +457 -35
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +92 -38
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
package/dist/tools/cache.js
CHANGED
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { RateLimitError, confirmTokenParam, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import { beerMetaFrom } from '../cache/store.js';
|
|
3
|
+
import { beerMetaFrom, DEFAULT_VISIT_RADIUS_M } from '../cache/store.js';
|
|
4
4
|
import { syncCheckins } from '../cache/sync.js';
|
|
5
5
|
import { syncUserBeers } from '../cache/sync-beers.js';
|
|
6
|
+
import { syncUserVenues, VENUES_LIST, VENUES_MAX_PAGES_CAP, VENUES_REFRESH_LIST } from '../cache/sync-venues.js';
|
|
7
|
+
import { syncWishlist, WISHLIST_MAX_PAGES_CAP } from '../cache/sync-wishlist.js';
|
|
8
|
+
import { syncBreweryLikes, BREWERY_LIKES_MAX_PAGES_CAP } from '../cache/sync-brewery-likes.js';
|
|
9
|
+
import { syncVenueDrinks, VENUE_DRINKS_DEFAULT_CALLS, VENUE_DRINKS_MAX_CALLS_CAP } from '../cache/venue-drinks.js';
|
|
10
|
+
import { DEFAULT_MULTI_USER_MODE, MAX_GROUP_USERS, MULTI_USER_MODES, multiHasHad, resolveUsers, } from '../cache/multi-user.js';
|
|
11
|
+
import { favoriteLookup } from '../cache/favorites.js';
|
|
6
12
|
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
7
13
|
import { clampToQuota } from '../quota.js';
|
|
14
|
+
import { rateLimitStatus } from '../ratelimit.js';
|
|
8
15
|
// Re-fetch cached beer metadata at most this often; a hit newer than this skips
|
|
9
16
|
// the beer/info API call.
|
|
10
17
|
const BEER_META_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
18
|
+
/**
|
|
19
|
+
* untappd_top_not_had's prefer_favorites boost, added to a beer's global rating
|
|
20
|
+
* (0–5) for ranking only: a wishlisted beer (explicit intent) gets more than one
|
|
21
|
+
* from a favorite brewery. Small enough that a clearly better beer still wins.
|
|
22
|
+
*/
|
|
23
|
+
export const WISHLIST_BOOST = 0.5;
|
|
24
|
+
export const FAVORITE_BREWERY_BOOST = 0.25;
|
|
11
25
|
export const UsernameArg = z
|
|
12
26
|
.string()
|
|
13
27
|
.min(1)
|
|
@@ -85,6 +99,77 @@ export async function freshness(cache, username) {
|
|
|
85
99
|
...(caveat ? { caveat } : {}),
|
|
86
100
|
};
|
|
87
101
|
}
|
|
102
|
+
/** Several users' has-had freshness: every block, all-complete, and one caveat naming the incomplete users. */
|
|
103
|
+
export async function groupFreshness(cache, usernames) {
|
|
104
|
+
const byUser = {};
|
|
105
|
+
const incomplete = [];
|
|
106
|
+
for (const u of usernames) {
|
|
107
|
+
const f = await freshness(cache, u);
|
|
108
|
+
byUser[u] = f;
|
|
109
|
+
if (!f.coverage_complete)
|
|
110
|
+
incomplete.push(u);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
coverage_complete: incomplete.length === 0,
|
|
114
|
+
by_user: byUser,
|
|
115
|
+
...(incomplete.length
|
|
116
|
+
? {
|
|
117
|
+
caveat: `Has-had coverage is incomplete for ${incomplete.join(', ')} (see by_user) — a beer counted as not had by ` +
|
|
118
|
+
'them may be a false negative. Run untappd_sync_user_beers for each until complete.',
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const UsernamesArg = z
|
|
124
|
+
.array(z.string().min(1))
|
|
125
|
+
.min(1)
|
|
126
|
+
.max(MAX_GROUP_USERS)
|
|
127
|
+
.optional()
|
|
128
|
+
.describe(`Check a GROUP of users (1–${MAX_GROUP_USERS}) instead of one — e.g. everyone at the table. Use instead of username; ` +
|
|
129
|
+
'each must be synced (untappd_sync_user_beers).');
|
|
130
|
+
const ModeArg = z
|
|
131
|
+
.enum(MULTI_USER_MODES)
|
|
132
|
+
.optional()
|
|
133
|
+
.describe('With usernames: "none_had" (default) keeps beers NONE of them has had (new to everyone); "any_not_had" keeps ' +
|
|
134
|
+
'beers at least one of them has not had.');
|
|
135
|
+
/**
|
|
136
|
+
* Freshness block for venue-history reads (untappd_cache_has_visited): how much
|
|
137
|
+
* of the user's user/venues list is cached, so a "not visited" can be flagged
|
|
138
|
+
* as a possible false negative.
|
|
139
|
+
*/
|
|
140
|
+
export async function venueFreshness(cache, username) {
|
|
141
|
+
const state = await cache.getListState(username, VENUES_LIST);
|
|
142
|
+
const refresh = await cache.getListState(username, VENUES_REFRESH_LIST);
|
|
143
|
+
const cached = await cache.userVenuesCount(username);
|
|
144
|
+
const backfilled = state?.complete ?? false;
|
|
145
|
+
// A cut-short incremental refresh means re-visited or new venues below its
|
|
146
|
+
// cursor may be missing, so the history is not authoritative until it ends.
|
|
147
|
+
const refreshing = backfilled && !!refresh && !refresh.complete;
|
|
148
|
+
const complete = backfilled && !refreshing;
|
|
149
|
+
const total = state?.total_count ?? null;
|
|
150
|
+
const percent = pct(cached, total, complete);
|
|
151
|
+
let caveat;
|
|
152
|
+
if (!state?.last_synced_at && cached === 0) {
|
|
153
|
+
caveat =
|
|
154
|
+
"This user's venue history has never been synced — the cache is EMPTY. Run untappd_sync_user_venues first; results here are not authoritative.";
|
|
155
|
+
}
|
|
156
|
+
else if (refreshing) {
|
|
157
|
+
caveat =
|
|
158
|
+
'A venue-history refresh is unfinished — recently visited or new venues may be missing or show a stale recent_visit, so a "not visited" may be a false negative. Run untappd_sync_user_venues until another_run_needed is false.';
|
|
159
|
+
}
|
|
160
|
+
else if (!complete) {
|
|
161
|
+
caveat = `Venue history incomplete (${percent !== null ? `~${percent}%` : 'unknown'} cached). A "not visited" may be a false negative — run untappd_sync_user_venues until another_run_needed is false.`;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
last_synced_at: state?.last_synced_at ?? null,
|
|
165
|
+
coverage_complete: complete,
|
|
166
|
+
...(refreshing ? { refresh_in_progress: true } : {}),
|
|
167
|
+
cached_venues: cached,
|
|
168
|
+
total_venues: total,
|
|
169
|
+
percent,
|
|
170
|
+
...(caveat ? { caveat } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
88
173
|
/**
|
|
89
174
|
* Register the check-in cache tools. `cacheProvider` supplies the backing store
|
|
90
175
|
* (a `node:sqlite` file here) and is required so this module imports no
|
|
@@ -95,9 +180,15 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
95
180
|
title: 'Show the local cache location, schema and per-user coverage',
|
|
96
181
|
description: 'Diagnostics for the LOCAL check-in cache — NO API call. Returns the resolved cache file path, its size on ' +
|
|
97
182
|
'disk (db + WAL), journal mode, schema version, the number of cached menu-line resolutions and beer-metadata ' +
|
|
98
|
-
'rows, and for every cached user: cached vs total distinct beers, cached check-ins,
|
|
99
|
-
'resume offset of an unfinished user/beers backfill,
|
|
100
|
-
'
|
|
183
|
+
'rows, cached venues, and for every cached user: cached vs total distinct beers, cached check-ins, ' +
|
|
184
|
+
'coverage_complete, the resume offset of an unfinished user/beers backfill, last_synced_at, and venue-history ' +
|
|
185
|
+
'coverage (venues: cached vs total, complete, percent, next_offset; null if never synced), wishlist and ' +
|
|
186
|
+
'Untappd brewery-likes mirror coverage (wishlist / brewery_likes: cached vs total, complete; null if never ' +
|
|
187
|
+
'synced). Also reports rate_limit: API calls ' +
|
|
188
|
+
'made in the last hour (this process plus any other sharing the cache file), the ~100/hour limit, the ' +
|
|
189
|
+
'estimated calls left, the raw X-Ratelimit-* headers (the v4 API sends Remaining: 0 on every success, so it ' +
|
|
190
|
+
'is ignored until it reports a live value) and any 429 wait. Use it to confirm the cache persisted across ' +
|
|
191
|
+
'restarts, to see how far a backfill has got, or to check whether a sync can run now.',
|
|
101
192
|
annotations: toolAnnotations({ title: 'Show the local cache location, schema and per-user coverage', readOnly: true, idempotent: true, openWorld: false }),
|
|
102
193
|
inputSchema: z.object({}),
|
|
103
194
|
}, async () => {
|
|
@@ -111,7 +202,9 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
111
202
|
schema_version: stats.schema_version,
|
|
112
203
|
resolution_cache_entries: stats.resolution_cache_entries,
|
|
113
204
|
beer_meta_entries: stats.beer_meta_entries,
|
|
205
|
+
venue_entries: stats.venue_entries,
|
|
114
206
|
users: stats.users,
|
|
207
|
+
rate_limit: await rateLimitStatus(client, cache),
|
|
115
208
|
});
|
|
116
209
|
});
|
|
117
210
|
server.registerTool('untappd_sync_checkins', {
|
|
@@ -122,7 +215,9 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
122
215
|
'than your own and will not page further — such a sync reports history_truncated and you should use ' +
|
|
123
216
|
'untappd_sync_user_beers for full has-had coverage. backfill_complete is only reported once ~all of ' +
|
|
124
217
|
'total_checkins is cached. Pass force_backfill: true to reset a cache wrongly marked complete and re-page ' +
|
|
125
|
-
'the whole history (cached rows are kept).
|
|
218
|
+
'the whole history (cached rows are kept). Stops BEFORE the ~100 calls/hour rate limit (or cleanly on a 429) ' +
|
|
219
|
+
'with stopped_for_rate_limit and rate_limit.resume_at — progress is saved; re-run after that time. Omit ' +
|
|
220
|
+
'username for your own account.',
|
|
126
221
|
annotations: toolAnnotations({ title: "Sync a user's check-ins into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
127
222
|
inputSchema: z.object({
|
|
128
223
|
username: UsernameArg,
|
|
@@ -156,7 +251,9 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
156
251
|
'"has had" coverage — thousands of beers instead of tens of thousands of check-ins — and, unlike ' +
|
|
157
252
|
'user/checkins, it pages fully for any public/friend account. Offset-paged and resumable: fetches max_pages ' +
|
|
158
253
|
'per call and persists progress; run again until another_run_needed is false. Feeds the same ' +
|
|
159
|
-
'untappd_cache_has_had / has_had_many / not_had tools.
|
|
254
|
+
'untappd_cache_has_had / has_had_many / not_had tools. Stops BEFORE the ~100 calls/hour rate limit (or ' +
|
|
255
|
+
'cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at — progress is saved; re-run after ' +
|
|
256
|
+
'that time. Omit username for your own account.',
|
|
160
257
|
annotations: toolAnnotations({ title: "Sync a user's complete distinct-beers list into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
161
258
|
inputSchema: z.object({
|
|
162
259
|
username: UsernameArg,
|
|
@@ -174,12 +271,249 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
174
271
|
const summary = await syncUserBeers(client, cacheProvider(), user, quota.budget);
|
|
175
272
|
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
176
273
|
});
|
|
274
|
+
server.registerTool('untappd_sync_user_venues', {
|
|
275
|
+
title: "Sync a user's complete venue history into the cache",
|
|
276
|
+
description: "Fetch a user's COMPLETE venue history (every venue they've checked in at, with first/most-recent visit dates " +
|
|
277
|
+
'and visit count) into the cache from user/venues. Unlike user/checkins — which only returns the ~50 most ' +
|
|
278
|
+
'recent check-ins for anyone but yourself — user/venues pages any public/friend account\'s whole history, so ' +
|
|
279
|
+
'this is how to answer "has this person been here?" (via untappd_cache_has_visited). Untappd serves 25 venues ' +
|
|
280
|
+
'per page, so a 2,700-venue history is ~110 calls: resumable — fetches max_pages per call, saves progress ' +
|
|
281
|
+
'after every page; run again until another_run_needed is false. Once complete, a re-run is a cheap refresh ' +
|
|
282
|
+
'from the top that stops at the first page with no changed visits (a refresh cut short resumes where it ' +
|
|
283
|
+
'stopped; another_run_needed stays true until it finishes). Stops BEFORE the ~100 calls/hour rate ' +
|
|
284
|
+
'limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at. Omit username for your own account.',
|
|
285
|
+
annotations: toolAnnotations({ title: "Sync a user's complete venue history into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
286
|
+
inputSchema: z.object({
|
|
287
|
+
username: UsernameArg,
|
|
288
|
+
max_pages: z
|
|
289
|
+
.number()
|
|
290
|
+
.int()
|
|
291
|
+
.min(1)
|
|
292
|
+
.max(VENUES_MAX_PAGES_CAP)
|
|
293
|
+
.optional()
|
|
294
|
+
.describe('Pages (25 venues each) to fetch this run (default 10, max 50). Keep modest to respect the ~100 calls/hour rate limit.'),
|
|
295
|
+
}),
|
|
296
|
+
}, async ({ username, max_pages }) => {
|
|
297
|
+
const user = resolveUser(username, client.loginName);
|
|
298
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
299
|
+
const summary = await syncUserVenues(client, cacheProvider(), user, quota.budget);
|
|
300
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
301
|
+
});
|
|
302
|
+
server.registerTool('untappd_sync_wishlist', {
|
|
303
|
+
title: "Sync a user's wishlist into the cache",
|
|
304
|
+
description: "Mirror a user's Untappd wishlist (user/wishlist) into the local cache, so pick tools can boost wishlisted " +
|
|
305
|
+
'beers without an API call. Each sync is a full pass from the top (~25 beers per call; wishlists are small), ' +
|
|
306
|
+
'adding new beers and — once the pass finishes — dropping ones no longer wishlisted (a partial pass never ' +
|
|
307
|
+
'deletes). Resumable: saves progress every page; run again until another_run_needed is false. Stops BEFORE ' +
|
|
308
|
+
'the ~100 calls/hour rate limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at. ' +
|
|
309
|
+
'Only reads Untappd — never changes the wishlist (that is untappd_wishlist_add / _remove). Omit username for ' +
|
|
310
|
+
'your own account; another user\'s wishlist needs a public account or friendship.',
|
|
311
|
+
annotations: toolAnnotations({ title: "Sync a user's wishlist into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
312
|
+
inputSchema: z.object({
|
|
313
|
+
username: UsernameArg,
|
|
314
|
+
max_pages: z
|
|
315
|
+
.number()
|
|
316
|
+
.int()
|
|
317
|
+
.min(1)
|
|
318
|
+
.max(WISHLIST_MAX_PAGES_CAP)
|
|
319
|
+
.optional()
|
|
320
|
+
.describe(`Pages (~25 beers each) to fetch this run (default 10, max ${WISHLIST_MAX_PAGES_CAP}).`),
|
|
321
|
+
}),
|
|
322
|
+
}, async ({ username, max_pages }) => {
|
|
323
|
+
const user = resolveUser(username, client.loginName);
|
|
324
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
325
|
+
const summary = await syncWishlist(client, cacheProvider(), user, quota.budget);
|
|
326
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
327
|
+
});
|
|
328
|
+
server.registerTool('untappd_sync_brewery_likes', {
|
|
329
|
+
title: "Sync a user's liked (followed) breweries into the cache",
|
|
330
|
+
description: "Mirror the breweries a user \"likes\" on Untappd (user/brewerylikes — the app calls them \"followings\"; " +
|
|
331
|
+
"works for other users' public/friend accounts) into the local cache. Liked breweries then count as favorites " +
|
|
332
|
+
'with source "untappd" — in untappd_favorites_list, the `favorite` field on has_had / brewery_beers / ' +
|
|
333
|
+
'venue_menu / top_not_had / trip_check results, and the prefer_favorites boost. Each sync is a full pass from ' +
|
|
334
|
+
'the top (~50 breweries per call), adding new likes and — once the pass finishes — dropping unliked ones (a ' +
|
|
335
|
+
'partial pass never deletes). Resumable: saves progress every page; run again until another_run_needed is ' +
|
|
336
|
+
'false (reports cached/total and percent). Stops BEFORE the ~100 calls/hour rate limit (or cleanly on a 429) ' +
|
|
337
|
+
'with stopped_for_rate_limit and rate_limit.resume_at. Only reads Untappd. Untappd has NO followed/favorite ' +
|
|
338
|
+
'VENUE list, so venue favorites stay derived + manual. (brewery/info\'s follow_status is the signed-in ' +
|
|
339
|
+
"account's, never another user's — this list is the way to see theirs.) Omit username for your own account.",
|
|
340
|
+
annotations: toolAnnotations({
|
|
341
|
+
title: "Sync a user's liked (followed) breweries into the cache",
|
|
342
|
+
readOnly: false,
|
|
343
|
+
idempotent: false,
|
|
344
|
+
openWorld: true,
|
|
345
|
+
destructive: false,
|
|
346
|
+
}),
|
|
347
|
+
inputSchema: z.object({
|
|
348
|
+
username: UsernameArg,
|
|
349
|
+
max_pages: z
|
|
350
|
+
.number()
|
|
351
|
+
.int()
|
|
352
|
+
.min(1)
|
|
353
|
+
.max(BREWERY_LIKES_MAX_PAGES_CAP)
|
|
354
|
+
.optional()
|
|
355
|
+
.describe(`Pages (~50 breweries each) to fetch this run (default 10, max ${BREWERY_LIKES_MAX_PAGES_CAP}).`),
|
|
356
|
+
}),
|
|
357
|
+
}, async ({ username, max_pages }) => {
|
|
358
|
+
const user = resolveUser(username, client.loginName);
|
|
359
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
360
|
+
const summary = await syncBreweryLikes(client, cacheProvider(), user, quota.budget);
|
|
361
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
362
|
+
});
|
|
363
|
+
server.registerTool('untappd_sync_venue_drinks', {
|
|
364
|
+
title: 'Fetch what a user drank on their first/latest visit to each venue (opt-in)',
|
|
365
|
+
description: 'OPT-IN, costs API calls: for a user\'s cached venue history (run untappd_sync_user_venues first), fetch the ' +
|
|
366
|
+
'FIRST and MOST-RECENT visit check-ins of each venue via checkin/view (works for any public check-in) and cache ' +
|
|
367
|
+
'the beer + date — a PARTIAL "what did they drink there" (two check-ins per venue at most, never the full ' +
|
|
368
|
+
'list). One call per check-in, most-visited venues first, at most max_calls per run (default ' +
|
|
369
|
+
`${VENUE_DRINKS_DEFAULT_CALLS}, max ${VENUE_DRINKS_MAX_CALLS_CAP}); already-cached check-ins are never re-fetched, ` +
|
|
370
|
+
'and deleted/private ones (404) are remembered and skipped. Narrow with venue_ids and which. Stops BEFORE the ' +
|
|
371
|
+
'~100 calls/hour rate limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at; run ' +
|
|
372
|
+
'again until another_run_needed is false. Results appear in untappd_cache_has_visited under matches[].drinks. ' +
|
|
373
|
+
'Stores only beer, brewery and date — never comments. Omit username for your own account.',
|
|
374
|
+
annotations: toolAnnotations({
|
|
375
|
+
title: 'Fetch what a user drank on their first/latest visit to each venue (opt-in)',
|
|
376
|
+
readOnly: false,
|
|
377
|
+
idempotent: false,
|
|
378
|
+
openWorld: true,
|
|
379
|
+
destructive: false,
|
|
380
|
+
}),
|
|
381
|
+
inputSchema: z.object({
|
|
382
|
+
username: UsernameArg,
|
|
383
|
+
max_calls: z
|
|
384
|
+
.number()
|
|
385
|
+
.int()
|
|
386
|
+
.min(1)
|
|
387
|
+
.max(VENUE_DRINKS_MAX_CALLS_CAP)
|
|
388
|
+
.optional()
|
|
389
|
+
.describe(`checkin/view calls this run (default ${VENUE_DRINKS_DEFAULT_CALLS}, max ${VENUE_DRINKS_MAX_CALLS_CAP}). Each is one of the ~100 hourly calls.`),
|
|
390
|
+
venue_ids: z
|
|
391
|
+
.array(z.number().int().positive())
|
|
392
|
+
.min(1)
|
|
393
|
+
.max(100)
|
|
394
|
+
.optional()
|
|
395
|
+
.describe('Only these venues (from untappd_cache_has_visited). Default: every cached venue, most-visited first.'),
|
|
396
|
+
which: z
|
|
397
|
+
.enum(['both', 'first', 'recent'])
|
|
398
|
+
.optional()
|
|
399
|
+
.describe('Fetch each venue\'s first-visit check-in, most-recent one, or both (default both).'),
|
|
400
|
+
}),
|
|
401
|
+
}, async ({ username, max_calls, venue_ids, which }) => {
|
|
402
|
+
const user = resolveUser(username, client.loginName);
|
|
403
|
+
const quota = clampToQuota(client, max_calls ?? VENUE_DRINKS_DEFAULT_CALLS, 1);
|
|
404
|
+
const summary = await syncVenueDrinks(client, cacheProvider(), user, { maxCalls: quota.budget, venueIds: venue_ids, which });
|
|
405
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
406
|
+
});
|
|
407
|
+
const VenueQueryArg = z.object({
|
|
408
|
+
label: z.string().max(200).optional().describe('Your own name for this query, echoed back (e.g. "Coppertail, Tampa")'),
|
|
409
|
+
venue_ids: z.array(z.number().int().positive()).max(100).optional().describe('Exact Untappd venue ids'),
|
|
410
|
+
name: z
|
|
411
|
+
.string()
|
|
412
|
+
.min(1)
|
|
413
|
+
.max(200)
|
|
414
|
+
.optional()
|
|
415
|
+
.describe('Venue name: matches a case-insensitive substring, or every significant word ("Green Bench Brewing" finds ' +
|
|
416
|
+
'"Green Bench Brewing Co." and its "(duplicate of …)" copies)'),
|
|
417
|
+
city: z.string().min(1).max(100).optional().describe('Narrow a name/geo match to venues whose city contains this'),
|
|
418
|
+
state: z.string().min(1).max(100).optional().describe('Narrow a name/geo match to venues whose state contains this (e.g. "FL")'),
|
|
419
|
+
lat: z.number().min(-90).max(90).optional().describe('Latitude of a point; needs lng'),
|
|
420
|
+
lng: z.number().min(-180).max(180).optional().describe('Longitude of a point; needs lat'),
|
|
421
|
+
radius_m: z
|
|
422
|
+
.number()
|
|
423
|
+
.positive()
|
|
424
|
+
.max(50_000)
|
|
425
|
+
.optional()
|
|
426
|
+
.describe(`Radius around lat/lng in metres (default ${DEFAULT_VISIT_RADIUS_M})`),
|
|
427
|
+
});
|
|
428
|
+
server.registerTool('untappd_cache_has_visited', {
|
|
429
|
+
title: 'Check if a user has been to a venue (from the cache)',
|
|
430
|
+
description: 'Answer "has this user been here?" for one or many places in ONE call, from the cached venue history only — ' +
|
|
431
|
+
'NO API call. Each query matches by exact venue_ids, by name (case-insensitive substring or all significant ' +
|
|
432
|
+
'words, optionally narrowed by city/state), and/or by lat/lng within radius_m (default 150 m); venue_ids are ' +
|
|
433
|
+
'unioned with the name/geo match, and name + lat/lng together must both hold. Name/location matching matters ' +
|
|
434
|
+
'because a brewery_id is NOT its taproom\'s venue_id, a brewery can have several venues, and Untappd keeps ' +
|
|
435
|
+
'"… (duplicate of …)" venues — so expect, and check, several matches. Returns per query: visited, the matched ' +
|
|
436
|
+
'venues (first_visit, recent_visit, total_count, is_duplicate, distance_m, and — once untappd_sync_venue_drinks ' +
|
|
437
|
+
'has fetched them — drinks: the beer from the first/recent visit check-ins — and favorite {is_favorite, sources, ' +
|
|
438
|
+
'reason}: derived from the visit history or added with untappd_favorite_add), and total_visits; plus a freshness ' +
|
|
439
|
+
'block (last_synced_at, coverage_complete, caveat). Run untappd_sync_user_venues first.',
|
|
440
|
+
annotations: toolAnnotations({ title: 'Check if a user has been to a venue (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
|
|
441
|
+
inputSchema: z.object({
|
|
442
|
+
username: UsernameArg,
|
|
443
|
+
queries: z.array(VenueQueryArg).min(1).max(50).describe('Places to check (1–50); each needs venue_ids, name, or lat+lng'),
|
|
444
|
+
max_matches: z.number().int().min(1).max(50).optional().describe('Most matched venues listed per query (default 10)'),
|
|
445
|
+
}),
|
|
446
|
+
}, async ({ username, queries, max_matches }) => {
|
|
447
|
+
queries.forEach((q, i) => {
|
|
448
|
+
const hasGeo = q.lat !== undefined || q.lng !== undefined;
|
|
449
|
+
if (hasGeo && (q.lat === undefined || q.lng === undefined)) {
|
|
450
|
+
throw createHelpfulError(`Query ${i + 1}: lat and lng must be given together.`, { hint: 'Pass both, or match by name / venue_ids instead.' });
|
|
451
|
+
}
|
|
452
|
+
if (!q.venue_ids?.length && q.name === undefined && !hasGeo) {
|
|
453
|
+
throw createHelpfulError(`Query ${i + 1} has nothing to match on.`, {
|
|
454
|
+
hint: 'Give venue_ids, a name (optionally with city/state), or lat + lng (optionally radius_m).',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
const user = resolveUser(username, client.loginName);
|
|
459
|
+
const cache = cacheProvider();
|
|
460
|
+
const limit = max_matches ?? 10;
|
|
461
|
+
const results = [];
|
|
462
|
+
let visitedCount = 0;
|
|
463
|
+
const found = [];
|
|
464
|
+
for (const { label, ...q } of queries)
|
|
465
|
+
found.push({ label, q, matches: await cache.findVisitedVenues(user, q) });
|
|
466
|
+
const favs = await favoriteLookup(cache, [user], { venue: found.flatMap((f) => f.matches.slice(0, limit).map((m) => m.venue_id)) });
|
|
467
|
+
for (const { label, q, matches } of found) {
|
|
468
|
+
if (matches.length > 0)
|
|
469
|
+
visitedCount++;
|
|
470
|
+
const shown = matches.slice(0, limit);
|
|
471
|
+
const drinksByVenue = new Map();
|
|
472
|
+
for (const d of await cache.getVenueDrinks(user, shown.map((m) => m.venue_id))) {
|
|
473
|
+
const list = drinksByVenue.get(d.venue_id) ?? [];
|
|
474
|
+
list.push({ checkin_id: d.checkin_id, bid: d.bid, beer_name: d.beer_name, brewery_name: d.brewery_name, created_at: d.created_at });
|
|
475
|
+
drinksByVenue.set(d.venue_id, list);
|
|
476
|
+
}
|
|
477
|
+
results.push({
|
|
478
|
+
...(label !== undefined ? { label } : {}),
|
|
479
|
+
query: q,
|
|
480
|
+
visited: matches.length > 0,
|
|
481
|
+
match_count: matches.length,
|
|
482
|
+
total_visits: matches.reduce((n, m) => n + (m.total_count ?? 0), 0),
|
|
483
|
+
matches: shown.map((m) => ({
|
|
484
|
+
venue_id: m.venue_id,
|
|
485
|
+
name: m.name,
|
|
486
|
+
category: m.category,
|
|
487
|
+
city: m.city,
|
|
488
|
+
state: m.state,
|
|
489
|
+
country: m.country,
|
|
490
|
+
...(m.is_duplicate ? { is_duplicate: true } : {}),
|
|
491
|
+
...(m.distance_m !== undefined ? { distance_m: m.distance_m } : {}),
|
|
492
|
+
first_visit: m.first_visit,
|
|
493
|
+
recent_visit: m.recent_visit,
|
|
494
|
+
total_count: m.total_count,
|
|
495
|
+
first_checkin_id: m.first_checkin_id,
|
|
496
|
+
recent_checkin_id: m.recent_checkin_id,
|
|
497
|
+
...(drinksByVenue.has(m.venue_id) ? { drinks: drinksByVenue.get(m.venue_id) } : {}),
|
|
498
|
+
favorite: favs.get('venue', m.venue_id),
|
|
499
|
+
})),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return minifiedResult({
|
|
503
|
+
username: user,
|
|
504
|
+
checked: queries.length,
|
|
505
|
+
visited_count: visitedCount,
|
|
506
|
+
results,
|
|
507
|
+
freshness: await venueFreshness(cache, user),
|
|
508
|
+
});
|
|
509
|
+
});
|
|
177
510
|
server.registerTool('untappd_cache_has_had', {
|
|
178
511
|
title: 'Check if a user has had a beer (from the cache)',
|
|
179
512
|
description: 'Answer "has this user ever checked in this beer?" from the cache only — NO API call. Consults BOTH cached ' +
|
|
180
513
|
'sources (check-ins and the distinct-beers list); a hit in either counts as had. Match by exact bid or a ' +
|
|
181
514
|
'case-insensitive substring of the beer name. Returns whether they had it, the times-had count, best rating, ' +
|
|
182
|
-
'last date, which sources matched,
|
|
515
|
+
'last date, which sources matched, any detailed check-ins, and favorite {is_favorite, sources, reason} (rated ' +
|
|
516
|
+
'≥4.25 / had ≥3 times, or added with untappd_favorite_add). Reports per-source freshness so you can ' +
|
|
183
517
|
'caveat incomplete data. Requires bid or beer_name. Run untappd_sync_user_beers first for full coverage.',
|
|
184
518
|
annotations: toolAnnotations({ title: 'Check if a user has had a beer (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
|
|
185
519
|
inputSchema: z.object({
|
|
@@ -194,29 +528,66 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
194
528
|
const user = resolveUser(username, client.loginName);
|
|
195
529
|
const cache = cacheProvider();
|
|
196
530
|
const result = await cache.hasHad(user, { bid, beerName: beer_name });
|
|
197
|
-
|
|
531
|
+
// By bid: that beer. By name only: any matched check-in's beer that is a favorite.
|
|
532
|
+
const favBids = bid !== undefined ? [bid] : [...new Set(result.matches.map((m) => m.bid).filter((b) => b !== null))];
|
|
533
|
+
const favs = await favoriteLookup(cache, [user], { beer: favBids });
|
|
534
|
+
const favorite = favBids.map((b) => favs.get('beer', b)).find((f) => f.is_favorite) ?? favs.get('beer', null);
|
|
535
|
+
return minifiedResult({ username: user, query: { bid, beer_name }, ...result, favorite, freshness: await freshness(cache, user) });
|
|
198
536
|
});
|
|
199
537
|
server.registerTool('untappd_cache_has_had_many', {
|
|
200
538
|
title: 'Batch-check many beers against the cache',
|
|
201
539
|
description: 'Cross-check a list of beer ids against a user\'s cached history in ONE call — NO API call. Consults BOTH ' +
|
|
202
|
-
'sources (check-ins + distinct beers). Returns had/not-had per bid (with count and last date when had)
|
|
203
|
-
'
|
|
204
|
-
'
|
|
540
|
+
'sources (check-ins + distinct beers). Returns had/not-had per bid (with count and last date when had), plus ' +
|
|
541
|
+
'favorite {is_favorite, sources, reason} per bid and brewery_favorite when its brewery (from cached beer ' +
|
|
542
|
+
'metadata) is a favorite. Ideal ' +
|
|
543
|
+
'for checking a whole venue menu at once. Pass usernames (instead of username) to check a GROUP: each bid ' +
|
|
544
|
+
'then lists had_by / not_had_by and qualifies per mode — "none_had" (default: new to everyone) or ' +
|
|
545
|
+
'"any_not_had" (at least one of them still needs it) — with qualifying_bids and a per-user freshness block. ' +
|
|
546
|
+
'Run untappd_sync_user_beers first (for every user); the freshness block flags if coverage is incomplete.',
|
|
205
547
|
annotations: toolAnnotations({ title: 'Batch-check many beers against the cache', readOnly: true, idempotent: true, openWorld: false }),
|
|
206
548
|
inputSchema: z.object({
|
|
207
549
|
username: UsernameArg,
|
|
550
|
+
usernames: UsernamesArg,
|
|
551
|
+
mode: ModeArg,
|
|
208
552
|
bids: z.array(z.number().int().positive()).min(1).max(500).describe('Beer ids to check (1–500)'),
|
|
209
553
|
}),
|
|
210
|
-
}, async ({ username, bids }) => {
|
|
211
|
-
const user = resolveUser(username, client.loginName);
|
|
554
|
+
}, async ({ username, usernames, mode, bids }) => {
|
|
212
555
|
const cache = cacheProvider();
|
|
556
|
+
// One lookup for the whole batch (never per bid). Brewery ids come from cached beer metadata, when known.
|
|
557
|
+
const favoritesFor = async (users) => {
|
|
558
|
+
const breweryOf = new Map((await cache.getBeerMeta(bids)).map((b) => [b.bid, b.brewery_id]));
|
|
559
|
+
const favs = await favoriteLookup(cache, users, { beer: bids, brewery: [...breweryOf.values()] });
|
|
560
|
+
return (bid) => {
|
|
561
|
+
const brewery = favs.get('brewery', breweryOf.get(bid));
|
|
562
|
+
return { favorite: favs.get('beer', bid), ...(brewery.is_favorite ? { brewery_favorite: brewery } : {}) };
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
if (usernames && usernames.length > 0) {
|
|
566
|
+
const users = resolveUsers(username, usernames, client.loginName);
|
|
567
|
+
const m = mode ?? DEFAULT_MULTI_USER_MODE;
|
|
568
|
+
const group = await multiHasHad(cache, users, bids, m);
|
|
569
|
+
const favOf = await favoritesFor(users);
|
|
570
|
+
const results = [...group.values()].map((r) => ({ ...r, ...favOf(r.bid) }));
|
|
571
|
+
const qualifying = results.filter((r) => r.qualifies).map((r) => r.bid);
|
|
572
|
+
return minifiedResult({
|
|
573
|
+
usernames: users,
|
|
574
|
+
mode: m,
|
|
575
|
+
checked: results.length,
|
|
576
|
+
qualifying: qualifying.length,
|
|
577
|
+
qualifying_bids: qualifying,
|
|
578
|
+
results,
|
|
579
|
+
freshness: await groupFreshness(cache, users),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const user = resolveUser(username, client.loginName);
|
|
213
583
|
const verdicts = await cache.hasHadMany(user, bids);
|
|
584
|
+
const favOf = await favoritesFor([user]);
|
|
214
585
|
const results = [];
|
|
215
586
|
let hadCount = 0;
|
|
216
587
|
for (const [bid, r] of verdicts) {
|
|
217
588
|
if (r.had)
|
|
218
589
|
hadCount++;
|
|
219
|
-
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating, sources: r.sources });
|
|
590
|
+
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating, sources: r.sources, ...favOf(bid) });
|
|
220
591
|
}
|
|
221
592
|
return minifiedResult({
|
|
222
593
|
username: user,
|
|
@@ -295,10 +666,17 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
295
666
|
'run (~100 calls/hour limit) — if more are needed it returns partial: true / another_run_needed: true, so ' +
|
|
296
667
|
're-running fills the rest. A beer whose refresh is deferred or fails is still ranked on its older cached ' +
|
|
297
668
|
'metadata and marked stale: true. Reports the same freshness/caveat block as untappd_cache_not_had. Omit username ' +
|
|
298
|
-
'for your own account.'
|
|
669
|
+
'for your own account. For a GROUP pass usernames: mode "none_had" (default) ranks only beers new to everyone, ' +
|
|
670
|
+
'"any_not_had" ranks beers at least one of them still needs (each ranked beer lists not_had_by / had_by). ' +
|
|
671
|
+
`prefer_favorites (default true) lifts wishlisted beers (+${WISHLIST_BOOST}) and beers from a favorite brewery ` +
|
|
672
|
+
`(+${FAVORITE_BREWERY_BOOST}) — liked on Untappd, derived or manual, any user's in a group; the wishlist needs ` +
|
|
673
|
+
'untappd_sync_wishlist and Untappd likes untappd_sync_brewery_likes — ' +
|
|
674
|
+
'and says so per beer (score, boosted_by, wishlisted, brewery_favorite/favorite).',
|
|
299
675
|
annotations: toolAnnotations({ title: 'Top-rated beers a user has NOT had, from a candidate list', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
300
676
|
inputSchema: z.object({
|
|
301
677
|
username: UsernameArg,
|
|
678
|
+
usernames: UsernamesArg,
|
|
679
|
+
mode: ModeArg,
|
|
302
680
|
bids: z.array(z.number().int().positive()).min(1).max(100).describe('Candidate beer ids (1–100)'),
|
|
303
681
|
top_n: z.number().int().min(1).max(10).optional().describe('How many top beers to return (default 2, max 10)'),
|
|
304
682
|
style: z
|
|
@@ -313,9 +691,16 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
313
691
|
.max(100)
|
|
314
692
|
.optional()
|
|
315
693
|
.describe('Max beer/info API calls this run for uncached/stale metadata (default 25). Keep modest to respect the ~100 calls/hour rate limit.'),
|
|
694
|
+
prefer_favorites: z
|
|
695
|
+
.boolean()
|
|
696
|
+
.optional()
|
|
697
|
+
.describe(`Boost beers on the wishlist (+${WISHLIST_BOOST}) or from a favorite brewery (+${FAVORITE_BREWERY_BOOST}) when ranking (default true). ` +
|
|
698
|
+
'false ranks by global rating alone.'),
|
|
316
699
|
}),
|
|
317
|
-
}, async ({ username, bids, top_n, style, api_budget }) => {
|
|
318
|
-
const
|
|
700
|
+
}, async ({ username, usernames, mode, bids, top_n, style, api_budget, prefer_favorites }) => {
|
|
701
|
+
const group = usernames !== undefined && usernames.length > 0;
|
|
702
|
+
const users = resolveUsers(username, usernames, client.loginName);
|
|
703
|
+
const groupMode = mode ?? DEFAULT_MULTI_USER_MODE;
|
|
319
704
|
const cache = cacheProvider();
|
|
320
705
|
const topN = top_n ?? 2;
|
|
321
706
|
const quota = clampToQuota(client, api_budget ?? 25);
|
|
@@ -323,9 +708,10 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
323
708
|
const nowMs = Date.now();
|
|
324
709
|
const nowIso = new Date().toISOString();
|
|
325
710
|
// Dedupe candidates, then filter to not-had using the cache only (no API).
|
|
711
|
+
// One user is the group case with one member (both modes mean "not had").
|
|
326
712
|
const candidates = [...new Set(bids)];
|
|
327
|
-
const verdicts = await cache
|
|
328
|
-
const notHad = candidates.filter((bid) =>
|
|
713
|
+
const verdicts = await multiHasHad(cache, users, candidates, groupMode);
|
|
714
|
+
const notHad = candidates.filter((bid) => verdicts.get(bid).qualifies);
|
|
329
715
|
// Read cached metadata; a hit newer than the TTL is used as-is, otherwise
|
|
330
716
|
// the bid needs a beer/info fetch.
|
|
331
717
|
const metaByBid = new Map();
|
|
@@ -390,30 +776,66 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
390
776
|
const s = style.toLowerCase();
|
|
391
777
|
matched = available.filter((m) => (m.style?.toLowerCase().includes(s) ?? false) || (m.parent_style?.toLowerCase().includes(s) ?? false));
|
|
392
778
|
}
|
|
779
|
+
// Favorites (any group member's) and wishlists — cache only.
|
|
780
|
+
const preferFavorites = prefer_favorites ?? true;
|
|
781
|
+
const favs = await favoriteLookup(cache, users, { beer: matched.map((m) => m.bid), brewery: matched.map((m) => m.brewery_id) });
|
|
782
|
+
const wishlisted = new Set();
|
|
783
|
+
for (const u of users)
|
|
784
|
+
for (const w of await cache.getWishlist(u, matched.map((m) => m.bid)))
|
|
785
|
+
wishlisted.add(w.bid);
|
|
786
|
+
const boostsOf = (m) => {
|
|
787
|
+
const out = [];
|
|
788
|
+
if (wishlisted.has(m.bid))
|
|
789
|
+
out.push('wishlist');
|
|
790
|
+
if (favs.isFavorite('brewery', m.brewery_id))
|
|
791
|
+
out.push('favorite_brewery');
|
|
792
|
+
return out;
|
|
793
|
+
};
|
|
794
|
+
const scoreOf = (m) => ratingOf(m) +
|
|
795
|
+
(preferFavorites
|
|
796
|
+
? boostsOf(m).reduce((n, b) => n + (b === 'wishlist' ? WISHLIST_BOOST : FAVORITE_BREWERY_BOOST), 0)
|
|
797
|
+
: 0);
|
|
393
798
|
const ranked = matched
|
|
394
799
|
.slice()
|
|
395
|
-
.sort((a, b) => ratingOf(b) - ratingOf(a))
|
|
800
|
+
.sort((a, b) => scoreOf(b) - scoreOf(a) || ratingOf(b) - ratingOf(a))
|
|
396
801
|
.slice(0, topN)
|
|
397
|
-
.map((m) =>
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
brewery
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
802
|
+
.map((m) => {
|
|
803
|
+
const boosts = preferFavorites ? boostsOf(m) : [];
|
|
804
|
+
const beerFav = favs.get('beer', m.bid);
|
|
805
|
+
const breweryFav = favs.get('brewery', m.brewery_id);
|
|
806
|
+
return {
|
|
807
|
+
bid: m.bid,
|
|
808
|
+
name: m.name,
|
|
809
|
+
brewery: m.brewery,
|
|
810
|
+
style: m.style,
|
|
811
|
+
abv: m.abv,
|
|
812
|
+
rating: m.weighted_rating_score ?? m.rating_score,
|
|
813
|
+
rating_count: m.rating_count,
|
|
814
|
+
...(boosts.length ? { score: Number(scoreOf(m).toFixed(3)), boosted_by: boosts } : {}),
|
|
815
|
+
...(wishlisted.has(m.bid) ? { wishlisted: true } : {}),
|
|
816
|
+
...(beerFav.is_favorite ? { favorite: beerFav } : {}),
|
|
817
|
+
...(breweryFav.is_favorite ? { brewery_favorite: breweryFav } : {}),
|
|
818
|
+
...(staleBids.has(m.bid) ? { stale: true } : {}),
|
|
819
|
+
...(group
|
|
820
|
+
? {
|
|
821
|
+
not_had_by: verdicts.get(m.bid).not_had_by,
|
|
822
|
+
had_by: verdicts.get(m.bid).had_by.map((x) => x.username),
|
|
823
|
+
}
|
|
824
|
+
: {}),
|
|
825
|
+
};
|
|
826
|
+
});
|
|
407
827
|
// Errors leave beers unranked (or ranked on stale data) just like a
|
|
408
828
|
// deferral does, so a re-run is worth it in both cases.
|
|
409
829
|
const partial = deferred > 0 || errors > 0;
|
|
410
830
|
return minifiedResult({
|
|
411
|
-
username:
|
|
831
|
+
...(group ? { usernames: users } : { username: users[0] }),
|
|
412
832
|
ranked,
|
|
413
833
|
summary: {
|
|
834
|
+
...(group ? { mode: groupMode } : {}),
|
|
414
835
|
candidates: candidates.length,
|
|
415
836
|
not_had: notHad.length,
|
|
416
837
|
style_matched: style !== undefined ? matched.length : null,
|
|
838
|
+
prefer_favorites: preferFavorites,
|
|
417
839
|
api_calls_used: apiCalls,
|
|
418
840
|
errors,
|
|
419
841
|
stale: staleMeta.length,
|
|
@@ -422,15 +844,15 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
422
844
|
...(rateLimited ? { rate_limited: true } : {}),
|
|
423
845
|
...quotaFields(quota),
|
|
424
846
|
},
|
|
425
|
-
freshness: await freshness(cache,
|
|
847
|
+
freshness: group ? await groupFreshness(cache, users) : await freshness(cache, users[0]),
|
|
426
848
|
});
|
|
427
849
|
});
|
|
428
850
|
server.registerTool('untappd_cache_forget', {
|
|
429
851
|
title: "Forget a user's cached check-ins",
|
|
430
852
|
description: "Delete everything the LOCAL cache holds for one user — their cached check-ins (beer, rating, comment, venue, " +
|
|
431
|
-
'date), distinct-beers list and sync state — e.g. after syncing a friend you no longer want a history of. Only ' +
|
|
432
|
-
'the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it
|
|
433
|
-
'
|
|
853
|
+
'date), distinct-beers list, venue history, wishlist copy, local favorites, venue drinks and sync state — e.g. after syncing a friend you no longer want a history of. Only ' +
|
|
854
|
+
'the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it (manual favorites ' +
|
|
855
|
+
'cannot be re-fetched). Shared beer, venue and brewery facts are kept. The preview shows the username and exactly how many rows will be removed. Omit username ' +
|
|
434
856
|
`for your own account. ${CONFIRM_FLOW}`,
|
|
435
857
|
annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
|
|
436
858
|
inputSchema: z.object({
|