untappd-mcp 2.3.2 → 2.5.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.
@@ -0,0 +1,236 @@
1
+ // Rate-limit tracking for the ~100 calls/hour Untappd v4 limit.
2
+ //
3
+ // Every response the client receives is recorded as an ApiCallObservation
4
+ // (time, status, and whatever X-Ratelimit-* / Retry-After headers it carried).
5
+ // The headers alone can't be trusted: the v4 API sends `X-Ratelimit-Remaining:
6
+ // 0` on EVERY successful response (observed live 2026-11-02), so a header-only
7
+ // reading would report the quota exhausted after the first call. The estimate
8
+ // therefore rests on a LOCAL count of calls in the rolling hour, and only takes
9
+ // the header into account once it has reported a live (non-zero) value — or
10
+ // when Untappd actually answered 429.
11
+ //
12
+ // The stdio server also persists observations into the shared cache file
13
+ // (`api_calls` table), so a fresh process — or a second one sharing the file —
14
+ // knows what was already spent this hour. `checkRateBudget` merges both views
15
+ // and is the guard every paging sync consults before each page, so it can stop
16
+ // cleanly with resume info instead of failing mid-batch on a 429.
17
+ import { readEnvVar } from '@chrischall/mcp-utils';
18
+ export const RATE_WINDOW_MS = 60 * 60 * 1000;
19
+ /** Untappd's documented per-token limit; override with UNTAPPD_RATE_LIMIT_PER_HOUR. */
20
+ export const DEFAULT_HOURLY_LIMIT = 100;
21
+ /** Calls the guard leaves unspent so interactive tools still work after a sync. */
22
+ export const DEFAULT_RESERVE = 2;
23
+ function intHeader(h, name) {
24
+ const raw = h.get(name);
25
+ if (raw === null || raw.trim() === '')
26
+ return null;
27
+ const n = Number(raw);
28
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
29
+ }
30
+ /** Build an observation from a response's status and headers. */
31
+ export function observeResponse(status, headers, now = Date.now()) {
32
+ const limit = intHeader(headers, 'x-ratelimit-limit');
33
+ const reset = intHeader(headers, 'x-ratelimit-reset');
34
+ let reset_at = null;
35
+ if (reset !== null) {
36
+ if (reset > 1e12)
37
+ reset_at = reset; // epoch ms
38
+ else if (reset > 1e9)
39
+ reset_at = reset * 1000; // epoch seconds
40
+ else
41
+ reset_at = now + reset * 1000; // seconds from now
42
+ }
43
+ const ra = intHeader(headers, 'retry-after');
44
+ return {
45
+ at: now,
46
+ status,
47
+ remaining: intHeader(headers, 'x-ratelimit-remaining'),
48
+ limit: limit !== null && limit > 0 ? limit : null,
49
+ reset_at,
50
+ retry_after_s: ra !== null && ra > 0 ? ra : null,
51
+ };
52
+ }
53
+ /** UNTAPPD_RATE_LIMIT_PER_HOUR, when set to a positive integer. */
54
+ export function configuredHourlyLimit() {
55
+ const raw = readEnvVar('UNTAPPD_RATE_LIMIT_PER_HOUR');
56
+ const n = raw === undefined || raw === null ? NaN : Number(raw);
57
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
58
+ }
59
+ const iso = (ms) => (ms === null ? null : new Date(ms).toISOString());
60
+ function compute(all, now, opts) {
61
+ const obs = all.filter((o) => o.at > now - RATE_WINDOW_MS).sort((a, b) => a.at - b.at);
62
+ const latestWith = (k) => {
63
+ for (let i = obs.length - 1; i >= 0; i--)
64
+ if (obs[i][k] !== null)
65
+ return obs[i];
66
+ return undefined;
67
+ };
68
+ const headerLimit = latestWith('limit')?.limit ?? null;
69
+ const configured = opts.configuredLimit ?? null;
70
+ const hourly_limit = configured ?? headerLimit ?? DEFAULT_HOURLY_LIMIT;
71
+ const limit_source = configured !== null ? 'env' : headerLimit !== null ? 'header' : 'assumed';
72
+ const lastRemaining = latestWith('remaining');
73
+ const headerRemaining = lastRemaining?.remaining ?? null;
74
+ const headerLive = obs.some((o) => o.remaining !== null && o.remaining > 0);
75
+ const resetMs = lastRemaining?.reset_at ?? null;
76
+ const headerTrusted = lastRemaining !== undefined && (headerLive || lastRemaining.status === 429) && (resetMs === null || resetMs > now);
77
+ let blockedUntilMs = null;
78
+ let last429 = null;
79
+ for (const o of obs) {
80
+ if (o.status !== 429)
81
+ continue;
82
+ last429 = o.at;
83
+ const until = o.retry_after_s !== null
84
+ ? o.at + o.retry_after_s * 1000
85
+ : o.reset_at ?? (obs[0] ? obs[0].at + RATE_WINDOW_MS : o.at + RATE_WINDOW_MS);
86
+ if (until > now && (blockedUntilMs === null || until > blockedUntilMs))
87
+ blockedUntilMs = until;
88
+ }
89
+ const local = hourly_limit - obs.length;
90
+ let estimate = local;
91
+ if (headerTrusted && headerRemaining !== null)
92
+ estimate = Math.min(estimate, headerRemaining);
93
+ if (blockedUntilMs !== null)
94
+ estimate = 0;
95
+ return {
96
+ hourly_limit,
97
+ limit_source,
98
+ calls_last_hour: obs.length,
99
+ estimated_remaining: Math.max(0, estimate),
100
+ header_remaining: headerRemaining,
101
+ header_remaining_trusted: headerTrusted,
102
+ header_reset_at: iso(resetMs),
103
+ last_response_at: iso(obs.length ? obs[obs.length - 1].at : null),
104
+ last_429_at: iso(last429),
105
+ blocked_until: iso(blockedUntilMs),
106
+ times: obs.map((o) => o.at),
107
+ blockedUntilMs,
108
+ headerBoundResetMs: headerTrusted ? resetMs : null,
109
+ };
110
+ }
111
+ /** The rate-limit picture for a set of observations (pure; for tests and the status tool). */
112
+ export function computeRateLimitStatus(obs, now, opts) {
113
+ const { times: _t, blockedUntilMs: _b, headerBoundResetMs: _h, ...status } = compute(obs, now, opts);
114
+ return status;
115
+ }
116
+ /** In-memory record of this process's responses within the rolling hour. */
117
+ export class RateLimitTracker {
118
+ obs = [];
119
+ record(o) {
120
+ this.obs.push(o);
121
+ this.prune(o.at);
122
+ }
123
+ observations(now = Date.now()) {
124
+ this.prune(now);
125
+ return [...this.obs];
126
+ }
127
+ prune(now) {
128
+ const cutoff = now - RATE_WINDOW_MS;
129
+ if (this.obs.length && this.obs[0].at <= cutoff)
130
+ this.obs = this.obs.filter((o) => o.at > cutoff);
131
+ }
132
+ }
133
+ /** This process's observations merged with the shared cache log, de-duplicated. */
134
+ async function mergedObservations(client, cache, now) {
135
+ const tracker = client.rateLimit;
136
+ const mine = tracker?.observations(now) ?? [];
137
+ let theirs = [];
138
+ try {
139
+ theirs = (await cache?.recentApiCalls?.(now - RATE_WINDOW_MS)) ?? [];
140
+ }
141
+ catch {
142
+ /* an unreadable log just means "this process's view only" */
143
+ }
144
+ // The shared log normally already holds this process's own calls (the stdio
145
+ // server persists each one), so merge as a multiset: per identical
146
+ // observation, keep the larger of the two counts rather than summing them.
147
+ // Two genuinely separate calls in the same millisecond stay two.
148
+ const key = (o) => `${o.at}:${o.status}:${o.remaining}:${o.retry_after_s}`;
149
+ const group = (xs) => {
150
+ const m = new Map();
151
+ for (const o of xs)
152
+ m.set(key(o), [...(m.get(key(o)) ?? []), o]);
153
+ return m;
154
+ };
155
+ const a = group(mine);
156
+ const b = group(theirs);
157
+ const out = [];
158
+ for (const k of new Set([...a.keys(), ...b.keys()])) {
159
+ const x = a.get(k) ?? [];
160
+ const y = b.get(k) ?? [];
161
+ out.push(...(x.length >= y.length ? x : y));
162
+ }
163
+ return out;
164
+ }
165
+ /** The current rate-limit picture (this process + the shared cache log). */
166
+ export async function rateLimitStatus(client, cache) {
167
+ const now = Date.now();
168
+ return computeRateLimitStatus(await mergedObservations(client, cache, now), now, {
169
+ configuredLimit: configuredHourlyLimit(),
170
+ });
171
+ }
172
+ /**
173
+ * The stop-before-429 guard: may a sync spend `needed` more calls and still
174
+ * leave `reserve` unspent? Paging syncs call it before EVERY page and stop
175
+ * cleanly (progress is persisted per page) with `resume_at` when it says no.
176
+ */
177
+ export async function checkRateBudget(client, cache, opts = {}) {
178
+ const needed = opts.needed ?? 1;
179
+ const reserve = opts.reserve ?? DEFAULT_RESERVE;
180
+ const now = Date.now();
181
+ const c = compute(await mergedObservations(client, cache, now), now, { configuredLimit: configuredHourlyLimit() });
182
+ const base = {
183
+ estimated_remaining: c.estimated_remaining,
184
+ calls_last_hour: c.calls_last_hour,
185
+ hourly_limit: c.hourly_limit,
186
+ reserve,
187
+ };
188
+ if (c.estimated_remaining - needed >= reserve) {
189
+ return { ok: true, ...base, resume_at: null, resume_after_s: null };
190
+ }
191
+ let resumeMs = null;
192
+ if (c.blockedUntilMs !== null)
193
+ resumeMs = c.blockedUntilMs;
194
+ else if (c.headerBoundResetMs !== null)
195
+ resumeMs = c.headerBoundResetMs;
196
+ else {
197
+ // Rolling window: wait until enough of the oldest calls age out.
198
+ const deficit = needed + reserve - (c.hourly_limit - c.calls_last_hour);
199
+ if (deficit > 0 && deficit <= c.times.length)
200
+ resumeMs = c.times[deficit - 1] + RATE_WINDOW_MS;
201
+ }
202
+ return {
203
+ ok: false,
204
+ ...base,
205
+ resume_at: iso(resumeMs),
206
+ resume_after_s: resumeMs === null ? null : Math.max(0, Math.ceil((resumeMs - now) / 1000)),
207
+ reason: c.blockedUntilMs !== null
208
+ ? 'Untappd answered 429 (rate limited) and its wait has not elapsed yet.'
209
+ : `Stopped before Untappd's hourly rate limit: ~${c.estimated_remaining} of ${c.hourly_limit} calls left this hour, ${reserve} held in reserve.`,
210
+ };
211
+ }
212
+ /** A guard verdict as a sync's stop record. */
213
+ export function stopFromVerdict(v) {
214
+ return {
215
+ reason: v.reason ?? 'rate limit',
216
+ estimated_remaining: v.estimated_remaining,
217
+ calls_last_hour: v.calls_last_hour,
218
+ hourly_limit: v.hourly_limit,
219
+ resume_at: v.resume_at,
220
+ resume_after_s: v.resume_after_s,
221
+ };
222
+ }
223
+ /** A 429 that slipped past the guard, as a sync's stop record. */
224
+ export function stopFrom429(e, now = Date.now()) {
225
+ const ra = e.retryAfterSeconds;
226
+ return {
227
+ reason: 'Untappd answered 429 (rate limited); progress up to the last full page is saved.',
228
+ resume_at: ra !== undefined ? new Date(now + ra * 1000).toISOString() : null,
229
+ resume_after_s: ra ?? null,
230
+ };
231
+ }
232
+ /** One-line resume advice for a sync's `note`. */
233
+ export function resumeAdvice(stop, tool) {
234
+ const when = stop.resume_at ? ` after ${stop.resume_at} (~${Math.ceil((stop.resume_after_s ?? 0) / 60)} min)` : ' later';
235
+ return `${stop.reason} Progress is saved — run ${tool} again${when} to resume.`;
236
+ }
@@ -13,9 +13,12 @@ async function seedBeerMeta(cache, beers) {
13
13
  return;
14
14
  try {
15
15
  const now = new Date().toISOString();
16
+ // Search results carry no rating. Seeding one would overwrite a real cached
17
+ // rating with null under a fresh timestamp, so the ranking tools would
18
+ // trust it for 30 days — only rows that actually carry a rating are seeded.
16
19
  const rows = beers
17
20
  .map((b) => beerMetaFrom(b.beer, b.brewery, now))
18
- .filter((m) => m !== null);
21
+ .filter((m) => m !== null && (m.weighted_rating_score ?? m.rating_score) !== null);
19
22
  if (rows.length)
20
23
  await cache().upsertBeerMeta(rows);
21
24
  }
@@ -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
+ }