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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +74 -2
- package/dist/bundle.js +5440 -1664
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1203 -12
- package/dist/cache/sync-beers.js +24 -2
- package/dist/cache/sync-brewery-likes.js +125 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +29 -15
- package/dist/compact.js +49 -42
- package/dist/index.js +20 -3
- package/dist/menu/pick.js +7 -1
- package/dist/ratelimit.js +236 -0
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +457 -35
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +92 -38
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
|
+
import { mapCheckinRow } from './store.js';
|
|
3
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
4
|
+
export const VENUE_DRINKS_DEFAULT_CALLS = 10;
|
|
5
|
+
export const VENUE_DRINKS_MAX_CALLS_CAP = 50;
|
|
6
|
+
const TOOL = 'untappd_sync_venue_drinks';
|
|
7
|
+
/** A check-in that no longer exists or isn't visible answers 404 (not a transient failure). */
|
|
8
|
+
function isGone(e) {
|
|
9
|
+
return /\b404\b|not found|does not exist/i.test(messageOf(e));
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Opt-in "what did they drink there": for the user's cached user_venues rows,
|
|
13
|
+
* fetch the first and most-recent visit check-ins via checkin/view (works for
|
|
14
|
+
* any public check-in) and store the beer + date in `venue_drinks`. A PARTIAL
|
|
15
|
+
* picture by design — two check-ins per venue at most. Budgeted by `maxCalls`,
|
|
16
|
+
* with the stop-before-429 guard before every call; progress is stored per
|
|
17
|
+
* call, so a re-run continues with what is still pending.
|
|
18
|
+
*/
|
|
19
|
+
export async function syncVenueDrinks(client, cache, username, opts) {
|
|
20
|
+
const budget = Math.max(1, Math.min(VENUE_DRINKS_MAX_CALLS_CAP, Math.floor(opts.maxCalls)));
|
|
21
|
+
const filter = { venueIds: opts.venueIds, which: opts.which ?? 'both' };
|
|
22
|
+
const candidates = await cache.venueDrinkCandidates(username, filter);
|
|
23
|
+
let calls = 0;
|
|
24
|
+
let stored = 0;
|
|
25
|
+
let unavailable = 0;
|
|
26
|
+
let errors = 0;
|
|
27
|
+
let firstError;
|
|
28
|
+
let rateStop;
|
|
29
|
+
for (const c of candidates) {
|
|
30
|
+
if (calls >= budget)
|
|
31
|
+
break;
|
|
32
|
+
const verdict = await checkRateBudget(client, cache);
|
|
33
|
+
if (!verdict.ok) {
|
|
34
|
+
rateStop = stopFromVerdict(verdict);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
calls++;
|
|
38
|
+
let data;
|
|
39
|
+
try {
|
|
40
|
+
data = await client.get(`/checkin/view/${c.checkin_id}`);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
if (e instanceof RateLimitError) {
|
|
44
|
+
rateStop = stopFrom429(e);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
if (isGone(e)) {
|
|
48
|
+
unavailable++;
|
|
49
|
+
await cache.upsertVenueDrinks(username, [{ venue_id: c.venue_id, checkin_id: c.checkin_id, bid: null, beer_name: null, brewery_id: null, brewery_name: null, created_at: null }], new Date().toISOString());
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
errors++;
|
|
53
|
+
firstError ??= messageOf(e);
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Only the beer, brewery and date are kept — never the comment (other people's free text).
|
|
58
|
+
const r = mapCheckinRow(username, data?.checkin);
|
|
59
|
+
const row = {
|
|
60
|
+
venue_id: c.venue_id,
|
|
61
|
+
checkin_id: c.checkin_id,
|
|
62
|
+
bid: r?.bid ?? null,
|
|
63
|
+
beer_name: r?.beer_name ?? null,
|
|
64
|
+
brewery_id: r?.brewery_id ?? null,
|
|
65
|
+
brewery_name: r?.brewery_name ?? null,
|
|
66
|
+
created_at: r?.created_at ?? null,
|
|
67
|
+
};
|
|
68
|
+
if (row.bid === null && row.beer_name === null) {
|
|
69
|
+
// An unexpected shape — don't store a tombstone for it; count and retry later.
|
|
70
|
+
errors++;
|
|
71
|
+
firstError ??= `checkin/view ${c.checkin_id} had no beer`;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
await cache.upsertVenueDrinks(username, [row], new Date().toISOString());
|
|
75
|
+
stored++;
|
|
76
|
+
}
|
|
77
|
+
const pending = (await cache.venueDrinkCandidates(username, filter)).length;
|
|
78
|
+
const another = pending > 0;
|
|
79
|
+
let note;
|
|
80
|
+
if (rateStop)
|
|
81
|
+
note = `Paused with ${pending} check-in(s) pending. ${resumeAdvice(rateStop, TOOL)}`;
|
|
82
|
+
else if (another)
|
|
83
|
+
note = `${pending} check-in(s) still pending — run ${TOOL} again to continue (max_calls per run).`;
|
|
84
|
+
else
|
|
85
|
+
note = 'Every selected venue\'s first/recent visit drink is cached; untappd_cache_has_visited lists them under matches[].drinks.';
|
|
86
|
+
if (errors > 0)
|
|
87
|
+
note += ` ${errors} fetch(es) failed and stay pending.`;
|
|
88
|
+
return {
|
|
89
|
+
username,
|
|
90
|
+
api_calls: calls,
|
|
91
|
+
stored,
|
|
92
|
+
unavailable,
|
|
93
|
+
errors,
|
|
94
|
+
pending,
|
|
95
|
+
another_run_needed: another,
|
|
96
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
97
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
98
|
+
...(firstError ? { first_error: firstError } : {}),
|
|
99
|
+
note,
|
|
100
|
+
};
|
|
101
|
+
}
|
package/dist/client.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { dirname, join } from 'path';
|
|
2
2
|
import { fileURLToPath } from 'url';
|
|
3
3
|
import { loadDotenvSafely, readEnvVar, buildQueryString, formatApiError, createHelpfulError, McpToolError, RateLimitError, UnreachableError, } from '@chrischall/mcp-utils';
|
|
4
|
+
import { RateLimitTracker, computeRateLimitStatus, configuredHourlyLimit, observeResponse, } from './ratelimit.js';
|
|
4
5
|
// Load .env for local (Node) dev; silently skip if dotenv is unavailable (e.g.
|
|
5
6
|
// the mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and
|
|
6
7
|
// never lets .env override a host-provided value. Guard on `import.meta.url`:
|
|
@@ -13,8 +14,6 @@ if (import.meta.url) {
|
|
|
13
14
|
const BASE_URL = 'https://api.untappd.com/v4';
|
|
14
15
|
const SERVICE = 'Untappd';
|
|
15
16
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
16
|
-
// Untappd's limit is per hour, so a remaining-count older than that says nothing.
|
|
17
|
-
const QUOTA_WINDOW_MS = 60 * 60 * 1000;
|
|
18
17
|
// Non-secret client constants that mimic the Untappd iPad app (v4.7.13). The
|
|
19
18
|
// API accepts requests carrying these; NONE are user secrets (the secrets are
|
|
20
19
|
// UNTAPPD_CLIENT_ID / UNTAPPD_CLIENT_SECRET / UNTAPPD_USERNAME / _PASSWORD,
|
|
@@ -153,8 +152,9 @@ export class UntappdClient {
|
|
|
153
152
|
userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
|
|
154
153
|
token;
|
|
155
154
|
loginInFlight = null;
|
|
156
|
-
/**
|
|
157
|
-
|
|
155
|
+
/** Every response this process received within the rolling hour (see src/ratelimit.ts). */
|
|
156
|
+
rateLimit = new RateLimitTracker();
|
|
157
|
+
apiCallListeners = [];
|
|
158
158
|
tokenIsSupplied;
|
|
159
159
|
/**
|
|
160
160
|
* Defer the config error so the server can still start (and respond to the
|
|
@@ -211,22 +211,36 @@ export class UntappdClient {
|
|
|
211
211
|
return this._loginName;
|
|
212
212
|
}
|
|
213
213
|
/**
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
* null as "unknown"
|
|
214
|
+
* Estimated calls left this hour, or null before this process has made any
|
|
215
|
+
* call in the window. Rests on a LOCAL count of calls (the v4 API sends
|
|
216
|
+
* `X-Ratelimit-Remaining: 0` on every success, so the header is only believed
|
|
217
|
+
* once it has reported a live non-zero value, or on a 429) — see
|
|
218
|
+
* src/ratelimit.ts. Consumers must still treat null as "unknown".
|
|
219
219
|
*/
|
|
220
220
|
get quotaRemaining() {
|
|
221
|
-
|
|
221
|
+
const obs = this.rateLimit.observations();
|
|
222
|
+
if (obs.length === 0)
|
|
222
223
|
return null;
|
|
223
|
-
return
|
|
224
|
+
return computeRateLimitStatus(obs, Date.now(), { configuredLimit: configuredHourlyLimit() }).estimated_remaining;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Subscribe to every API response (status + rate-limit headers). The stdio
|
|
228
|
+
* server uses it to persist the call log into the shared cache file, so other
|
|
229
|
+
* processes (and the next one) know what this hour has already spent.
|
|
230
|
+
*/
|
|
231
|
+
onApiCall(listener) {
|
|
232
|
+
this.apiCallListeners.push(listener);
|
|
224
233
|
}
|
|
225
234
|
noteQuota(res) {
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
const o = observeResponse(res.status, res.headers);
|
|
236
|
+
this.rateLimit.record(o);
|
|
237
|
+
for (const l of this.apiCallListeners) {
|
|
238
|
+
try {
|
|
239
|
+
l(o);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* a listener failure must never fail the API call */
|
|
243
|
+
}
|
|
230
244
|
}
|
|
231
245
|
}
|
|
232
246
|
/** App creds for a write; reached only once a token is in hand. */
|
package/dist/compact.js
CHANGED
|
@@ -75,36 +75,6 @@ export function compactUserBeer(item) {
|
|
|
75
75
|
last_had: i.recent_created_at,
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
-
/**
|
|
79
|
-
* Slim one `/brewery/beer_list/` item.
|
|
80
|
-
*
|
|
81
|
-
* This endpoint needs its OWN projector rather than reusing
|
|
82
|
-
* {@link compactBeerResult}, and the reason is easy to miss: the item shape IS
|
|
83
|
-
* `{beer:{…}, brewery:{…}}`-wrapped, so `beerCore` fits perfectly — but the two
|
|
84
|
-
* fields the search projector adds on top of it are named differently here.
|
|
85
|
-
* `/search/beer` says `checkin_count` / `have_had`; `/brewery/beer_list/` says
|
|
86
|
-
* `total_count` / `has_had`. Reusing the search projector would therefore have
|
|
87
|
-
* emitted a page of records carrying two silent `undefined`s — a beer with no
|
|
88
|
-
* check-ins that you have never had — and `projectItems`' drift guard cannot
|
|
89
|
-
* catch that, because the CONTAINER is exactly where it expects it to be.
|
|
90
|
-
* Established against a live capture of brewery 1142, not by inference.
|
|
91
|
-
*
|
|
92
|
-
* Rating rides along because the tool's own description promises "per-beer
|
|
93
|
-
* rating and check-in counts"; a compact rung that dropped them would
|
|
94
|
-
* contradict the thing the caller was told they were getting.
|
|
95
|
-
*/
|
|
96
|
-
export function compactBreweryBeer(item) {
|
|
97
|
-
const i = asDict(item) ?? {};
|
|
98
|
-
const beer = asDict(i.beer) ?? {};
|
|
99
|
-
return {
|
|
100
|
-
...beerCore(i),
|
|
101
|
-
rating: beer.rating_score,
|
|
102
|
-
rating_count: beer.rating_count,
|
|
103
|
-
checkin_count: i.total_count,
|
|
104
|
-
have_had: i.has_had,
|
|
105
|
-
your_count: i.total_user_count,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
78
|
/**
|
|
109
79
|
* Project `<container>.items` in a response with `mapFn`, preserving the rest of
|
|
110
80
|
* the container (pagination etc.). Drift-safe: returns the raw response
|
|
@@ -125,20 +95,57 @@ export const compactBeerSearch = (resp) => projectItems(resp, 'beers', compactBe
|
|
|
125
95
|
export const compactWishlist = (resp) => projectItems(resp, 'beers', compactWishlistBeer);
|
|
126
96
|
export const compactUserBeers = (resp) => projectItems(resp, 'beers', compactUserBeer);
|
|
127
97
|
/**
|
|
128
|
-
*
|
|
98
|
+
* The ONE compact shape of `untappd_brewery_beers`, whether its rows came from
|
|
99
|
+
* Untappd just now or from the local cache — so a caller never sees different
|
|
100
|
+
* keys depending on whether the cache happened to open. Both paths first map
|
|
101
|
+
* the page through `mapBreweryBeerItem` (store.ts), which is where the
|
|
102
|
+
* endpoint's own naming is read: `/brewery/beer_list/` says `total_count` (not
|
|
103
|
+
* `checkin_count`) and `has_had` (not `have_had`), established against a live
|
|
104
|
+
* capture of brewery 1142.
|
|
129
105
|
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
106
|
+
* It evolves the old live-only projection as a superset: every item keeps
|
|
107
|
+
* bid/name/style/abv/ibu/brewery/rating/rating_count/checkin_count and gains
|
|
108
|
+
* in_production (+ favorite when the cache marks one). Two old item fields are
|
|
109
|
+
* gone on purpose: `have_had` / `your_count` were the item's `has_had` /
|
|
110
|
+
* `total_user_count`, which describe the AUTHENTICATED account, never the user
|
|
111
|
+
* being asked about. The old top-level `total_count` and `beers.count` stay;
|
|
112
|
+
* Untappd's `sorting_options` (UI chrome duplicating the input enum) stays
|
|
113
|
+
* dropped; `pagination` is ours, so paging reads the same off either path.
|
|
134
114
|
*/
|
|
135
|
-
export function
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
115
|
+
export function compactBreweryBeerList(r, page, favorites) {
|
|
116
|
+
const count = r.beers.length;
|
|
117
|
+
const more = r.total_count !== null ? page.offset + count < r.total_count : count > 0 && count >= page.limit;
|
|
118
|
+
return {
|
|
119
|
+
brewery_id: r.brewery_id,
|
|
120
|
+
brewery_name: r.brewery_name,
|
|
121
|
+
total_count: r.total_count,
|
|
122
|
+
source: r.source,
|
|
123
|
+
fetched_at: r.fetched_at,
|
|
124
|
+
expires_at: r.expires_at,
|
|
125
|
+
// Always present (null when favorites aren't known: no cache, or no username) so
|
|
126
|
+
// the cached and no-cache paths return the same keys.
|
|
127
|
+
favorite: favorites ? favorites.brewery : null,
|
|
128
|
+
pagination: { offset: page.offset, limit: page.limit, next_offset: more ? page.offset + count : null },
|
|
129
|
+
beers: {
|
|
130
|
+
count,
|
|
131
|
+
items: r.beers.map((b) => {
|
|
132
|
+
const fav = favorites?.beer(b.bid);
|
|
133
|
+
return {
|
|
134
|
+
bid: b.bid,
|
|
135
|
+
name: b.beer_name,
|
|
136
|
+
style: b.style,
|
|
137
|
+
abv: b.abv,
|
|
138
|
+
ibu: b.ibu,
|
|
139
|
+
brewery: r.brewery_name,
|
|
140
|
+
rating: b.rating_score,
|
|
141
|
+
rating_count: b.rating_count,
|
|
142
|
+
checkin_count: b.checkin_count,
|
|
143
|
+
in_production: b.in_production,
|
|
144
|
+
...(fav !== undefined ? { favorite: fav } : {}),
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
142
149
|
}
|
|
143
150
|
/**
|
|
144
151
|
* The rungs this server honours (`@chrischall/mcp-utils`' `view` vocabulary,
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,8 @@ import { registerWishlistTools } from './tools/wishlist.js';
|
|
|
14
14
|
import { registerCheckinTools } from './tools/checkin.js';
|
|
15
15
|
import { registerUtilityTools } from './tools/utilities.js';
|
|
16
16
|
import { registerCacheTools } from './tools/cache.js';
|
|
17
|
+
import { registerFavoriteTools } from './tools/favorites.js';
|
|
18
|
+
import { registerTripTools } from './tools/trip.js';
|
|
17
19
|
import { registerMenuTools } from './tools/menu.js';
|
|
18
20
|
import { logRegisteredTools } from './tools/diagnostics.js';
|
|
19
21
|
import { CheckinCache, defaultCachePath, describeCachePath } from './cache/db.js';
|
|
@@ -28,15 +30,28 @@ const client = new UntappdClient();
|
|
|
28
30
|
const cachePath = defaultCachePath();
|
|
29
31
|
console.error(describeCachePath(cachePath));
|
|
30
32
|
let nodeCache;
|
|
31
|
-
const nodeCacheProvider = () =>
|
|
33
|
+
const nodeCacheProvider = () => {
|
|
34
|
+
if (!nodeCache) {
|
|
35
|
+
nodeCache = CheckinCache.open(cachePath);
|
|
36
|
+
// Hand the shared call log what this process already spent before the cache opened.
|
|
37
|
+
void nodeCache.recordApiCalls?.(client.rateLimit.observations()).catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
return nodeCache;
|
|
40
|
+
};
|
|
41
|
+
// Persist every API response's rate-limit observation into the shared cache file
|
|
42
|
+
// (once it is open), so a second process — or the next one — counts this hour's
|
|
43
|
+
// calls too (src/ratelimit.ts). Recording never fails an API call.
|
|
44
|
+
client.onApiCall((o) => {
|
|
45
|
+
void nodeCache?.recordApiCalls?.([o]).catch(() => { });
|
|
46
|
+
});
|
|
32
47
|
await runMcp({
|
|
33
48
|
name: 'untappd-mcp',
|
|
34
49
|
version: VERSION,
|
|
35
50
|
banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
|
|
36
51
|
tools: [
|
|
37
52
|
(s) => registerBeerTools(s, client, nodeCacheProvider),
|
|
38
|
-
(s) => registerBreweryTools(s, client),
|
|
39
|
-
(s) => registerVenueTools(s, client),
|
|
53
|
+
(s) => registerBreweryTools(s, client, nodeCacheProvider),
|
|
54
|
+
(s) => registerVenueTools(s, client, nodeCacheProvider),
|
|
40
55
|
(s) => registerUserTools(s, client),
|
|
41
56
|
(s) => registerFeedTools(s, client),
|
|
42
57
|
(s) => registerResolveTools(s, client),
|
|
@@ -48,6 +63,8 @@ await runMcp({
|
|
|
48
63
|
// The cache is backed by a local `node:sqlite` file here; the provider is
|
|
49
64
|
// injectable, so another deployment can back the same tools differently.
|
|
50
65
|
(s) => registerCacheTools(s, client, nodeCacheProvider),
|
|
66
|
+
(s) => registerFavoriteTools(s, client, nodeCacheProvider),
|
|
67
|
+
(s) => registerTripTools(s, client, nodeCacheProvider),
|
|
51
68
|
(s) => registerMenuTools(s, client, nodeCacheProvider),
|
|
52
69
|
// Keep last: logs the full registered toolset (count + names) at startup.
|
|
53
70
|
(s) => logRegisteredTools(s, 'stdio'),
|
package/dist/menu/pick.js
CHANGED
|
@@ -92,7 +92,11 @@ export async function ensureFresh(client, cache, username, maxCalls) {
|
|
|
92
92
|
const prior = await cache.getState(username);
|
|
93
93
|
const mode = prior?.beers_complete ? 'incremental' : 'backfill';
|
|
94
94
|
let calls = 0;
|
|
95
|
-
const counting = {
|
|
95
|
+
const counting = {
|
|
96
|
+
get: (p, q) => (calls++, client.get(p, q)),
|
|
97
|
+
// Hand the sync's rate guard the real tracker (absent on test fakes).
|
|
98
|
+
rateLimit: client.rateLimit,
|
|
99
|
+
};
|
|
96
100
|
const extra = {};
|
|
97
101
|
if (maxCalls < 1) {
|
|
98
102
|
extra.skipped = 'no API budget left for a sync this run';
|
|
@@ -102,6 +106,8 @@ export async function ensureFresh(client, cache, username, maxCalls) {
|
|
|
102
106
|
const s = await syncUserBeers(counting, cache, username, maxCalls);
|
|
103
107
|
if (s.incremental)
|
|
104
108
|
extra.stopped_at_bid = s.stopped_at_bid;
|
|
109
|
+
if (s.stopped_for_rate_limit)
|
|
110
|
+
extra.rate_limited = true;
|
|
105
111
|
}
|
|
106
112
|
catch (e) {
|
|
107
113
|
if (e instanceof RateLimitError)
|
|
@@ -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
|
+
}
|