untappd-mcp 1.2.0 → 1.3.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/dist/bundle.js CHANGED
@@ -101,7 +101,7 @@ var require_code = __commonJS({
101
101
  }
102
102
  exports._ = _;
103
103
  var plus = new _Code("+");
104
- function str(strs, ...args) {
104
+ function str2(strs, ...args) {
105
105
  const expr = [safeStringify(strs[0])];
106
106
  let i = 0;
107
107
  while (i < args.length) {
@@ -112,7 +112,7 @@ var require_code = __commonJS({
112
112
  optimize(expr);
113
113
  return new _Code(expr);
114
114
  }
115
- exports.str = str;
115
+ exports.str = str2;
116
116
  function addCodeArg(code, arg) {
117
117
  if (arg instanceof _Code)
118
118
  code.push(...arg._items);
@@ -155,7 +155,7 @@ var require_code = __commonJS({
155
155
  return;
156
156
  }
157
157
  function strConcat(c1, c2) {
158
- return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str`${c1}${c2}`;
158
+ return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str2`${c1}${c2}`;
159
159
  }
160
160
  exports.strConcat = strConcat;
161
161
  function interpolate(x) {
@@ -1117,22 +1117,22 @@ var require_util = __commonJS({
1117
1117
  return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
1118
1118
  }
1119
1119
  exports.schemaRefOrVal = schemaRefOrVal;
1120
- function unescapeFragment(str) {
1121
- return unescapeJsonPointer(decodeURIComponent(str));
1120
+ function unescapeFragment(str2) {
1121
+ return unescapeJsonPointer(decodeURIComponent(str2));
1122
1122
  }
1123
1123
  exports.unescapeFragment = unescapeFragment;
1124
- function escapeFragment(str) {
1125
- return encodeURIComponent(escapeJsonPointer(str));
1124
+ function escapeFragment(str2) {
1125
+ return encodeURIComponent(escapeJsonPointer(str2));
1126
1126
  }
1127
1127
  exports.escapeFragment = escapeFragment;
1128
- function escapeJsonPointer(str) {
1129
- if (typeof str == "number")
1130
- return `${str}`;
1131
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
1128
+ function escapeJsonPointer(str2) {
1129
+ if (typeof str2 == "number")
1130
+ return `${str2}`;
1131
+ return str2.replace(/~/g, "~0").replace(/\//g, "~1");
1132
1132
  }
1133
1133
  exports.escapeJsonPointer = escapeJsonPointer;
1134
- function unescapeJsonPointer(str) {
1135
- return str.replace(/~1/g, "/").replace(/~0/g, "~");
1134
+ function unescapeJsonPointer(str2) {
1135
+ return str2.replace(/~1/g, "/").replace(/~0/g, "~");
1136
1136
  }
1137
1137
  exports.unescapeJsonPointer = unescapeJsonPointer;
1138
1138
  function eachItem(xs, f) {
@@ -2157,8 +2157,8 @@ var require_json_schema_traverse = __commonJS({
2157
2157
  post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
2158
2158
  }
2159
2159
  }
2160
- function escapeJsonPtr(str) {
2161
- return str.replace(/~/g, "~0").replace(/\//g, "~1");
2160
+ function escapeJsonPtr(str2) {
2161
+ return str2.replace(/~/g, "~0").replace(/\//g, "~1");
2162
2162
  }
2163
2163
  }
2164
2164
  });
@@ -3222,10 +3222,10 @@ var require_utils = __commonJS({
3222
3222
  return { host, isIPV6: false };
3223
3223
  }
3224
3224
  }
3225
- function findToken(str, token) {
3225
+ function findToken(str2, token) {
3226
3226
  let ind = 0;
3227
- for (let i = 0; i < str.length; i++) {
3228
- if (str[i] === token) ind++;
3227
+ for (let i = 0; i < str2.length; i++) {
3228
+ if (str2[i] === token) ind++;
3229
3229
  }
3230
3230
  return ind;
3231
3231
  }
@@ -3962,7 +3962,7 @@ var require_core = __commonJS({
3962
3962
  var util_1 = require_util();
3963
3963
  var $dataRefSchema = require_data();
3964
3964
  var uri_1 = require_uri();
3965
- var defaultRegExp = (str, flags) => new RegExp(str, flags);
3965
+ var defaultRegExp = (str2, flags) => new RegExp(str2, flags);
3966
3966
  defaultRegExp.code = "new RegExp";
3967
3967
  var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
3968
3968
  var EXT_SCOPE_NAMES = /* @__PURE__ */ new Set([
@@ -4757,16 +4757,16 @@ var require_ucs2length = __commonJS({
4757
4757
  "node_modules/ajv/dist/runtime/ucs2length.js"(exports) {
4758
4758
  "use strict";
4759
4759
  Object.defineProperty(exports, "__esModule", { value: true });
4760
- function ucs2length(str) {
4761
- const len = str.length;
4760
+ function ucs2length(str2) {
4761
+ const len = str2.length;
4762
4762
  let length = 0;
4763
4763
  let pos = 0;
4764
4764
  let value;
4765
4765
  while (pos < len) {
4766
4766
  length++;
4767
- value = str.charCodeAt(pos++);
4767
+ value = str2.charCodeAt(pos++);
4768
4768
  if (value >= 55296 && value <= 56319 && pos < len) {
4769
- value = str.charCodeAt(pos);
4769
+ value = str2.charCodeAt(pos);
4770
4770
  if ((value & 64512) === 56320)
4771
4771
  pos++;
4772
4772
  }
@@ -6649,8 +6649,8 @@ var require_formats = __commonJS({
6649
6649
  }
6650
6650
  var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
6651
6651
  var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
6652
- function date5(str) {
6653
- const matches = DATE.exec(str);
6652
+ function date5(str2) {
6653
+ const matches = DATE.exec(str2);
6654
6654
  if (!matches)
6655
6655
  return false;
6656
6656
  const year = +matches[1];
@@ -6669,8 +6669,8 @@ var require_formats = __commonJS({
6669
6669
  }
6670
6670
  var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
6671
6671
  function getTime(strictTimeZone) {
6672
- return function time3(str) {
6673
- const matches = TIME.exec(str);
6672
+ return function time3(str2) {
6673
+ const matches = TIME.exec(str2);
6674
6674
  if (!matches)
6675
6675
  return false;
6676
6676
  const hr = +matches[1];
@@ -6716,8 +6716,8 @@ var require_formats = __commonJS({
6716
6716
  var DATE_TIME_SEPARATOR = /t|\s/i;
6717
6717
  function getDateTime(strictTimeZone) {
6718
6718
  const time3 = getTime(strictTimeZone);
6719
- return function date_time(str) {
6720
- const dateTime = str.split(DATE_TIME_SEPARATOR);
6719
+ return function date_time(str2) {
6720
+ const dateTime = str2.split(DATE_TIME_SEPARATOR);
6721
6721
  return dateTime.length === 2 && date5(dateTime[0]) && time3(dateTime[1]);
6722
6722
  };
6723
6723
  }
@@ -6742,13 +6742,13 @@ var require_formats = __commonJS({
6742
6742
  }
6743
6743
  var NOT_URI_FRAGMENT = /\/|:/;
6744
6744
  var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
6745
- function uri(str) {
6746
- return NOT_URI_FRAGMENT.test(str) && URI.test(str);
6745
+ function uri(str2) {
6746
+ return NOT_URI_FRAGMENT.test(str2) && URI.test(str2);
6747
6747
  }
6748
6748
  var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
6749
- function byte(str) {
6749
+ function byte(str2) {
6750
6750
  BYTE.lastIndex = 0;
6751
- return BYTE.test(str);
6751
+ return BYTE.test(str2);
6752
6752
  }
6753
6753
  var MIN_INT32 = -(2 ** 31);
6754
6754
  var MAX_INT32 = 2 ** 31 - 1;
@@ -6762,11 +6762,11 @@ var require_formats = __commonJS({
6762
6762
  return true;
6763
6763
  }
6764
6764
  var Z_ANCHOR = /[^\\]\\Z/;
6765
- function regex(str) {
6766
- if (Z_ANCHOR.test(str))
6765
+ function regex(str2) {
6766
+ if (Z_ANCHOR.test(str2))
6767
6767
  return false;
6768
6768
  try {
6769
- new RegExp(str);
6769
+ new RegExp(str2);
6770
6770
  return true;
6771
6771
  } catch (e) {
6772
6772
  return false;
@@ -11306,14 +11306,14 @@ function promiseAllObject(promisesObj) {
11306
11306
  }
11307
11307
  function randomString(length = 10) {
11308
11308
  const chars = "abcdefghijklmnopqrstuvwxyz";
11309
- let str = "";
11309
+ let str2 = "";
11310
11310
  for (let i = 0; i < length; i++) {
11311
- str += chars[Math.floor(Math.random() * chars.length)];
11311
+ str2 += chars[Math.floor(Math.random() * chars.length)];
11312
11312
  }
11313
- return str;
11313
+ return str2;
11314
11314
  }
11315
- function esc(str) {
11316
- return JSON.stringify(str);
11315
+ function esc(str2) {
11316
+ return JSON.stringify(str2);
11317
11317
  }
11318
11318
  function slugify(input) {
11319
11319
  return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
@@ -11427,8 +11427,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
11427
11427
  "symbol",
11428
11428
  "undefined"
11429
11429
  ]);
11430
- function escapeRegex(str) {
11431
- return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11430
+ function escapeRegex(str2) {
11431
+ return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
11432
11432
  }
11433
11433
  function clone(inst, def, params) {
11434
11434
  const cl = new inst._zod.constr(def ?? inst._zod.def);
@@ -31067,8 +31067,8 @@ function redactSecrets(text) {
31067
31067
  return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
31068
31068
  }
31069
31069
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
31070
- const str = text === null || text === void 0 ? "" : String(text);
31071
- const redacted = redactSecrets(str);
31070
+ const str2 = text === null || text === void 0 ? "" : String(text);
31071
+ const redacted = redactSecrets(str2);
31072
31072
  if (redacted.length <= max)
31073
31073
  return redacted;
31074
31074
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
@@ -31184,7 +31184,7 @@ function toolAnnotations(opts = {}) {
31184
31184
  }
31185
31185
 
31186
31186
  // src/version.ts
31187
- var VERSION = "1.2.0";
31187
+ var VERSION = "1.3.1";
31188
31188
 
31189
31189
  // src/client.ts
31190
31190
  import { dirname, join } from "path";
@@ -32363,6 +32363,568 @@ function registerUtilityTools(server, client2) {
32363
32363
  );
32364
32364
  }
32365
32365
 
32366
+ // src/cache/db.ts
32367
+ import { DatabaseSync } from "node:sqlite";
32368
+ import { mkdirSync } from "node:fs";
32369
+ import { dirname as dirname2, join as join2 } from "node:path";
32370
+ import { homedir } from "node:os";
32371
+ function asDict2(v) {
32372
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
32373
+ }
32374
+ function num(v) {
32375
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
32376
+ }
32377
+ function str(v) {
32378
+ return typeof v === "string" && v.length > 0 ? v : null;
32379
+ }
32380
+ function mapCheckinRow(username, item) {
32381
+ const c = asDict2(item);
32382
+ const checkin_id = num(c.checkin_id);
32383
+ if (checkin_id === null) return null;
32384
+ const beer = asDict2(c.beer);
32385
+ const brewery = asDict2(c.brewery);
32386
+ const venue = asDict2(c.venue);
32387
+ return {
32388
+ checkin_id,
32389
+ username,
32390
+ bid: num(beer.bid),
32391
+ beer_name: str(beer.beer_name),
32392
+ brewery_id: num(brewery.brewery_id),
32393
+ brewery_name: str(brewery.brewery_name),
32394
+ beer_style: str(beer.beer_style),
32395
+ abv: num(beer.beer_abv),
32396
+ rating: num(c.rating_score),
32397
+ comment: str(c.checkin_comment),
32398
+ venue_id: num(venue.venue_id),
32399
+ venue_name: str(venue.venue_name),
32400
+ created_at: normaliseDate(c.created_at)
32401
+ };
32402
+ }
32403
+ function normaliseDate(v) {
32404
+ const s = str(v);
32405
+ if (!s) return null;
32406
+ const t = Date.parse(s);
32407
+ return Number.isNaN(t) ? s : new Date(t).toISOString();
32408
+ }
32409
+ var SCHEMA = `
32410
+ CREATE TABLE IF NOT EXISTS checkins (
32411
+ checkin_id INTEGER PRIMARY KEY,
32412
+ username TEXT NOT NULL,
32413
+ bid INTEGER,
32414
+ beer_name TEXT,
32415
+ brewery_id INTEGER,
32416
+ brewery_name TEXT,
32417
+ beer_style TEXT,
32418
+ abv REAL,
32419
+ rating REAL,
32420
+ comment TEXT,
32421
+ venue_id INTEGER,
32422
+ venue_name TEXT,
32423
+ created_at TEXT
32424
+ );
32425
+ CREATE INDEX IF NOT EXISTS idx_checkins_user_bid ON checkins(username, bid);
32426
+ CREATE INDEX IF NOT EXISTS idx_checkins_user_brewery ON checkins(username, brewery_id);
32427
+
32428
+ CREATE TABLE IF NOT EXISTS sync_state (
32429
+ username TEXT PRIMARY KEY,
32430
+ oldest_max_id INTEGER,
32431
+ newest_checkin_id INTEGER,
32432
+ catchup_max_id INTEGER,
32433
+ last_synced_at TEXT,
32434
+ backfill_complete INTEGER NOT NULL DEFAULT 0,
32435
+ total_checkins INTEGER
32436
+ );
32437
+ `;
32438
+ var CheckinCache = class _CheckinCache {
32439
+ db;
32440
+ constructor(db) {
32441
+ this.db = db;
32442
+ this.db.exec(SCHEMA);
32443
+ this.migrate();
32444
+ }
32445
+ // Forward-compat for a sync_state table created before a column existed. Each
32446
+ // ADD COLUMN throws harmlessly if the column is already present.
32447
+ migrate() {
32448
+ for (const col of ["catchup_max_id INTEGER"]) {
32449
+ try {
32450
+ this.db.exec(`ALTER TABLE sync_state ADD COLUMN ${col}`);
32451
+ } catch {
32452
+ }
32453
+ }
32454
+ }
32455
+ /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
32456
+ static open(path) {
32457
+ if (path !== ":memory:") mkdirSync(dirname2(path), { recursive: true });
32458
+ return new _CheckinCache(new DatabaseSync(path));
32459
+ }
32460
+ close() {
32461
+ this.db.close();
32462
+ }
32463
+ /**
32464
+ * Upsert a batch of rows (dedupe on checkin_id) inside one transaction and
32465
+ * return the NET number of newly-inserted rows (updates to existing rows count
32466
+ * as 0), which is what "rows added" reports.
32467
+ */
32468
+ upsertCheckins(username, rows) {
32469
+ if (rows.length === 0) return 0;
32470
+ const key = username.toLowerCase();
32471
+ const before = this.cachedCount(username);
32472
+ const stmt = this.db.prepare(`
32473
+ INSERT INTO checkins
32474
+ (checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
32475
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
32476
+ ON CONFLICT(checkin_id) DO UPDATE SET
32477
+ username=excluded.username, bid=excluded.bid, beer_name=excluded.beer_name,
32478
+ brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
32479
+ abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
32480
+ venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at
32481
+ `);
32482
+ this.db.exec("BEGIN");
32483
+ try {
32484
+ for (const r of rows) {
32485
+ stmt.run(
32486
+ r.checkin_id,
32487
+ key,
32488
+ r.bid,
32489
+ r.beer_name,
32490
+ r.brewery_id,
32491
+ r.brewery_name,
32492
+ r.beer_style,
32493
+ r.abv,
32494
+ r.rating,
32495
+ r.comment,
32496
+ r.venue_id,
32497
+ r.venue_name,
32498
+ r.created_at
32499
+ );
32500
+ }
32501
+ this.db.exec("COMMIT");
32502
+ } catch (e) {
32503
+ this.db.exec("ROLLBACK");
32504
+ throw e;
32505
+ }
32506
+ return this.cachedCount(username) - before;
32507
+ }
32508
+ /** Total cached check-ins for a user (= distinct check-ins, since PK is checkin_id). */
32509
+ cachedCount(username) {
32510
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM checkins WHERE username = ?").get(username.toLowerCase());
32511
+ return row.n;
32512
+ }
32513
+ /** Distinct beers (unique bids) cached for a user. */
32514
+ distinctBeerCount(username) {
32515
+ const row = this.db.prepare("SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL").get(username.toLowerCase());
32516
+ return row.n;
32517
+ }
32518
+ /** Highest cached checkin_id for a user (the true newest once contiguous), or null. */
32519
+ newestCachedId(username) {
32520
+ const row = this.db.prepare("SELECT MAX(checkin_id) AS m FROM checkins WHERE username = ?").get(username.toLowerCase());
32521
+ return row.m;
32522
+ }
32523
+ getState(username) {
32524
+ const row = this.db.prepare("SELECT * FROM sync_state WHERE username = ?").get(username.toLowerCase());
32525
+ if (!row) return void 0;
32526
+ return { ...row, username: row.username, backfill_complete: row.backfill_complete === 1 };
32527
+ }
32528
+ /**
32529
+ * Insert-or-merge sync state. Only keys PRESENT in `patch` are written, so an
32530
+ * explicit `null` (e.g. clearing `oldest_max_id` when backfill completes)
32531
+ * overrides the stored value — a `??` merge could not do that.
32532
+ */
32533
+ setState(username, patch) {
32534
+ const key = username.toLowerCase();
32535
+ const cur = this.getState(key);
32536
+ const pick2 = (k, fallback) => k in patch ? patch[k] : cur?.[k] ?? fallback;
32537
+ const next = {
32538
+ username: key,
32539
+ oldest_max_id: pick2("oldest_max_id", null),
32540
+ newest_checkin_id: pick2("newest_checkin_id", null),
32541
+ catchup_max_id: pick2("catchup_max_id", null),
32542
+ last_synced_at: pick2("last_synced_at", null),
32543
+ backfill_complete: pick2("backfill_complete", false),
32544
+ total_checkins: pick2("total_checkins", null)
32545
+ };
32546
+ this.db.prepare(
32547
+ `INSERT INTO sync_state (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins)
32548
+ VALUES (?, ?, ?, ?, ?, ?, ?)
32549
+ ON CONFLICT(username) DO UPDATE SET
32550
+ oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
32551
+ catchup_max_id=excluded.catchup_max_id,
32552
+ last_synced_at=excluded.last_synced_at, backfill_complete=excluded.backfill_complete,
32553
+ total_checkins=excluded.total_checkins`
32554
+ ).run(
32555
+ key,
32556
+ next.oldest_max_id,
32557
+ next.newest_checkin_id,
32558
+ next.catchup_max_id,
32559
+ next.last_synced_at,
32560
+ next.backfill_complete ? 1 : 0,
32561
+ next.total_checkins
32562
+ );
32563
+ }
32564
+ /** Exact bid match or case-insensitive substring match on beer name. */
32565
+ hasHad(username, opts) {
32566
+ const where = ["username = ?"];
32567
+ const params = [username.toLowerCase()];
32568
+ if (opts.bid !== void 0) {
32569
+ where.push("bid = ?");
32570
+ params.push(opts.bid);
32571
+ }
32572
+ if (opts.beerName !== void 0) {
32573
+ where.push("beer_name LIKE ? COLLATE NOCASE");
32574
+ params.push(`%${escapeLike(opts.beerName)}%`);
32575
+ }
32576
+ const clause = where.join(" AND ");
32577
+ const agg = this.db.prepare(`SELECT COUNT(*) AS n, MAX(rating) AS best FROM checkins WHERE ${clause}`).get(...params);
32578
+ const matches = this.db.prepare(
32579
+ `SELECT checkin_id, bid, beer_name, rating, venue_name, created_at
32580
+ FROM checkins WHERE ${clause} ORDER BY checkin_id DESC LIMIT 25`
32581
+ ).all(...params);
32582
+ return {
32583
+ had: agg.n > 0,
32584
+ count: agg.n,
32585
+ last_date: matches[0]?.created_at ?? null,
32586
+ best_rating: agg.best,
32587
+ matches
32588
+ };
32589
+ }
32590
+ query(username, filters) {
32591
+ const where = ["username = ?"];
32592
+ const params = [username.toLowerCase()];
32593
+ if (filters.brewery_id !== void 0) {
32594
+ where.push("brewery_id = ?");
32595
+ params.push(filters.brewery_id);
32596
+ }
32597
+ if (filters.brewery !== void 0) {
32598
+ where.push("brewery_name LIKE ? COLLATE NOCASE");
32599
+ params.push(`%${escapeLike(filters.brewery)}%`);
32600
+ }
32601
+ if (filters.style !== void 0) {
32602
+ where.push("beer_style LIKE ? COLLATE NOCASE");
32603
+ params.push(`%${escapeLike(filters.style)}%`);
32604
+ }
32605
+ if (filters.min_rating !== void 0) {
32606
+ where.push("rating >= ?");
32607
+ params.push(filters.min_rating);
32608
+ }
32609
+ if (filters.venue !== void 0) {
32610
+ where.push("venue_name LIKE ? COLLATE NOCASE");
32611
+ params.push(`%${escapeLike(filters.venue)}%`);
32612
+ }
32613
+ if (filters.venue_id !== void 0) {
32614
+ where.push("venue_id = ?");
32615
+ params.push(filters.venue_id);
32616
+ }
32617
+ if (filters.date_from !== void 0) {
32618
+ where.push("substr(created_at, 1, 10) >= ?");
32619
+ params.push(filters.date_from);
32620
+ }
32621
+ if (filters.date_to !== void 0) {
32622
+ where.push("substr(created_at, 1, 10) <= ?");
32623
+ params.push(filters.date_to);
32624
+ }
32625
+ const order = filters.sort === "oldest" ? "checkin_id ASC" : filters.sort === "highest_rated" ? "rating DESC, checkin_id DESC" : filters.sort === "lowest_rated" ? "rating ASC, checkin_id DESC" : "checkin_id DESC";
32626
+ const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
32627
+ const rows = this.db.prepare(`SELECT * FROM checkins WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`).all(...params, limit);
32628
+ return rows;
32629
+ }
32630
+ };
32631
+ function escapeLike(s) {
32632
+ return s.replace(/[%_]/g, " ");
32633
+ }
32634
+ function defaultCachePath() {
32635
+ return readEnvVar("UNTAPPD_CACHE_DB") ?? join2(homedir(), ".untappd-mcp", "checkins.db");
32636
+ }
32637
+
32638
+ // src/cache/sync.ts
32639
+ var PAGE_LIMIT = 50;
32640
+ async function fetchPage(client2, encodedUser, maxId) {
32641
+ const data = await client2.get(
32642
+ `/user/checkins/${encodedUser}`,
32643
+ { limit: PAGE_LIMIT, max_id: maxId }
32644
+ );
32645
+ const box = data?.checkins ?? {};
32646
+ const items = Array.isArray(box.items) ? box.items : [];
32647
+ const raw = box.pagination?.max_id;
32648
+ const nextMaxId = typeof raw === "number" && raw > 0 ? raw : null;
32649
+ return { items, nextMaxId };
32650
+ }
32651
+ function rowsOf(username, items) {
32652
+ const rows = [];
32653
+ for (const it of items) {
32654
+ const r = mapCheckinRow(username, it);
32655
+ if (r) rows.push(r);
32656
+ }
32657
+ return rows;
32658
+ }
32659
+ async function fetchTotalCheckins(client2, encodedUser) {
32660
+ try {
32661
+ const info = await client2.get(`/user/info/${encodedUser}`);
32662
+ const t = info?.user?.stats?.total_checkins;
32663
+ return typeof t === "number" ? t : null;
32664
+ } catch {
32665
+ return null;
32666
+ }
32667
+ }
32668
+ async function syncCheckins(client2, cache, rawUsername, maxPages = 10) {
32669
+ const encodedUser = encodeURIComponent(rawUsername);
32670
+ const prior = cache.getState(rawUsername);
32671
+ const priorNewest = prior?.newest_checkin_id ?? null;
32672
+ let pages = 0;
32673
+ let added = 0;
32674
+ let newest = priorNewest;
32675
+ let backfillComplete = prior?.backfill_complete ?? false;
32676
+ let oldestMaxId = prior?.oldest_max_id ?? null;
32677
+ let catchupMaxId = prior?.catchup_max_id ?? null;
32678
+ let catchupInProgress = catchupMaxId !== null;
32679
+ const now = () => (/* @__PURE__ */ new Date()).toISOString();
32680
+ const firstFetch = async (maxId) => {
32681
+ try {
32682
+ return await fetchPage(client2, encodedUser, maxId);
32683
+ } catch (e) {
32684
+ if (e instanceof RateLimitError) throw e;
32685
+ throw createHelpfulError(`Could not fetch check-ins for "${rawUsername}": ${messageOf(e)}`, {
32686
+ hint: "Untappd only returns another user's check-ins if their account is public or they're your friend. If the account is private, add them as a friend (untappd_add_friend) first, or sync only your own account."
32687
+ });
32688
+ }
32689
+ };
32690
+ if (priorNewest !== null) {
32691
+ let maxId = catchupMaxId ?? void 0;
32692
+ let caughtUp = false;
32693
+ for (; pages < maxPages; ) {
32694
+ const page = pages === 0 ? await firstFetch(maxId) : await fetchPage(client2, encodedUser, maxId);
32695
+ pages++;
32696
+ if (page.items.length === 0) {
32697
+ caughtUp = true;
32698
+ break;
32699
+ }
32700
+ const rows = rowsOf(rawUsername, page.items);
32701
+ added += cache.upsertCheckins(rawUsername, rows);
32702
+ const oldestOnPage = rows[rows.length - 1].checkin_id;
32703
+ if (oldestOnPage <= priorNewest) {
32704
+ caughtUp = true;
32705
+ break;
32706
+ }
32707
+ if (page.nextMaxId === null) {
32708
+ caughtUp = true;
32709
+ backfillComplete = true;
32710
+ oldestMaxId = null;
32711
+ break;
32712
+ }
32713
+ catchupMaxId = page.nextMaxId;
32714
+ cache.setState(rawUsername, { catchup_max_id: catchupMaxId, last_synced_at: now() });
32715
+ maxId = page.nextMaxId;
32716
+ }
32717
+ if (caughtUp) {
32718
+ newest = cache.newestCachedId(rawUsername);
32719
+ catchupMaxId = null;
32720
+ catchupInProgress = false;
32721
+ cache.setState(rawUsername, {
32722
+ newest_checkin_id: newest,
32723
+ catchup_max_id: null,
32724
+ backfill_complete: backfillComplete,
32725
+ oldest_max_id: oldestMaxId,
32726
+ last_synced_at: now()
32727
+ });
32728
+ } else {
32729
+ catchupInProgress = true;
32730
+ }
32731
+ }
32732
+ if (!backfillComplete && !catchupInProgress) {
32733
+ let maxId = oldestMaxId ?? void 0;
32734
+ for (; pages < maxPages; ) {
32735
+ const first = pages === 0;
32736
+ const page = first ? await firstFetch(maxId) : await fetchPage(client2, encodedUser, maxId);
32737
+ pages++;
32738
+ if (page.items.length === 0) {
32739
+ backfillComplete = true;
32740
+ break;
32741
+ }
32742
+ const rows = rowsOf(rawUsername, page.items);
32743
+ added += cache.upsertCheckins(rawUsername, rows);
32744
+ if (newest === null) newest = rows[0].checkin_id;
32745
+ oldestMaxId = page.nextMaxId;
32746
+ if (page.nextMaxId === null) backfillComplete = true;
32747
+ cache.setState(rawUsername, {
32748
+ newest_checkin_id: newest,
32749
+ oldest_max_id: oldestMaxId,
32750
+ backfill_complete: backfillComplete,
32751
+ last_synced_at: now()
32752
+ });
32753
+ if (backfillComplete) break;
32754
+ maxId = page.nextMaxId ?? void 0;
32755
+ }
32756
+ }
32757
+ const total = await fetchTotalCheckins(client2, encodedUser);
32758
+ const cached2 = cache.cachedCount(rawUsername);
32759
+ cache.setState(rawUsername, {
32760
+ newest_checkin_id: newest,
32761
+ oldest_max_id: oldestMaxId,
32762
+ catchup_max_id: catchupMaxId,
32763
+ backfill_complete: backfillComplete,
32764
+ total_checkins: total,
32765
+ last_synced_at: now()
32766
+ });
32767
+ const backfillPercent = total && total > 0 ? Math.min(100, Math.round(cached2 / total * 100)) : backfillComplete && !catchupInProgress ? 100 : null;
32768
+ const anotherRunNeeded = !backfillComplete || catchupInProgress;
32769
+ const note = catchupInProgress ? "More new check-ins remain than fit in this run \u2014 run untappd_sync_checkins again to finish catching up the newest check-ins." : backfillComplete ? "Full history is cached. Re-run occasionally to pick up new check-ins." : `Backfill incomplete \u2014 run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
32770
+ return {
32771
+ username: rawUsername,
32772
+ rows_added: added,
32773
+ pages_fetched: pages,
32774
+ cached_checkins: cached2,
32775
+ distinct_beers: cache.distinctBeerCount(rawUsername),
32776
+ total_checkins: total,
32777
+ backfill_percent: backfillPercent,
32778
+ backfill_complete: backfillComplete,
32779
+ catchup_in_progress: catchupInProgress,
32780
+ another_run_needed: anotherRunNeeded,
32781
+ last_synced_at: now(),
32782
+ note
32783
+ };
32784
+ }
32785
+
32786
+ // src/tools/cache.ts
32787
+ var UsernameArg2 = external_exports.string().min(1).optional().describe("Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).");
32788
+ function resolveUser2(username, loginName) {
32789
+ const u = username ?? loginName;
32790
+ if (!u) {
32791
+ throw createHelpfulError("No username given and no configured account to fall back to.", {
32792
+ hint: "Pass `username`, or set UNTAPPD_USERNAME so cache tools default to your own account."
32793
+ });
32794
+ }
32795
+ return u;
32796
+ }
32797
+ function freshness(cache, username) {
32798
+ const state = cache.getState(username);
32799
+ const cached2 = cache.cachedCount(username);
32800
+ const backfillComplete = state?.backfill_complete ?? false;
32801
+ const percent = state?.total_checkins && state.total_checkins > 0 ? Math.min(100, Math.round(cached2 / state.total_checkins * 100)) : backfillComplete ? 100 : null;
32802
+ const catchupInProgress = (state?.catchup_max_id ?? null) !== null;
32803
+ let caveat;
32804
+ if (!state || state.last_synced_at === null) {
32805
+ caveat = "This user has never been synced \u2014 the cache is EMPTY. Run untappd_sync_checkins first; results here are not authoritative.";
32806
+ } else if (catchupInProgress) {
32807
+ caveat = "A batch of the newest check-ins is still being caught up (last sync ran out of pages). Recent check-ins may be missing \u2014 run untappd_sync_checkins again to finish.";
32808
+ } else if (!backfillComplete) {
32809
+ caveat = `Backfill is incomplete${percent !== null ? ` (~${percent}% of history cached)` : ""}. A "not found" result may be a false negative for older check-ins \u2014 run untappd_sync_checkins again to keep backfilling.`;
32810
+ }
32811
+ return {
32812
+ last_synced_at: state?.last_synced_at ?? null,
32813
+ backfill_complete: backfillComplete,
32814
+ catchup_in_progress: catchupInProgress,
32815
+ backfill_percent: percent,
32816
+ cached_checkins: cached2,
32817
+ distinct_beers: cache.distinctBeerCount(username),
32818
+ ...caveat ? { caveat } : {}
32819
+ };
32820
+ }
32821
+ function registerCacheTools(server, client2, cacheProvider = defaultCacheProvider()) {
32822
+ server.registerTool(
32823
+ "untappd_sync_checkins",
32824
+ {
32825
+ title: "Sync Untappd check-ins into the local cache",
32826
+ description: "Fetch a user's check-ins into a local SQLite cache so history can be queried without paging the API. Incremental (stops once it reaches already-cached check-ins) and resumable: if the backfill is not yet complete it pages backwards up to max_pages per call and persists progress after every page, so run it repeatedly until backfill_complete is true. Syncing another user requires their account be public or a friend. Omit username for your own account.",
32827
+ annotations: toolAnnotations({ title: "Sync Untappd check-ins into the local cache", readOnly: false, idempotent: false, openWorld: true }),
32828
+ inputSchema: {
32829
+ username: UsernameArg2,
32830
+ max_pages: external_exports.number().int().min(1).max(50).optional().describe("Total pages (50 check-ins each) to fetch this run, incremental + backfill (default 10). Keep modest to respect the ~100 calls/hour rate limit.")
32831
+ }
32832
+ },
32833
+ async ({ username, max_pages }) => {
32834
+ const user = resolveUser2(username, client2.loginName);
32835
+ const summary = await syncCheckins(client2, cacheProvider(), user, max_pages ?? 10);
32836
+ return textResult(summary);
32837
+ }
32838
+ );
32839
+ server.registerTool(
32840
+ "untappd_cache_has_had",
32841
+ {
32842
+ title: "Check if a user has had a beer (from local cache)",
32843
+ description: 'Answer "has this user ever checked in this beer?" from the LOCAL cache only \u2014 NO API call. Match by exact bid, or by a case-insensitive substring of the beer name. Returns whether they had it, how many times, the best rating, the last date, and the matching check-ins. Always reports cache freshness so you can caveat incomplete data. Requires bid or beer_name. Run untappd_sync_checkins first.',
32844
+ annotations: toolAnnotations({ title: "Check if a user has had a beer (from local cache)", readOnly: true, idempotent: true, openWorld: false }),
32845
+ inputSchema: {
32846
+ username: UsernameArg2,
32847
+ bid: external_exports.number().int().positive().optional().describe("Exact Untappd beer id to look for"),
32848
+ beer_name: external_exports.string().min(1).optional().describe("Case-insensitive substring match on the beer name")
32849
+ }
32850
+ },
32851
+ async ({ username, bid, beer_name }) => {
32852
+ if (bid === void 0 && beer_name === void 0) {
32853
+ throw createHelpfulError("Provide either bid or beer_name.", { hint: "bid is exact; beer_name is a case-insensitive substring match." });
32854
+ }
32855
+ const user = resolveUser2(username, client2.loginName);
32856
+ const cache = cacheProvider();
32857
+ const result = cache.hasHad(user, { bid, beerName: beer_name });
32858
+ return textResult({ username: user, query: { bid, beer_name }, ...result, freshness: freshness(cache, user) });
32859
+ }
32860
+ );
32861
+ server.registerTool(
32862
+ "untappd_cache_has_had_many",
32863
+ {
32864
+ title: "Batch-check many beers against the local cache",
32865
+ description: "Cross-check a list of beer ids against a user's cached history in ONE call \u2014 NO API call. Returns had/not-had per bid (with count and last date when had). Ideal for checking a whole venue menu at once. Run untappd_sync_checkins first; the freshness block flags if the cache is incomplete.",
32866
+ annotations: toolAnnotations({ title: "Batch-check many beers against the local cache", readOnly: true, idempotent: true, openWorld: false }),
32867
+ inputSchema: {
32868
+ username: UsernameArg2,
32869
+ bids: external_exports.array(external_exports.number().int().positive()).min(1).max(500).describe("Beer ids to check (1\u2013500)")
32870
+ }
32871
+ },
32872
+ async ({ username, bids }) => {
32873
+ const user = resolveUser2(username, client2.loginName);
32874
+ const cache = cacheProvider();
32875
+ const seen = /* @__PURE__ */ new Set();
32876
+ const results = [];
32877
+ let hadCount = 0;
32878
+ for (const bid of bids) {
32879
+ if (seen.has(bid)) continue;
32880
+ seen.add(bid);
32881
+ const r = cache.hasHad(user, { bid });
32882
+ if (r.had) hadCount++;
32883
+ results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating });
32884
+ }
32885
+ return textResult({
32886
+ username: user,
32887
+ checked: seen.size,
32888
+ had: hadCount,
32889
+ not_had: seen.size - hadCount,
32890
+ results,
32891
+ freshness: freshness(cache, user)
32892
+ });
32893
+ }
32894
+ );
32895
+ server.registerTool(
32896
+ "untappd_cache_query",
32897
+ {
32898
+ title: "Query cached check-ins with filters",
32899
+ description: "Query a user's cached check-ins by brewery, style, minimum rating, venue, and/or date range, with sorting and a limit \u2014 from the LOCAL cache only, NO API call. Returns the matching check-ins plus cache freshness. Run untappd_sync_checkins first.",
32900
+ annotations: toolAnnotations({ title: "Query cached check-ins with filters", readOnly: true, idempotent: true, openWorld: false }),
32901
+ inputSchema: {
32902
+ username: UsernameArg2,
32903
+ brewery_id: external_exports.number().int().positive().optional().describe("Exact brewery id"),
32904
+ brewery: external_exports.string().min(1).optional().describe("Case-insensitive substring match on brewery name"),
32905
+ style: external_exports.string().min(1).optional().describe('Case-insensitive substring match on beer style (e.g. "IPA")'),
32906
+ min_rating: external_exports.number().min(0).max(5).optional().describe("Only check-ins you rated at least this (0\u20135)"),
32907
+ venue: external_exports.string().min(1).optional().describe("Case-insensitive substring match on venue name"),
32908
+ venue_id: external_exports.number().int().positive().optional().describe("Exact venue id"),
32909
+ date_from: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Only check-ins on/after this date (YYYY-MM-DD, UTC)"),
32910
+ date_to: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Only check-ins on/before this date (YYYY-MM-DD, UTC)"),
32911
+ sort: external_exports.enum(["recent", "oldest", "highest_rated", "lowest_rated"]).optional().describe("Sort order (default recent first)"),
32912
+ limit: external_exports.number().int().min(1).max(200).optional().describe("Max rows (1\u2013200, default 25)")
32913
+ }
32914
+ },
32915
+ async ({ username, ...filters }) => {
32916
+ const user = resolveUser2(username, client2.loginName);
32917
+ const cache = cacheProvider();
32918
+ const rows = cache.query(user, filters);
32919
+ return textResult({ username: user, count: rows.length, filters, results: rows, freshness: freshness(cache, user) });
32920
+ }
32921
+ );
32922
+ }
32923
+ function defaultCacheProvider() {
32924
+ let cache;
32925
+ return () => cache ??= CheckinCache.open(defaultCachePath());
32926
+ }
32927
+
32366
32928
  // src/index.ts
32367
32929
  var client = new UntappdClient();
32368
32930
  await runMcp({
@@ -32380,6 +32942,10 @@ await runMcp({
32380
32942
  (s) => registerFriendActionTools(s, client),
32381
32943
  (s) => registerWishlistTools(s, client),
32382
32944
  (s) => registerCheckinTools(s, client),
32383
- (s) => registerUtilityTools(s, client)
32945
+ (s) => registerUtilityTools(s, client),
32946
+ // Cache tools live only on the Node/stdio server: they need a local SQLite
32947
+ // file, which the filesystem-less Cloudflare Worker (src/worker.ts) can't
32948
+ // provide, so they are intentionally NOT registered there.
32949
+ (s) => registerCacheTools(s, client)
32384
32950
  ]
32385
32951
  });