untappd-mcp 1.7.6 → 1.8.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Untappd — beers, breweries, venues, check-ins, wishlists, and your friend feed",
10
- "version": "1.7.6"
10
+ "version": "1.8.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Untappd",
16
16
  "source": "./",
17
17
  "description": "MCP server for Untappd — search beers/breweries/venues, read profiles/check-ins/wishlists, and post check-ins, toasts, and comments",
18
- "version": "1.7.6",
18
+ "version": "1.8.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
3
  "displayName": "Untappd",
4
- "version": "1.7.6",
4
+ "version": "1.8.0",
5
5
  "description": "MCP server for Untappd — search beers/breweries/venues, read check-ins and wishlists, and post check-ins, toasts, and comments",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/dist/bundle.js CHANGED
@@ -31190,7 +31190,7 @@ function toolAnnotations(opts = {}) {
31190
31190
  }
31191
31191
 
31192
31192
  // src/version.ts
31193
- var VERSION = "1.7.6";
31193
+ var VERSION = "1.8.0";
31194
31194
 
31195
31195
  // src/client.ts
31196
31196
  import { dirname, join } from "path";
@@ -32212,6 +32212,108 @@ function registerVenueTools(server, client2) {
32212
32212
  return textResult(data);
32213
32213
  }
32214
32214
  );
32215
+ server.registerTool(
32216
+ "untappd_venue_menu",
32217
+ {
32218
+ title: "Get a venue's verified beer menu (section-paged)",
32219
+ description: "Return a venue's verified beer menu as a flat, compact list of beers. untappd_venue_info returns only the FIRST section of each menu (Untappd defaults the section list to one), so it silently under-reports any venue whose menu spans multiple sections \u2014 e.g. a 23-beer wall that comes back with 2 items. This tool forwards the section_limit / section_offset paging params venue/info echoes back but never receives, walks sections up to a per-call max_pages budget (respecting the ~100 calls/hour limit \u2014 it does NOT loop to completion in one call), and flattens to [{bid, name, brewery, style, abv, price, serving_type, menu, section}]. Like the sync tools it is resumable: when the budget runs out before full coverage it returns another_run_needed:true plus next_section_offset to pass back on the next call. truncated:true means the upstream returned no more sections short of total_count (e.g. it ignored the paging params) \u2014 not resumable. Get an id from untappd_search_venue. Read-only.",
32220
+ annotations: toolAnnotations({ title: "Get a venue's verified beer menu (section-paged)", readOnly: true, idempotent: true, openWorld: true }),
32221
+ inputSchema: {
32222
+ venue_id: external_exports.number().int().positive().describe("Untappd venue id"),
32223
+ menu_id: external_exports.number().int().positive().optional().describe("Restrict to a single menu id (from a prior result). Optional."),
32224
+ section_limit: external_exports.number().int().min(1).max(50).optional().describe("Sections fetched per API call \u2014 page size (default 50)."),
32225
+ section_offset: external_exports.number().int().min(0).optional().describe("Section offset to start from; pass a prior next_section_offset to resume (default 0)."),
32226
+ max_pages: external_exports.number().int().min(1).max(10).optional().describe("API calls to spend THIS run \u2014 page budget, not page size (default 3). Resume with next_section_offset if another_run_needed."),
32227
+ sort: external_exports.string().optional().describe("Menu sort key (e.g. 'publish_order', 'highest_rated'). Optional.")
32228
+ }
32229
+ },
32230
+ async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort }) => {
32231
+ const pageSize = section_limit ?? 50;
32232
+ const budget = max_pages ?? 3;
32233
+ const startOffset = section_offset ?? 0;
32234
+ let offset = startOffset;
32235
+ const seen = /* @__PURE__ */ new Set();
32236
+ const beers = [];
32237
+ let totalCount = 0;
32238
+ let sawMenu = false;
32239
+ let pagesFetched = 0;
32240
+ let reachedEnd = false;
32241
+ for (let page = 0; page < budget; page++) {
32242
+ const data = await client2.get(`/venue/info/${venue_id}`, {
32243
+ section_limit: pageSize,
32244
+ section_offset: offset,
32245
+ menu_id,
32246
+ sort
32247
+ });
32248
+ pagesFetched++;
32249
+ const vb = data?.venue?.verfied_beers;
32250
+ if (!vb) {
32251
+ reachedEnd = true;
32252
+ break;
32253
+ }
32254
+ sawMenu = true;
32255
+ let added = 0;
32256
+ let matchedItemCount = 0;
32257
+ let sectionsReturned = 0;
32258
+ for (const wrap of vb.items ?? []) {
32259
+ const menu = wrap?.menu;
32260
+ if (!menu) continue;
32261
+ if (menu_id && menu.menu_id !== menu_id) continue;
32262
+ if (typeof menu.total_item_count === "number") matchedItemCount += menu.total_item_count;
32263
+ const sections = menu.sections?.items ?? [];
32264
+ for (const section of sections) {
32265
+ sectionsReturned++;
32266
+ for (const it of section.items ?? []) {
32267
+ const beer = it?.beer;
32268
+ if (!beer || typeof beer.bid !== "number") continue;
32269
+ const key = `${String(menu.menu_id)}:${String(section.section_id)}:${beer.bid}`;
32270
+ if (seen.has(key)) continue;
32271
+ seen.add(key);
32272
+ const sectionName = section.section_name;
32273
+ beers.push({
32274
+ bid: beer.bid,
32275
+ name: beer.beer_name,
32276
+ brewery: it.brewery?.brewery_name,
32277
+ style: beer.beer_style,
32278
+ abv: beer.beer_abv,
32279
+ price: it.price?.value,
32280
+ serving_type: it.serving_type,
32281
+ menu: menu.menu_name,
32282
+ section: typeof sectionName === "string" ? sectionName.trim() : sectionName
32283
+ });
32284
+ added++;
32285
+ }
32286
+ }
32287
+ }
32288
+ totalCount = menu_id ? matchedItemCount : typeof vb.total_count === "number" ? vb.total_count : totalCount;
32289
+ offset += pageSize;
32290
+ if (totalCount > 0 && beers.length >= totalCount) {
32291
+ reachedEnd = true;
32292
+ break;
32293
+ }
32294
+ if (sectionsReturned < pageSize || added === 0) {
32295
+ reachedEnd = true;
32296
+ break;
32297
+ }
32298
+ }
32299
+ if (!sawMenu) {
32300
+ return textResult({ venue_id, total_count: 0, returned: 0, pages_fetched: pagesFetched, another_run_needed: false, truncated: false, beers: [], note: "No verified menu on this venue." });
32301
+ }
32302
+ const covered = totalCount > 0 && beers.length >= totalCount;
32303
+ const another_run_needed = !reachedEnd;
32304
+ const truncated = reachedEnd && !covered && startOffset === 0 && totalCount > 0;
32305
+ return textResult({
32306
+ venue_id,
32307
+ total_count: totalCount,
32308
+ returned: beers.length,
32309
+ pages_fetched: pagesFetched,
32310
+ another_run_needed,
32311
+ ...another_run_needed ? { next_section_offset: offset } : {},
32312
+ truncated,
32313
+ beers
32314
+ });
32315
+ }
32316
+ );
32215
32317
  server.registerTool(
32216
32318
  "untappd_venue_by_foursquare",
32217
32319
  {
@@ -28,6 +28,139 @@ export function registerVenueTools(server, client) {
28
28
  const data = await client.get(`/venue/info/${venue_id}`, { compact: compact ? 'true' : undefined });
29
29
  return textResult(data);
30
30
  });
31
+ server.registerTool('untappd_venue_menu', {
32
+ title: "Get a venue's verified beer menu (section-paged)",
33
+ description: "Return a venue's verified beer menu as a flat, compact list of beers. untappd_venue_info returns only the FIRST " +
34
+ 'section of each menu (Untappd defaults the section list to one), so it silently under-reports any venue whose menu ' +
35
+ 'spans multiple sections — e.g. a 23-beer wall that comes back with 2 items. This tool forwards the ' +
36
+ 'section_limit / section_offset paging params venue/info echoes back but never receives, walks sections up to a ' +
37
+ 'per-call max_pages budget (respecting the ~100 calls/hour limit — it does NOT loop to completion in one call), and ' +
38
+ 'flattens to [{bid, name, brewery, style, abv, price, serving_type, menu, section}]. Like the sync tools it is ' +
39
+ 'resumable: when the budget runs out before full coverage it returns another_run_needed:true plus next_section_offset ' +
40
+ 'to pass back on the next call. truncated:true means the upstream returned no more sections short of total_count ' +
41
+ '(e.g. it ignored the paging params) — not resumable. Get an id from untappd_search_venue. Read-only.',
42
+ annotations: toolAnnotations({ title: "Get a venue's verified beer menu (section-paged)", readOnly: true, idempotent: true, openWorld: true }),
43
+ inputSchema: {
44
+ venue_id: z.number().int().positive().describe('Untappd venue id'),
45
+ menu_id: z.number().int().positive().optional().describe('Restrict to a single menu id (from a prior result). Optional.'),
46
+ section_limit: z.number().int().min(1).max(50).optional().describe('Sections fetched per API call — page size (default 50).'),
47
+ section_offset: z.number().int().min(0).optional().describe('Section offset to start from; pass a prior next_section_offset to resume (default 0).'),
48
+ 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.'),
49
+ sort: z.string().optional().describe("Menu sort key (e.g. 'publish_order', 'highest_rated'). Optional."),
50
+ },
51
+ }, async ({ venue_id, menu_id, section_limit, section_offset, max_pages, sort }) => {
52
+ // venue/info pages its MENUS with limit/offset, but caps each menu's SECTION
53
+ // list — echoing `section_limit`/`section_offset` back as accepted params
54
+ // (the response even carries a `section_offset ` key with a stray trailing
55
+ // space; the real param name is the clean one the web menu UI sends). We
56
+ // forward them and walk sections, deduping by menu+section+bid so overlapping
57
+ // or param-ignoring pages can't double-count. Per CLAUDE.md's rate-limit
58
+ // design this spends at most `max_pages` API calls per run and hands the
59
+ // caller next_section_offset to resume, rather than looping to completion.
60
+ //
61
+ // Termination is driven by SECTION availability, not a running beer count:
62
+ // a page that returns fewer sections than we asked for is the end of the
63
+ // list. That's what makes resuming correct — this tool is stateless across
64
+ // calls, so on a resumed tail (section_offset > 0) `beers.length` covers
65
+ // only this run and can never equal the whole-menu total, which would
66
+ // otherwise mislabel a finished tail as truncated.
67
+ const pageSize = section_limit ?? 50;
68
+ const budget = max_pages ?? 3;
69
+ const startOffset = section_offset ?? 0;
70
+ let offset = startOffset;
71
+ const seen = new Set();
72
+ const beers = [];
73
+ let totalCount = 0;
74
+ let sawMenu = false;
75
+ let pagesFetched = 0;
76
+ let reachedEnd = false; // hit full coverage, or the section list ran out
77
+ for (let page = 0; page < budget; page++) {
78
+ const data = await client.get(`/venue/info/${venue_id}`, {
79
+ section_limit: pageSize,
80
+ section_offset: offset,
81
+ menu_id,
82
+ sort,
83
+ });
84
+ pagesFetched++;
85
+ const vb = data?.venue?.verfied_beers;
86
+ if (!vb) {
87
+ reachedEnd = true; // no menu payload — nothing more to page
88
+ break;
89
+ }
90
+ sawMenu = true;
91
+ let added = 0;
92
+ let matchedItemCount = 0;
93
+ let sectionsReturned = 0;
94
+ for (const wrap of vb.items ?? []) {
95
+ const menu = wrap?.menu;
96
+ if (!menu)
97
+ continue;
98
+ if (menu_id && menu.menu_id !== menu_id)
99
+ continue;
100
+ if (typeof menu.total_item_count === 'number')
101
+ matchedItemCount += menu.total_item_count;
102
+ const sections = menu.sections?.items ?? [];
103
+ for (const section of sections) {
104
+ sectionsReturned++;
105
+ for (const it of section.items ?? []) {
106
+ const beer = it?.beer;
107
+ if (!beer || typeof beer.bid !== 'number')
108
+ continue;
109
+ const key = `${String(menu.menu_id)}:${String(section.section_id)}:${beer.bid}`;
110
+ if (seen.has(key))
111
+ continue;
112
+ seen.add(key);
113
+ const sectionName = section.section_name;
114
+ beers.push({
115
+ bid: beer.bid,
116
+ name: beer.beer_name,
117
+ brewery: it.brewery?.brewery_name,
118
+ style: beer.beer_style,
119
+ abv: beer.beer_abv,
120
+ price: it.price?.value,
121
+ serving_type: it.serving_type,
122
+ menu: menu.menu_name,
123
+ section: typeof sectionName === 'string' ? sectionName.trim() : sectionName,
124
+ });
125
+ added++;
126
+ }
127
+ }
128
+ }
129
+ // Coverage target: with a menu_id filter, aim for THAT menu's own item
130
+ // count — verfied_beers.total_count spans every menu, so a single-menu
131
+ // slice could never reach it (it would burn the whole page budget and
132
+ // wrongly report a shortfall). Without a filter, the venue-wide total.
133
+ totalCount = menu_id ? matchedItemCount : typeof vb.total_count === 'number' ? vb.total_count : totalCount;
134
+ offset += pageSize;
135
+ if (totalCount > 0 && beers.length >= totalCount) {
136
+ reachedEnd = true; // full coverage from this walk
137
+ break;
138
+ }
139
+ if (sectionsReturned < pageSize || added === 0) {
140
+ reachedEnd = true; // a short (or all-duplicate) page is the end of the section list
141
+ break;
142
+ }
143
+ }
144
+ if (!sawMenu) {
145
+ return textResult({ venue_id, total_count: 0, returned: 0, pages_fetched: pagesFetched, another_run_needed: false, truncated: false, beers: [], note: 'No verified menu on this venue.' });
146
+ }
147
+ const covered = totalCount > 0 && beers.length >= totalCount;
148
+ const another_run_needed = !reachedEnd; // stopped only because the page budget ran out mid-list
149
+ // Only a walk that started at the top of the list can judge a genuine
150
+ // shortfall as truncated; a resumed tail legitimately returns fewer than
151
+ // the whole-menu total_count and is not truncated.
152
+ const truncated = reachedEnd && !covered && startOffset === 0 && totalCount > 0;
153
+ return textResult({
154
+ venue_id,
155
+ total_count: totalCount,
156
+ returned: beers.length,
157
+ pages_fetched: pagesFetched,
158
+ another_run_needed,
159
+ ...(another_run_needed ? { next_section_offset: offset } : {}),
160
+ truncated,
161
+ beers,
162
+ });
163
+ });
31
164
  server.registerTool('untappd_venue_by_foursquare', {
32
165
  title: 'Look up an Untappd venue by Foursquare id',
33
166
  description: 'Resolve a Foursquare venue id to its Untappd venue. Useful to turn a foursquare_id (e.g. from a check-in) ' +
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 = '1.7.6'; // x-release-please-version
6
+ export const VERSION = '1.8.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "1.7.6",
3
+ "version": "1.8.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": "1.7.6",
9
+ "version": "1.8.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "1.7.6",
14
+ "version": "1.8.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -24,6 +24,12 @@ Run `untappd_healthcheck` to confirm login works.
24
24
  - `untappd_search_beer` / `untappd_beer_info` — find beers, then get full detail by bid.
25
25
  - `untappd_search_brewery` / `untappd_brewery_info` — breweries.
26
26
  - `untappd_search_venue` / `untappd_venue_info` — bars, breweries, restaurants.
27
+ - `untappd_venue_menu` — a venue's verified beer menu, flattened. Use instead of
28
+ `venue_info` for "what's on tap" — `venue_info` returns only the first section
29
+ of each menu and under-reports large boards. This pages sections under a
30
+ per-call `max_pages` budget (like the sync tools) and is resumable: it returns
31
+ `another_run_needed` + `next_section_offset` when the budget runs out, or
32
+ `truncated` if the upstream stops returning sections short of coverage.
27
33
  - `untappd_user_info` — a user's profile (omit `username` for your own).
28
34
  - `untappd_user_checkins` — recent check-ins (page with `max_id`).
29
35
  - `untappd_user_wishlist` — wishlist beers.