untappd-mcp 2.4.0 → 2.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,18 @@ import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
3
  import { compactCheckins, UNTAPPD_VIEWS, upstreamCompact } from '../compact.js';
4
4
  import { walkVenueMenu } from '../menu/venue-menu.js';
5
- export function registerVenueTools(server, client) {
5
+ import { cachedResponse, VENUE_RESPONSE_TTL_MS } from '../cache/response-cache.js';
6
+ import { optionalCache } from './cache-optional.js';
7
+ import { favoriteLookup } from '../cache/favorites.js';
8
+ const TTL_HOURS = VENUE_RESPONSE_TTL_MS / 3_600_000;
9
+ const RefreshArg = z
10
+ .boolean()
11
+ .optional()
12
+ .describe(`Bypass the ${TTL_HOURS}-hour local cache and call Untappd (default false)`);
13
+ export function registerVenueTools(server, client, cacheProvider) {
14
+ const cacheOrNone = optionalCache(cacheProvider);
15
+ /** The `cache` block a cached result carries (only when a cache is in use). */
16
+ const cacheBlock = (r) => r.expires_at === null ? {} : { cache: { hit: r.hit, fetched_at: r.fetched_at, expires_at: r.expires_at } };
6
17
  server.registerTool('untappd_search_venue', {
7
18
  title: 'Search Untappd venues',
8
19
  description: 'Search Untappd for venues (bars, breweries, restaurants) by name. Returns matches with their venue id, ' +
@@ -37,10 +48,15 @@ export function registerVenueTools(server, client) {
37
48
  'spans multiple sections — e.g. a 23-beer wall that comes back with 2 items. This tool forwards the ' +
38
49
  'section_limit / section_offset paging params venue/info echoes back but never receives, walks sections up to a ' +
39
50
  'per-call max_pages budget (respecting the ~100 calls/hour limit — it does NOT loop to completion in one call), and ' +
40
- 'flattens to [{bid, name, brewery, style, abv, price, serving_type, menu, section}]. Like the sync tools it is ' +
51
+ 'flattens to [{bid, name, brewery, brewery_id, style, abv, price, serving_type, menu, section}]. Like the sync tools it is ' +
41
52
  'resumable: when the budget runs out before full coverage it returns another_run_needed:true plus next_section_offset ' +
42
53
  'to pass back on the next call. truncated:true means the upstream returned no more sections short of total_count ' +
43
- '(e.g. it ignored the paging params) — not resumable. Get an id from untappd_search_venue. Read-only.',
54
+ '(e.g. it ignored the paging params) — not resumable. Results are cached locally for ' + TTL_HOURS + ' hours per ' +
55
+ 'venue + paging params (cache.hit, fetched_at, expires_at) so repeated trip planning does not re-spend calls; ' +
56
+ 'refresh:true re-fetches. With the cache, the result also carries favorite {is_favorite, sources, reason} for the ' +
57
+ 'venue, and favorite / brewery_favorite on each beer that is one (derived from username\'s history or added ' +
58
+ 'with untappd_favorite_add; default your account) — applied on read, so never stale. Get an id from ' +
59
+ 'untappd_search_venue. Read-only.',
44
60
  annotations: toolAnnotations({ title: "Get a venue's verified beer menu (section-paged)", readOnly: true, idempotent: true, openWorld: true }),
45
61
  inputSchema: z.object({
46
62
  venue_id: z.number().int().positive().describe('Untappd venue id'),
@@ -49,40 +65,72 @@ export function registerVenueTools(server, client) {
49
65
  section_offset: z.number().int().min(0).optional().describe('Section offset to start from; pass a prior next_section_offset to resume (default 0).'),
50
66
  max_pages: z.number().int().min(1).max(10).optional().describe('API calls to spend THIS run — page budget, not page size (default 3). Resume with next_section_offset if another_run_needed.'),
51
67
  sort: z.string().optional().describe("Menu sort key (e.g. 'publish_order', 'highest_rated'). Optional."),
68
+ refresh: RefreshArg,
69
+ username: z
70
+ .string()
71
+ .min(1)
72
+ .optional()
73
+ .describe('Whose favorites to mark (cache only; default your configured account)'),
52
74
  }),
53
- }, async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort }) => {
54
- // Per CLAUDE.md's rate-limit design this spends at most `max_pages` API
55
- // calls per run and hands the caller next_section_offset to resume, rather
56
- // than looping to completion. The walk itself lives in walkVenueMenu.
57
- const startOffset = section_offset ?? 0;
58
- const walk = await walkVenueMenu(client, venue_id, {
59
- menuId: menu_id,
60
- sectionLimit: section_limit,
61
- sectionOffset: startOffset,
62
- maxPages: max_pages ?? 3,
63
- sort,
64
- });
65
- const { totalCount, sawMenu, pagesFetched, reachedEnd } = walk;
66
- const beers = walk.beers.map(({ rawBeer: _b, rawBrewery: _w, ...b }) => b);
67
- if (!sawMenu) {
68
- return minifiedResult({ venue_id, total_count: 0, returned: 0, pages_fetched: pagesFetched, another_run_needed: false, truncated: false, beers: [], note: 'No verified menu on this venue.' });
69
- }
70
- const covered = totalCount > 0 && beers.length >= totalCount;
71
- const another_run_needed = !reachedEnd; // stopped only because the page budget ran out mid-list
72
- // Only a walk that started at the top of the list can judge a genuine
73
- // shortfall as truncated; a resumed tail legitimately returns fewer than
74
- // the whole-menu total_count and is not truncated.
75
- const truncated = reachedEnd && !covered && startOffset === 0 && totalCount > 0;
75
+ }, async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort, refresh, username }) => {
76
+ const key = `venue_menu:${venue_id}:${JSON.stringify([menu_id ?? null, section_limit ?? null, section_offset ?? 0, max_pages ?? 3, sort ?? null])}`;
77
+ const cache = cacheOrNone();
78
+ const r = await cachedResponse(cache, key, 'venue_menu', () => fetchMenu(), { refresh });
79
+ // Favorites are applied on read (not cached), so a change shows even on a cache hit.
80
+ const user = username ?? client.loginName;
81
+ if (!cache || !user)
82
+ return minifiedResult({ ...r.data, ...cacheBlock(r) });
83
+ const beers = (Array.isArray(r.data.beers) ? r.data.beers : []);
84
+ const ids = (k) => beers.map((b) => b[k]).filter((x) => typeof x === 'number');
85
+ const favs = await favoriteLookup(cache, [user], { venue: [venue_id], beer: ids('bid'), brewery: ids('brewery_id') });
76
86
  return minifiedResult({
77
- venue_id,
78
- total_count: totalCount,
79
- returned: beers.length,
80
- pages_fetched: pagesFetched,
81
- another_run_needed,
82
- ...(another_run_needed ? { next_section_offset: walk.nextOffset } : {}),
83
- truncated,
84
- beers,
87
+ ...r.data,
88
+ favorite: favs.get('venue', venue_id),
89
+ beers: beers.map((b) => {
90
+ const beerFav = favs.get('beer', b.bid);
91
+ const breweryFav = favs.get('brewery', b.brewery_id);
92
+ return {
93
+ ...b,
94
+ ...(beerFav.is_favorite ? { favorite: beerFav } : {}),
95
+ ...(breweryFav.is_favorite ? { brewery_favorite: breweryFav } : {}),
96
+ };
97
+ }),
98
+ ...cacheBlock(r),
85
99
  });
100
+ async function fetchMenu() {
101
+ // Per CLAUDE.md's rate-limit design this spends at most `max_pages` API
102
+ // calls per run and hands the caller next_section_offset to resume, rather
103
+ // than looping to completion. The walk itself lives in walkVenueMenu.
104
+ const startOffset = section_offset ?? 0;
105
+ const walk = await walkVenueMenu(client, venue_id, {
106
+ menuId: menu_id,
107
+ sectionLimit: section_limit,
108
+ sectionOffset: startOffset,
109
+ maxPages: max_pages ?? 3,
110
+ sort,
111
+ });
112
+ const { totalCount, sawMenu, pagesFetched, reachedEnd } = walk;
113
+ const beers = walk.beers.map(({ rawBeer: _b, rawBrewery: w, ...b }) => typeof w?.brewery_id === 'number' ? { ...b, brewery_id: w.brewery_id } : b);
114
+ if (!sawMenu) {
115
+ return { venue_id, total_count: 0, returned: 0, pages_fetched: pagesFetched, another_run_needed: false, truncated: false, beers: [], note: 'No verified menu on this venue.' };
116
+ }
117
+ const covered = totalCount > 0 && beers.length >= totalCount;
118
+ const another_run_needed = !reachedEnd; // stopped only because the page budget ran out mid-list
119
+ // Only a walk that started at the top of the list can judge a genuine
120
+ // shortfall as truncated; a resumed tail legitimately returns fewer than
121
+ // the whole-menu total_count and is not truncated.
122
+ const truncated = reachedEnd && !covered && startOffset === 0 && totalCount > 0;
123
+ return {
124
+ venue_id,
125
+ total_count: totalCount,
126
+ returned: beers.length,
127
+ pages_fetched: pagesFetched,
128
+ another_run_needed,
129
+ ...(another_run_needed ? { next_section_offset: walk.nextOffset } : {}),
130
+ truncated,
131
+ beers,
132
+ };
133
+ }
86
134
  });
87
135
  server.registerTool('untappd_venue_by_foursquare', {
88
136
  title: 'Look up an Untappd venue by Foursquare id',
@@ -99,17 +147,23 @@ export function registerVenueTools(server, client) {
99
147
  server.registerTool('untappd_venue_activity', {
100
148
  title: 'Get recent check-ins at a venue',
101
149
  description: 'Get the recent public check-ins at a venue by its id — who was there, what they drank, and their ratings. ' +
102
- 'Page backwards with max_id (the pagination.max_id from a prior call). Read-only.',
150
+ 'Page backwards with max_id (the pagination.max_id from a prior call). Cached locally for ' + TTL_HOURS + ' hours ' +
151
+ 'per venue + limit + max_id (cache.hit, fetched_at, expires_at); refresh:true re-fetches. Read-only.',
103
152
  annotations: toolAnnotations({ title: 'Get recent check-ins at a venue', readOnly: true, idempotent: false, openWorld: true }),
104
153
  inputSchema: z.object({
105
154
  venue_id: z.number().int().positive().describe('Untappd venue id'),
106
155
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
107
156
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
157
+ refresh: RefreshArg,
108
158
  view: viewParam(UNTAPPD_VIEWS, { note: 'compact projects each check-in to {id, user, beer, brewery, venue, rating, comment, toast/comment counts}; "full" returns Untappd\'s whole ~5 KB record.' }),
109
159
  }),
110
- }, async ({ venue_id, limit, max_id, view }) => {
111
- const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
160
+ }, async ({ venue_id, limit, max_id, refresh, view }) => {
161
+ // The RAW response is cached, so either view can be served from one entry.
162
+ const key = `venue_activity:${venue_id}:${limit ?? ''}:${max_id ?? ''}`;
163
+ const r = await cachedResponse(cacheOrNone(), key, 'venue_activity', () => client.get(`/venue/checkins/${venue_id}`, { limit, max_id }), { refresh });
112
164
  const v = resolveView(view, UNTAPPD_VIEWS);
113
- return viewResult(v, v === 'compact' ? compactCheckins(data) : data);
165
+ const shaped = v === 'compact' ? compactCheckins(r.data) : r.data;
166
+ const extra = cacheBlock(r);
167
+ return viewResult(v, Object.keys(extra).length && shaped && typeof shaped === 'object' ? { ...shaped, ...extra } : shaped);
114
168
  });
115
169
  }
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@
3
3
  // json's `extra-files`), and `versionSyncTest` guards that it stays equal to
4
4
  // package.json. Import VERSION wherever the version is needed rather than
5
5
  // re-declaring it.
6
- export const VERSION = '2.4.0'; // x-release-please-version
6
+ export const VERSION = '2.5.1'; // x-release-please-version
package/mint.yaml CHANGED
@@ -43,6 +43,11 @@ env:
43
43
  help: >-
44
44
  IANA timezone (e.g. America/New_York) that check-ins are stamped with
45
45
  when a call passes no timezone. Defaults to the server's own zone.
46
+ - name: UNTAPPD_RATE_LIMIT_PER_HOUR
47
+ required: false
48
+ help: >-
49
+ Hourly Untappd API call limit the syncs' stop-before-429 guard enforces
50
+ (default 100, Untappd's documented per-token limit).
46
51
  - name: UNTAPPD_PHOTO_DIR
47
52
  required: false
48
53
  help: >-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "mcpName": "io.github.chrischall/untappd-mcp",
5
5
  "description": "Untappd MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/untappd-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.4.0",
9
+ "version": "2.5.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.4.0",
14
+ "version": "2.5.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -79,6 +79,13 @@
79
79
  "format": "string",
80
80
  "isSecret": false
81
81
  },
82
+ {
83
+ "name": "UNTAPPD_RATE_LIMIT_PER_HOUR",
84
+ "description": "Hourly Untappd API call limit the syncs' stop-before-429 guard enforces (default 100, Untappd's documented per-token limit). Only change it if Untappd gives your token a different limit.",
85
+ "isRequired": false,
86
+ "format": "string",
87
+ "isSecret": false
88
+ },
82
89
  {
83
90
  "name": "UNTAPPD_PHOTO_DIR",
84
91
  "description": "The directory untappd_checkin's photo_path must be inside (several allowed, separated by :). Defaults to ~/Downloads/untappd-mcp — put a photo there to attach it. Any other file is refused, so a prompt-injected shout or comment can't publish an arbitrary image from your disk.",
@@ -205,6 +205,36 @@ Query tools (has-had ones consult BOTH sources — a hit in either = had):
205
205
  capped at `api_budget` per run (partial/another_run_needed when exceeded).
206
206
  - `untappd_cache_query` — filter cached check-ins by brewery, style, `min_rating`,
207
207
  venue, and date range.
208
+ - **Groups:** `untappd_cache_has_had_many` and `untappd_top_not_had` take
209
+ `usernames` (instead of `username`) plus `mode`: `none_had` (default — new to
210
+ everyone) or `any_not_had` (at least one of them still needs it). Each beer
211
+ lists `had_by` / `not_had_by`; freshness is reported per user.
212
+ - `untappd_sync_venue_drinks` — opt-in, costs one call per check-in: fetches the
213
+ beer from each cached venue's first/recent visit (`checkin/view`), budgeted by
214
+ `max_calls`; `untappd_cache_has_visited` then shows them as `drinks`. Partial
215
+ by design (two check-ins per venue at most).
216
+ - **Favorites** (local, no API): `untappd_favorites_list` (filter `kind`
217
+ beer|brewery|venue, `source` derived|manual|untappd, override `thresholds`),
218
+ `untappd_favorite_add` / `untappd_favorite_remove` (manual, with a note —
219
+ never written to Untappd). Derived defaults: venue ≥5 check-ins or ≥3 distinct
220
+ months; brewery ≥5 beers with avg ≥3.75, or ≥15 beers; beer rated ≥4.25 or
221
+ had ≥3 times. has_had / has_had_many / has_visited / brewery_beers / venue_menu /
222
+ top_not_had results carry `favorite` {is_favorite, sources, reason};
223
+ `untappd_top_not_had` boosts wishlisted (+0.5) and favorite-brewery (+0.25)
224
+ beers unless `prefer_favorites: false`. `untappd_sync_brewery_likes` mirrors
225
+ the breweries a user likes/follows on Untappd (resumable, rate-guarded); they
226
+ carry the source `untappd` (merged with derived/manual) and count for the boost.
227
+ Untappd has no followed-venue list, so venue favorites are derived + manual.
228
+ `brewery_info`'s `follow_status` is the signed-in account's, never another user's.
229
+ - `untappd_trip_check` — a whole trip in one call: per stop (`venue_id`, `name`
230
+ + `city`/`state`, `lat`/`lng`, and/or `brewery_id`) → visited with dates,
231
+ beers had from that brewery, and the top N (default 4) not-had beers by global
232
+ rating (`style` filter). Cache only, except `brewery/beer_list` for a missing or
233
+ >7-day-old list (`api_budget`, default 5; 0 = cache only). Flags favorite
234
+ venues/breweries, boosts wishlist/favorite-brewery beers (`prefer_favorites`),
235
+ and hints when a favorite venue is within `near_favorite_radius_m` (2 km).
236
+ Sync first: `untappd_sync_user_venues`, `untappd_sync_user_beers`,
237
+ `untappd_sync_wishlist`, `untappd_sync_brewery_likes`.
208
238
 
209
239
  **Retention.** The cache keeps what you sync until you remove it — including
210
240
  friends' dated check-ins, comments and venues. `untappd_cache_forget` (confirmation-gated)