untappd-mcp 2.3.2 → 2.4.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 +112 -4
- package/dist/bundle.js +1186 -133
- package/dist/cache/db.js +100 -10
- package/dist/cache/store.js +224 -15
- package/dist/cache/sync-beers.js +27 -5
- package/dist/client.js +24 -0
- package/dist/index.js +8 -2
- package/dist/menu/match.js +178 -0
- package/dist/menu/pick.js +514 -0
- package/dist/menu/venue-menu.js +108 -0
- package/dist/quota.js +14 -0
- package/dist/tools/beer.js +4 -1
- package/dist/tools/cache.js +54 -32
- package/dist/tools/menu.js +104 -0
- package/dist/tools/venue.js +14 -92
- package/dist/version.js +1 -1
- package/mint.yaml +12 -0
- package/package.json +5 -1
- package/server.json +9 -2
package/dist/tools/cache.js
CHANGED
|
@@ -4,16 +4,17 @@ import { beerMetaFrom } from '../cache/store.js';
|
|
|
4
4
|
import { syncCheckins } from '../cache/sync.js';
|
|
5
5
|
import { syncUserBeers } from '../cache/sync-beers.js';
|
|
6
6
|
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
7
|
+
import { clampToQuota } from '../quota.js';
|
|
7
8
|
// Re-fetch cached beer metadata at most this often; a hit newer than this skips
|
|
8
9
|
// the beer/info API call.
|
|
9
10
|
const BEER_META_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
|
-
const UsernameArg = z
|
|
11
|
+
export const UsernameArg = z
|
|
11
12
|
.string()
|
|
12
13
|
.min(1)
|
|
13
14
|
.optional()
|
|
14
15
|
.describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
|
|
15
16
|
/** Resolve the target username (raw, un-encoded) — explicit arg else configured login. */
|
|
16
|
-
function resolveUser(username, loginName) {
|
|
17
|
+
export function resolveUser(username, loginName) {
|
|
17
18
|
const u = username ?? loginName;
|
|
18
19
|
if (!u) {
|
|
19
20
|
throw createHelpfulError('No username given and no configured account to fall back to.', {
|
|
@@ -22,6 +23,12 @@ function resolveUser(username, loginName) {
|
|
|
22
23
|
}
|
|
23
24
|
return u;
|
|
24
25
|
}
|
|
26
|
+
/** Report the quota only when Untappd told us it (and say when it cut the budget). */
|
|
27
|
+
export function quotaFields(q) {
|
|
28
|
+
if (q.quota_remaining === null)
|
|
29
|
+
return {};
|
|
30
|
+
return { quota_remaining: q.quota_remaining, ...(q.clamped ? { budget_clamped_to_quota: true } : {}) };
|
|
31
|
+
}
|
|
25
32
|
function pct(cached, total, complete) {
|
|
26
33
|
if (total && total > 0)
|
|
27
34
|
return Math.min(100, Math.round((cached / total) * 100));
|
|
@@ -33,7 +40,7 @@ function pct(cached, total, complete) {
|
|
|
33
40
|
* by the API for non-self accounts) and the user/beers distinct-beers sync
|
|
34
41
|
* (complete has-had coverage) — so a caller knows exactly which caveats apply.
|
|
35
42
|
*/
|
|
36
|
-
async function freshness(cache, username) {
|
|
43
|
+
export async function freshness(cache, username) {
|
|
37
44
|
const state = await cache.getState(username);
|
|
38
45
|
const cachedCheckins = await cache.cachedCount(username);
|
|
39
46
|
const cachedBeers = await cache.distinctBeersCount(username);
|
|
@@ -84,6 +91,29 @@ async function freshness(cache, username) {
|
|
|
84
91
|
* platform-specific code.
|
|
85
92
|
*/
|
|
86
93
|
export function registerCacheTools(server, client, cacheProvider) {
|
|
94
|
+
server.registerTool('untappd_cache_status', {
|
|
95
|
+
title: 'Show the local cache location, schema and per-user coverage',
|
|
96
|
+
description: 'Diagnostics for the LOCAL check-in cache — NO API call. Returns the resolved cache file path, its size on ' +
|
|
97
|
+
'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, coverage_complete, the ' +
|
|
99
|
+
'resume offset of an unfinished user/beers backfill, and last_synced_at. Use it to confirm the cache ' +
|
|
100
|
+
'persisted across restarts or to see how far a backfill has got.',
|
|
101
|
+
annotations: toolAnnotations({ title: 'Show the local cache location, schema and per-user coverage', readOnly: true, idempotent: true, openWorld: false }),
|
|
102
|
+
inputSchema: z.object({}),
|
|
103
|
+
}, async () => {
|
|
104
|
+
const cache = cacheProvider();
|
|
105
|
+
const stats = await cache.stats();
|
|
106
|
+
// The file details only exist for a file-backed store (the node:sqlite
|
|
107
|
+
// CheckinCache); another backend reports the logical stats alone.
|
|
108
|
+
const file = cache.fileInfo?.();
|
|
109
|
+
return minifiedResult({
|
|
110
|
+
...(file ? { cache_path: file.path, file_size_bytes: file.bytes, journal_mode: file.journal_mode } : {}),
|
|
111
|
+
schema_version: stats.schema_version,
|
|
112
|
+
resolution_cache_entries: stats.resolution_cache_entries,
|
|
113
|
+
beer_meta_entries: stats.beer_meta_entries,
|
|
114
|
+
users: stats.users,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
87
117
|
server.registerTool('untappd_sync_checkins', {
|
|
88
118
|
title: 'Sync a user\'s check-ins into the cache',
|
|
89
119
|
description: "Fetch a user's detailed check-ins (venue, date, comment) into the cache from user/checkins. Incremental and " +
|
|
@@ -110,11 +140,14 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
110
140
|
}),
|
|
111
141
|
}, async ({ username, max_pages, force_backfill }) => {
|
|
112
142
|
const user = resolveUser(username, client.loginName);
|
|
143
|
+
// Floor of 1: the shared catch-up/backfill page split (sync.ts) is only
|
|
144
|
+
// defined for a budget of at least one page.
|
|
145
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
113
146
|
const summary = await syncCheckins(client, cacheProvider(), user, {
|
|
114
|
-
maxPages:
|
|
147
|
+
maxPages: quota.budget,
|
|
115
148
|
force: force_backfill ?? false,
|
|
116
149
|
});
|
|
117
|
-
return minifiedResult(summary);
|
|
150
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
118
151
|
});
|
|
119
152
|
server.registerTool('untappd_sync_user_beers', {
|
|
120
153
|
title: "Sync a user's complete distinct-beers list into the cache",
|
|
@@ -137,8 +170,9 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
137
170
|
}),
|
|
138
171
|
}, async ({ username, max_pages }) => {
|
|
139
172
|
const user = resolveUser(username, client.loginName);
|
|
140
|
-
const
|
|
141
|
-
|
|
173
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
174
|
+
const summary = await syncUserBeers(client, cacheProvider(), user, quota.budget);
|
|
175
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
142
176
|
});
|
|
143
177
|
server.registerTool('untappd_cache_has_had', {
|
|
144
178
|
title: 'Check if a user has had a beer (from the cache)',
|
|
@@ -176,23 +210,19 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
176
210
|
}, async ({ username, bids }) => {
|
|
177
211
|
const user = resolveUser(username, client.loginName);
|
|
178
212
|
const cache = cacheProvider();
|
|
179
|
-
const
|
|
213
|
+
const verdicts = await cache.hasHadMany(user, bids);
|
|
180
214
|
const results = [];
|
|
181
215
|
let hadCount = 0;
|
|
182
|
-
for (const bid of
|
|
183
|
-
if (seen.has(bid))
|
|
184
|
-
continue;
|
|
185
|
-
seen.add(bid);
|
|
186
|
-
const r = await cache.hasHad(user, { bid });
|
|
216
|
+
for (const [bid, r] of verdicts) {
|
|
187
217
|
if (r.had)
|
|
188
218
|
hadCount++;
|
|
189
219
|
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating, sources: r.sources });
|
|
190
220
|
}
|
|
191
221
|
return minifiedResult({
|
|
192
222
|
username: user,
|
|
193
|
-
checked:
|
|
223
|
+
checked: verdicts.size,
|
|
194
224
|
had: hadCount,
|
|
195
|
-
not_had:
|
|
225
|
+
not_had: verdicts.size - hadCount,
|
|
196
226
|
results,
|
|
197
227
|
freshness: await freshness(cache, user),
|
|
198
228
|
});
|
|
@@ -212,21 +242,14 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
212
242
|
}, async ({ username, bids }) => {
|
|
213
243
|
const user = resolveUser(username, client.loginName);
|
|
214
244
|
const cache = cacheProvider();
|
|
215
|
-
const
|
|
245
|
+
const verdicts = await cache.hasHadMany(user, bids);
|
|
216
246
|
const notHad = [];
|
|
217
247
|
const had = [];
|
|
218
|
-
for (const bid of
|
|
219
|
-
|
|
220
|
-
continue;
|
|
221
|
-
seen.add(bid);
|
|
222
|
-
if ((await cache.hasHad(user, { bid })).had)
|
|
223
|
-
had.push(bid);
|
|
224
|
-
else
|
|
225
|
-
notHad.push(bid);
|
|
226
|
-
}
|
|
248
|
+
for (const [bid, r] of verdicts)
|
|
249
|
+
(r.had ? had : notHad).push(bid);
|
|
227
250
|
return minifiedResult({
|
|
228
251
|
username: user,
|
|
229
|
-
checked:
|
|
252
|
+
checked: verdicts.size,
|
|
230
253
|
not_had_count: notHad.length,
|
|
231
254
|
had_count: had.length,
|
|
232
255
|
not_had: notHad,
|
|
@@ -295,16 +318,14 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
295
318
|
const user = resolveUser(username, client.loginName);
|
|
296
319
|
const cache = cacheProvider();
|
|
297
320
|
const topN = top_n ?? 2;
|
|
298
|
-
const
|
|
321
|
+
const quota = clampToQuota(client, api_budget ?? 25);
|
|
322
|
+
const budget = quota.budget;
|
|
299
323
|
const nowMs = Date.now();
|
|
300
324
|
const nowIso = new Date().toISOString();
|
|
301
325
|
// Dedupe candidates, then filter to not-had using the cache only (no API).
|
|
302
326
|
const candidates = [...new Set(bids)];
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
if (!(await cache.hasHad(user, { bid })).had)
|
|
306
|
-
notHad.push(bid);
|
|
307
|
-
}
|
|
327
|
+
const verdicts = await cache.hasHadMany(user, candidates);
|
|
328
|
+
const notHad = candidates.filter((bid) => !verdicts.get(bid).had);
|
|
308
329
|
// Read cached metadata; a hit newer than the TTL is used as-is, otherwise
|
|
309
330
|
// the bid needs a beer/info fetch.
|
|
310
331
|
const metaByBid = new Map();
|
|
@@ -399,6 +420,7 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
399
420
|
partial,
|
|
400
421
|
another_run_needed: partial,
|
|
401
422
|
...(rateLimited ? { rate_limited: true } : {}),
|
|
423
|
+
...quotaFields(quota),
|
|
402
424
|
},
|
|
403
425
|
freshness: await freshness(cache, user),
|
|
404
426
|
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { menuPick, venuePick } from '../menu/pick.js';
|
|
4
|
+
import { freshness, quotaFields, resolveUser, UsernameArg } from './cache.js';
|
|
5
|
+
import { clampToQuota } from '../quota.js';
|
|
6
|
+
import { parseUntappdUrl } from './resolve.js';
|
|
7
|
+
const StyleFilter = z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('Case-insensitive substring filter on the beer style or parent style for the picks (e.g. "ipa", "stout").');
|
|
12
|
+
const TopN = z.number().int().min(1).max(10).optional().describe('How many picks to return (default 2, max 10).');
|
|
13
|
+
const ApiBudget = z
|
|
14
|
+
.number()
|
|
15
|
+
.int()
|
|
16
|
+
.min(1)
|
|
17
|
+
.max(100)
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Max Untappd API calls this run, shared by the cache sync, searches and rating lookups (default 60; Untappd allows ~100/hour). ' +
|
|
20
|
+
'If it runs out, the result says another_run_needed and a re-run continues from what was cached.');
|
|
21
|
+
/** Parse a venue argument: a bare id, a numeric string, or an untappd.com/v/<slug>/<id> URL. */
|
|
22
|
+
export function parseVenueArg(v) {
|
|
23
|
+
if (typeof v === 'number')
|
|
24
|
+
return v;
|
|
25
|
+
const t = v.trim();
|
|
26
|
+
if (/^\d+$/.test(t))
|
|
27
|
+
return Number(t);
|
|
28
|
+
const r = parseUntappdUrl(t);
|
|
29
|
+
if (r?.type === 'venue' && r.id)
|
|
30
|
+
return r.id;
|
|
31
|
+
throw createHelpfulError(`"${v}" is not an Untappd venue id or venue URL.`, {
|
|
32
|
+
hint: 'Pass the numeric venue id, or a URL like https://untappd.com/v/<slug>/<venue_id>. untappd_search_venue finds ids by name.',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
export function registerMenuTools(server, client, cacheProvider, deps = {}) {
|
|
36
|
+
server.registerTool('untappd_menu_pick', {
|
|
37
|
+
title: "Pick beers off a tap list that a user hasn't had",
|
|
38
|
+
description: 'One call from a tap list to "order this": give the raw menu lines as read from a photo and get back the ' +
|
|
39
|
+
'top-rated beers the user has NOT had. It (1) refreshes the user\'s distinct-beers cache — an incremental ' +
|
|
40
|
+
'refresh when the cache is complete, otherwise it backfills automatically within api_budget, no separate ' +
|
|
41
|
+
'sync needed; (2) resolves each line to an Untappd beer (prices/punctuation stripped, scored on brewery + ' +
|
|
42
|
+
'name match and check-in volume, venue_city as tie-breaker; resolutions are cached locally so repeat venues ' +
|
|
43
|
+
'are instant); (3) treats "<Brewery> SEASONAL/ROTATING" as a list of possible beers (needs_confirmation) ' +
|
|
44
|
+
'and "ROTATING CIDER"-style lines as unresolved ("ask bartender"); (4) checks every beer against the cache; ' +
|
|
45
|
+
'(5) ranks the not-had ones by global rating. Returns picks, not_had, had, possible, unresolved, a sync ' +
|
|
46
|
+
'block (what the refresh did) and freshness (coverage); if coverage is still incomplete a "not had" may be ' +
|
|
47
|
+
'a false negative and another_run_needed is true. api_budget is also capped to the quota Untappd reports, ' +
|
|
48
|
+
'when it reports one. Omit username for your own account.',
|
|
49
|
+
annotations: toolAnnotations({ title: "Pick beers off a tap list that a user hasn't had", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
50
|
+
inputSchema: z.object({
|
|
51
|
+
username: UsernameArg,
|
|
52
|
+
items: z
|
|
53
|
+
.array(z.string().min(1).max(200))
|
|
54
|
+
.min(1)
|
|
55
|
+
.max(60)
|
|
56
|
+
.describe('Raw menu lines as read from the menu, e.g. ["JACKALOPE FENNARIO IPA $7", "BLACK ABBEY SEASONAL", "ROTATING CIDER"].'),
|
|
57
|
+
style_filter: StyleFilter,
|
|
58
|
+
top_n: TopN,
|
|
59
|
+
venue_city: z.string().min(1).optional().describe('City of the venue (e.g. "Nashville") — breaks ties between same-named beers toward local breweries.'),
|
|
60
|
+
api_budget: ApiBudget,
|
|
61
|
+
refresh_resolutions: z.boolean().optional().describe('Ignore cached line→beer matches and re-search every line (default false).'),
|
|
62
|
+
}),
|
|
63
|
+
}, async ({ username, items, style_filter, top_n, venue_city, api_budget, refresh_resolutions }) => {
|
|
64
|
+
const user = resolveUser(username, client.loginName);
|
|
65
|
+
const cache = cacheProvider();
|
|
66
|
+
const quota = clampToQuota(client, api_budget ?? 60);
|
|
67
|
+
const out = await menuPick(client, cache, {
|
|
68
|
+
username: user,
|
|
69
|
+
items,
|
|
70
|
+
styleFilter: style_filter,
|
|
71
|
+
topN: top_n ?? 2,
|
|
72
|
+
apiBudget: quota.budget,
|
|
73
|
+
venueCity: venue_city,
|
|
74
|
+
refreshResolutions: refresh_resolutions,
|
|
75
|
+
}, deps);
|
|
76
|
+
return minifiedResult({ username: user, ...out, ...quotaFields(quota), freshness: await freshness(cache, user) });
|
|
77
|
+
});
|
|
78
|
+
server.registerTool('untappd_venue_pick', {
|
|
79
|
+
title: "Pick beers off a venue's Untappd menu that a user hasn't had",
|
|
80
|
+
description: 'Like untappd_menu_pick, but takes an Untappd venue (URL or id) instead of menu lines: it pulls the venue\'s ' +
|
|
81
|
+
'FULL verified menu (walking every section page until complete, within api_budget), refreshes the user\'s ' +
|
|
82
|
+
'distinct-beers cache (incremental, or an automatic backfill), checks every menu beer against it and ranks ' +
|
|
83
|
+
'the not-had ones by global rating. Returns the same picks / not_had / had / sync shape plus a venue block ' +
|
|
84
|
+
'(menu_complete is false only if the budget ran out mid-menu). A venue with no verified menu comes back ' +
|
|
85
|
+
'unresolved — use untappd_menu_pick with the tap list instead. Omit username for your own account.',
|
|
86
|
+
annotations: toolAnnotations({ title: "Pick beers off a venue's Untappd menu that a user hasn't had", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
87
|
+
inputSchema: z.object({
|
|
88
|
+
venue: z
|
|
89
|
+
.union([z.string().min(1), z.number().int().positive()])
|
|
90
|
+
.describe('Untappd venue URL (https://untappd.com/v/<slug>/<id>) or numeric venue id.'),
|
|
91
|
+
username: UsernameArg,
|
|
92
|
+
style_filter: StyleFilter,
|
|
93
|
+
top_n: TopN,
|
|
94
|
+
api_budget: ApiBudget,
|
|
95
|
+
}),
|
|
96
|
+
}, async ({ venue, username, style_filter, top_n, api_budget }) => {
|
|
97
|
+
const venueId = parseVenueArg(venue);
|
|
98
|
+
const user = resolveUser(username, client.loginName);
|
|
99
|
+
const cache = cacheProvider();
|
|
100
|
+
const quota = clampToQuota(client, api_budget ?? 60);
|
|
101
|
+
const out = await venuePick(client, cache, { username: user, venueId, styleFilter: style_filter, topN: top_n ?? 2, apiBudget: quota.budget }, deps);
|
|
102
|
+
return minifiedResult({ username: user, ...out, ...quotaFields(quota), freshness: await freshness(cache, user) });
|
|
103
|
+
});
|
|
104
|
+
}
|
package/dist/tools/venue.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { walkVenueMenu } from '../menu/venue-menu.js';
|
|
4
5
|
export function registerVenueTools(server, client) {
|
|
5
6
|
server.registerTool('untappd_search_venue', {
|
|
6
7
|
title: 'Search Untappd venues',
|
|
@@ -50,98 +51,19 @@ export function registerVenueTools(server, client) {
|
|
|
50
51
|
sort: z.string().optional().describe("Menu sort key (e.g. 'publish_order', 'highest_rated'). Optional."),
|
|
51
52
|
}),
|
|
52
53
|
}, async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort }) => {
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// space; the real param name is the clean one the web menu UI sends). We
|
|
57
|
-
// forward them and walk sections, deduping by menu+section+bid so overlapping
|
|
58
|
-
// or param-ignoring pages can't double-count. Per CLAUDE.md's rate-limit
|
|
59
|
-
// design this spends at most `max_pages` API calls per run and hands the
|
|
60
|
-
// caller next_section_offset to resume, rather than looping to completion.
|
|
61
|
-
//
|
|
62
|
-
// Termination is driven by SECTION availability, not a running beer count:
|
|
63
|
-
// a page that returns fewer sections than we asked for is the end of the
|
|
64
|
-
// list. That's what makes resuming correct — this tool is stateless across
|
|
65
|
-
// calls, so on a resumed tail (section_offset > 0) `beers.length` covers
|
|
66
|
-
// only this run and can never equal the whole-menu total, which would
|
|
67
|
-
// otherwise mislabel a finished tail as truncated.
|
|
68
|
-
const pageSize = section_limit ?? 50;
|
|
69
|
-
const budget = max_pages ?? 3;
|
|
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.
|
|
70
57
|
const startOffset = section_offset ?? 0;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
section_limit: pageSize,
|
|
81
|
-
section_offset: offset,
|
|
82
|
-
menu_id,
|
|
83
|
-
sort,
|
|
84
|
-
});
|
|
85
|
-
pagesFetched++;
|
|
86
|
-
const vb = data?.venue?.verfied_beers;
|
|
87
|
-
if (!vb) {
|
|
88
|
-
reachedEnd = true; // no menu payload — nothing more to page
|
|
89
|
-
break;
|
|
90
|
-
}
|
|
91
|
-
sawMenu = true;
|
|
92
|
-
let added = 0;
|
|
93
|
-
let matchedItemCount = 0;
|
|
94
|
-
let sectionsReturned = 0;
|
|
95
|
-
for (const wrap of vb.items ?? []) {
|
|
96
|
-
const menu = wrap?.menu;
|
|
97
|
-
if (!menu)
|
|
98
|
-
continue;
|
|
99
|
-
if (menu_id && menu.menu_id !== menu_id)
|
|
100
|
-
continue;
|
|
101
|
-
if (typeof menu.total_item_count === 'number')
|
|
102
|
-
matchedItemCount += menu.total_item_count;
|
|
103
|
-
const sections = menu.sections?.items ?? [];
|
|
104
|
-
for (const section of sections) {
|
|
105
|
-
sectionsReturned++;
|
|
106
|
-
for (const it of section.items ?? []) {
|
|
107
|
-
const beer = it?.beer;
|
|
108
|
-
if (!beer || typeof beer.bid !== 'number')
|
|
109
|
-
continue;
|
|
110
|
-
const key = `${String(menu.menu_id)}:${String(section.section_id)}:${beer.bid}`;
|
|
111
|
-
if (seen.has(key))
|
|
112
|
-
continue;
|
|
113
|
-
seen.add(key);
|
|
114
|
-
const sectionName = section.section_name;
|
|
115
|
-
beers.push({
|
|
116
|
-
bid: beer.bid,
|
|
117
|
-
name: beer.beer_name,
|
|
118
|
-
brewery: it.brewery?.brewery_name,
|
|
119
|
-
style: beer.beer_style,
|
|
120
|
-
abv: beer.beer_abv,
|
|
121
|
-
price: it.price?.value,
|
|
122
|
-
serving_type: it.serving_type,
|
|
123
|
-
menu: menu.menu_name,
|
|
124
|
-
section: typeof sectionName === 'string' ? sectionName.trim() : sectionName,
|
|
125
|
-
});
|
|
126
|
-
added++;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
// Coverage target: with a menu_id filter, aim for THAT menu's own item
|
|
131
|
-
// count — verfied_beers.total_count spans every menu, so a single-menu
|
|
132
|
-
// slice could never reach it (it would burn the whole page budget and
|
|
133
|
-
// wrongly report a shortfall). Without a filter, the venue-wide total.
|
|
134
|
-
totalCount = menu_id ? matchedItemCount : typeof vb.total_count === 'number' ? vb.total_count : totalCount;
|
|
135
|
-
offset += pageSize;
|
|
136
|
-
if (totalCount > 0 && beers.length >= totalCount) {
|
|
137
|
-
reachedEnd = true; // full coverage from this walk
|
|
138
|
-
break;
|
|
139
|
-
}
|
|
140
|
-
if (sectionsReturned < pageSize || added === 0) {
|
|
141
|
-
reachedEnd = true; // a short (or all-duplicate) page is the end of the section list
|
|
142
|
-
break;
|
|
143
|
-
}
|
|
144
|
-
}
|
|
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);
|
|
145
67
|
if (!sawMenu) {
|
|
146
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.' });
|
|
147
69
|
}
|
|
@@ -157,7 +79,7 @@ export function registerVenueTools(server, client) {
|
|
|
157
79
|
returned: beers.length,
|
|
158
80
|
pages_fetched: pagesFetched,
|
|
159
81
|
another_run_needed,
|
|
160
|
-
...(another_run_needed ? { next_section_offset:
|
|
82
|
+
...(another_run_needed ? { next_section_offset: walk.nextOffset } : {}),
|
|
161
83
|
truncated,
|
|
162
84
|
beers,
|
|
163
85
|
});
|
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.4.0'; // x-release-please-version
|
package/mint.yaml
CHANGED
|
@@ -5,6 +5,18 @@ summary: >-
|
|
|
5
5
|
Search Untappd beers, breweries, and venues; read profiles, check-ins,
|
|
6
6
|
wishlists, and your friend feed; and post check-ins, toasts, and comments
|
|
7
7
|
to your account
|
|
8
|
+
examples:
|
|
9
|
+
- prompt: >-
|
|
10
|
+
What's trending on Untappd right now? Split it into the big macro beers
|
|
11
|
+
and the smaller craft ones.
|
|
12
|
+
note: Read-only; uses the trending beers list.
|
|
13
|
+
- prompt: >-
|
|
14
|
+
Look up Tree House Brewing on Untappd and list their highest-rated beers.
|
|
15
|
+
note: Read-only; brewery search, then its beer list sorted by rating.
|
|
16
|
+
- prompt: >-
|
|
17
|
+
Here's the tap list at the bar I'm at. Which two of these have I not had
|
|
18
|
+
yet, ranked by rating?
|
|
19
|
+
note: Checks your synced check-in history; nothing is posted.
|
|
8
20
|
env:
|
|
9
21
|
- name: UNTAPPD_USERNAME
|
|
10
22
|
required: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "untappd-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.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>",
|
|
@@ -58,5 +58,9 @@
|
|
|
58
58
|
"typescript": "^7.0.2",
|
|
59
59
|
"vite": "^8.2.2",
|
|
60
60
|
"vitest": "^5.0.0"
|
|
61
|
+
},
|
|
62
|
+
"allowScripts": {
|
|
63
|
+
"esbuild": false,
|
|
64
|
+
"fsevents": false
|
|
61
65
|
}
|
|
62
66
|
}
|
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.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -92,6 +92,13 @@
|
|
|
92
92
|
"isRequired": false,
|
|
93
93
|
"format": "string",
|
|
94
94
|
"isSecret": false
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
"name": "UNTAPPD_CACHE_DIR",
|
|
98
|
+
"description": "Directory for the cache instead of ~/.untappd-mcp (the file is checkins.db inside it). UNTAPPD_CACHE_DB wins if both are set. Local/stdio only.",
|
|
99
|
+
"isRequired": false,
|
|
100
|
+
"format": "string",
|
|
101
|
+
"isSecret": false
|
|
95
102
|
}
|
|
96
103
|
]
|
|
97
104
|
}
|