untappd-mcp 2.3.1 → 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.
@@ -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: max_pages ?? 10,
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 summary = await syncUserBeers(client, cacheProvider(), user, max_pages ?? 10);
141
- return minifiedResult(summary);
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 seen = new Set();
213
+ const verdicts = await cache.hasHadMany(user, bids);
180
214
  const results = [];
181
215
  let hadCount = 0;
182
- for (const bid of bids) {
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: seen.size,
223
+ checked: verdicts.size,
194
224
  had: hadCount,
195
- not_had: seen.size - hadCount,
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 seen = new Set();
245
+ const verdicts = await cache.hasHadMany(user, bids);
216
246
  const notHad = [];
217
247
  const had = [];
218
- for (const bid of bids) {
219
- if (seen.has(bid))
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: seen.size,
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 budget = api_budget ?? 25;
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 notHad = [];
304
- for (const bid of candidates) {
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
+ }
@@ -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
- // venue/info pages its MENUS with limit/offset, but caps each menu's SECTION
54
- // list — echoing `section_limit`/`section_offset` back as accepted params
55
- // (the response even carries a `section_offset ` key with a stray trailing
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
- let offset = startOffset;
72
- const seen = new Set();
73
- const beers = [];
74
- let totalCount = 0;
75
- let sawMenu = false;
76
- let pagesFetched = 0;
77
- let reachedEnd = false; // hit full coverage, or the section list ran out
78
- for (let page = 0; page < budget; page++) {
79
- const data = await client.get(`/venue/info/${venue_id}`, {
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: 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.3.1'; // x-release-please-version
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.1",
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>",
@@ -45,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^3.0.0",
48
+ "@chrischall/mcp-utils": "^3.0.3",
49
49
  "@modelcontextprotocol/server": "^2.3.0",
50
50
  "dotenv": "^18.0.1",
51
51
  "zod": "^4.6.5"
@@ -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.3.1",
9
+ "version": "2.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.3.1",
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
  }
@@ -5,19 +5,35 @@ description: Search Untappd beers, breweries, and venues; read user profiles, ch
5
5
 
6
6
  # Untappd MCP
7
7
 
8
- Talks to Untappd's mobile (v4) API using your own account. Logs in via the app's
9
- `xauth` endpoint (username + password → access token) and calls the same
10
- endpoints the iPad app uses.
8
+ Talks to Untappd's mobile (v4) API using your own account, calling the same
9
+ endpoints the iPad app uses. Authenticates with an access token you already
10
+ hold, or mints one via the app's `xauth` endpoint (username + password).
11
11
 
12
12
  ## Setup
13
13
 
14
- Set four environment variables (see the README for details):
15
-
16
- - `UNTAPPD_USERNAME`, `UNTAPPD_PASSWORD` — your Untappd login.
17
- - `UNTAPPD_CLIENT_ID`, `UNTAPPD_CLIENT_SECRET` — the Untappd mobile app client
18
- credentials (obtained by intercepting the app's traffic; see README).
19
-
20
- Run `untappd_healthcheck` to confirm login works.
14
+ Environment variables (the README has the full table and how to capture the
15
+ client credentials):
16
+
17
+ | Variable | Needed | Purpose |
18
+ |---|---|---|
19
+ | `UNTAPPD_ACCESS_TOKEN` | no | A token you already hold. With it, **no password is needed** — xauth is skipped. When it expires, the server says so; supply a fresh one. |
20
+ | `UNTAPPD_USERNAME`, `UNTAPPD_PASSWORD` | if no token | Your Untappd login, used only to mint a token. Accounts with 2FA can't log in this way. |
21
+ | `UNTAPPD_CLIENT_ID`, `UNTAPPD_CLIENT_SECRET` | for login & writes | The Untappd mobile app's client credentials. A pre-seeded token can read without them. |
22
+ | `UNTAPPD_TIMEZONE` | no | IANA zone check-ins are stamped with when a call omits `timezone`. Defaults to the server's zone. |
23
+ | `UNTAPPD_PHOTO_DIR` | no | Directory (or `:`-separated list) a check-in's `photo_path` must be inside. Default `~/Downloads/untappd-mcp`. |
24
+ | `UNTAPPD_CACHE_DB` | no | Local check-in cache file (default `~/.untappd-mcp/checkins.db`). Local/stdio only. |
25
+
26
+ Two things the server enforces that you should plan around:
27
+
28
+ - **Photos:** `untappd_checkin`'s `photo_path` must be a file inside
29
+ `UNTAPPD_PHOTO_DIR` (default `~/Downloads/untappd-mcp`); anything else (e.g.
30
+ `~/Pictures/beer.jpg`) is refused. Ask the user to move the photo there first.
31
+ - **Time zone:** on a hosted connector the server usually runs in UTC, so pass
32
+ `timezone` (the drinker's IANA zone, e.g. `America/New_York`) on
33
+ `untappd_checkin` unless `UNTAPPD_TIMEZONE` is set — otherwise the check-in is
34
+ stamped in the server's zone.
35
+
36
+ Run `untappd_healthcheck` to confirm auth works.
21
37
 
22
38
  ## Read tools
23
39