untappd-mcp 2.4.0 → 2.5.1

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.
@@ -1,7 +1,11 @@
1
1
  import { z } from 'zod';
2
2
  import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
- import { UNTAPPD_VIEWS, compactBreweryBeers, upstreamCompact } from '../compact.js';
4
- export function registerBreweryTools(server, client) {
3
+ import { UNTAPPD_VIEWS, compactBreweryBeerList, upstreamCompact } from '../compact.js';
4
+ import { BREWERY_BEERS_DEFAULT_LIMIT, BREWERY_BEERS_TTL_MS, getBreweryBeers } from '../cache/brewery-beers.js';
5
+ import { optionalCache } from './cache-optional.js';
6
+ import { favoriteLookup } from '../cache/favorites.js';
7
+ export function registerBreweryTools(server, client, cacheProvider) {
8
+ const cacheOrNone = optionalCache(cacheProvider);
5
9
  server.registerTool('untappd_search_brewery', {
6
10
  title: 'Search Untappd breweries',
7
11
  description: 'Search Untappd for breweries by name. Returns matches with their brewery id, location, type, and beer ' +
@@ -19,7 +23,9 @@ export function registerBreweryTools(server, client) {
19
23
  server.registerTool('untappd_brewery_info', {
20
24
  title: 'Get Untappd brewery detail',
21
25
  description: 'Get full detail for a brewery by its Untappd brewery id: description, location, type, rating, total ' +
22
- 'check-ins, and popular beers. Get an id from untappd_search_brewery. Read-only.',
26
+ 'check-ins, and popular beers. Get an id from untappd_search_brewery. Read-only. Its ' +
27
+ 'claimed_status.follow_status is whether the SIGNED-IN account follows/likes the brewery — never another ' +
28
+ "user's; for theirs, run untappd_sync_brewery_likes and read untappd_favorites_list (source \"untappd\").",
23
29
  annotations: toolAnnotations({ title: 'Get Untappd brewery detail', readOnly: true, idempotent: true, openWorld: true }),
24
30
  inputSchema: z.object({
25
31
  brewery_id: z.number().int().positive().describe('Untappd brewery id'),
@@ -32,8 +38,17 @@ export function registerBreweryTools(server, client) {
32
38
  });
33
39
  server.registerTool('untappd_brewery_beers', {
34
40
  title: "Get a brewery's beer list",
35
- description: 'Get the beers a brewery makes, by brewery id, with per-beer rating and check-in counts. Supports sorting ' +
36
- 'and paging. Get an id from untappd_search_brewery. Read-only.',
41
+ description: 'Get the beers a brewery makes, by brewery id, with per-beer global rating, check-in count, ABV, style and ' +
42
+ 'in_production. Supports sorting and paging. Get an id from untappd_search_brewery. Lists are CACHED locally ' +
43
+ `for ${BREWERY_BEERS_TTL_MS / 86_400_000} days (source:"cache"|"api", fetched_at, expires_at): a fresh cached list answers ` +
44
+ 'without an API call — the default popularity order from the cached top-of-list, other sorts once the whole ' +
45
+ 'list is cached (sorted locally). refresh:true re-fetches. The compact result has the same keys with or ' +
46
+ 'without the cache (source:"api" and null fetched_at/expires_at when no cache is available), with ' +
47
+ 'pagination {offset, limit, next_offset}. Untappd\'s per-beer has_had / your-count on this endpoint ' +
48
+ 'describe the AUTHENTICATED account, never the user you are asking about, so have_had / your_count are ' +
49
+ 'never cached or returned on either path — use untappd_cache_has_had_many for any user. With the cache, the result carries favorite {is_favorite, sources, ' +
50
+ 'reason} for the brewery and on each favorite beer (derived from username\'s history or added with ' +
51
+ 'untappd_favorite_add; default your account). view:"full" always calls Untappd and returns its raw page. Read-only.',
37
52
  annotations: toolAnnotations({ title: "Get a brewery's beer list", readOnly: true, idempotent: true, openWorld: true }),
38
53
  inputSchema: z.object({
39
54
  brewery_id: z.number().int().positive().describe('Untappd brewery id'),
@@ -43,19 +58,42 @@ export function registerBreweryTools(server, client) {
43
58
  .enum(['name', 'style', 'abv', 'rating', 'count'])
44
59
  .optional()
45
60
  .describe('Sort order (default by popularity)'),
61
+ refresh: z.boolean().optional().describe('Re-fetch from Untappd even if a fresh cached list exists (default false)'),
62
+ username: z
63
+ .string()
64
+ .min(1)
65
+ .optional()
66
+ .describe('Whose favorites to mark (cache only; default your configured account)'),
46
67
  view: viewParam(UNTAPPD_VIEWS, {
47
- note: 'compact keeps each beer\'s identity, rating and counts and drops the description, label URLs and the copy of this brewery repeated on every row; "full" returns Untappd\'s whole page.',
68
+ note: 'compact keeps each beer\'s identity, rating and counts and drops the description, label URLs and the copy of this brewery repeated on every row; "full" returns Untappd\'s whole page (always fetched live).',
48
69
  }),
49
70
  }),
50
71
  },
51
72
  // Unlike its `/brewery/info` sibling above, this rung is NOT forwarded to
52
73
  // Untappd: `/brewery/beer_list/` is not one of the endpoints documented to
53
74
  // honour an upstream `compact`, and sending an unverified parameter to see
54
- // what happens is not a rung. The projection is entirely ours and is
55
- // grounded on a live capture — see `compactBreweryBeer`.
56
- async ({ brewery_id, limit, offset, sort, view }) => {
75
+ // what happens is not a rung. The projection is entirely ours — see
76
+ // `compactBreweryBeerList`, the ONE shape both the cached and no-cache
77
+ // paths return.
78
+ async ({ brewery_id, limit, offset, sort, refresh, username, view }) => {
57
79
  const v = resolveView(view, UNTAPPD_VIEWS);
58
- const data = await client.get(`/brewery/beer_list/${brewery_id}`, { limit, offset, sort });
59
- return viewResult(v, v === 'compact' ? compactBreweryBeers(data) : data);
80
+ const cache = cacheOrNone();
81
+ // view:"full" wants Untappd's raw page, so it always fetches (and seeds the cache).
82
+ // Without a cache this is a plain fetch mapped through the same rows.
83
+ const r = await getBreweryBeers(client, cache, brewery_id, { limit, offset, sort, refresh: refresh || v === 'full' });
84
+ if (v === 'full')
85
+ return viewResult(v, r.raw);
86
+ // Drift-safe like every compact rung: a fetched page without beers.items is
87
+ // returned raw (with a stderr note) rather than as an empty projection.
88
+ if (r.source === 'api' && !Array.isArray(r.raw?.beers?.items)) {
89
+ process.stderr.write('[untappd-mcp] compact: expected beers.items array; returning raw response\n');
90
+ return viewResult(v, r.raw);
91
+ }
92
+ // Favorites are applied on read (never cached): the brewery itself, and any favorite beer.
93
+ const user = username ?? client.loginName;
94
+ const favs = cache && user ? await favoriteLookup(cache, [user], { brewery: [brewery_id], beer: r.beers.map((b) => b.bid) }) : null;
95
+ return viewResult(v, compactBreweryBeerList(r, { offset: offset ?? 0, limit: limit ?? BREWERY_BEERS_DEFAULT_LIMIT }, favs
96
+ ? { brewery: favs.get('brewery', brewery_id), beer: (bid) => (favs.isFavorite('beer', bid) ? favs.get('beer', bid) : undefined) }
97
+ : undefined));
60
98
  });
61
99
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Wrap an optional cache provider for tools whose cache is an optimisation, not
3
+ * their answer: no provider, or a cache that fails to open, means "call the API"
4
+ * (with one stderr note) rather than failing the read.
5
+ */
6
+ export function optionalCache(provider) {
7
+ let warned = false;
8
+ return () => {
9
+ if (!provider)
10
+ return undefined;
11
+ try {
12
+ return provider();
13
+ }
14
+ catch (e) {
15
+ if (!warned) {
16
+ warned = true;
17
+ console.error(`[untappd-mcp] response cache unavailable, calling Untappd directly: ${e?.message ?? e}`);
18
+ }
19
+ return undefined;
20
+ }
21
+ };
22
+ }