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/venue.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
6
|
+
export const VERSION = '2.5.0'; // 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.
|
|
3
|
+
"version": "2.5.0",
|
|
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.
|
|
9
|
+
"version": "2.5.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.5.0",
|
|
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)
|