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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +103 -1
- package/dist/tools/venue.js +133 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/untappd-mcp/SKILL.md +6 -0
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
{
|
package/dist/tools/venue.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.8.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "1.
|
|
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.
|