untappd-mcp 2.1.2 → 2.1.3

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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Untappd — beers, breweries, venues, check-ins, wishlists, and your friend feed",
10
- "version": "2.1.2"
10
+ "version": "2.1.3"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Untappd",
16
16
  "source": "./",
17
17
  "description": "MCP server for Untappd — search beers/breweries/venues, read profiles/check-ins/wishlists, and post check-ins, toasts, and comments",
18
- "version": "2.1.2",
18
+ "version": "2.1.3",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
3
  "displayName": "Untappd",
4
- "version": "2.1.2",
4
+ "version": "2.1.3",
5
5
  "description": "MCP server for Untappd — search beers/breweries/venues, read check-ins and wishlists, and post check-ins, toasts, and comments",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -34,6 +34,8 @@ it goes stale.
34
34
  | `UNTAPPD_DEVICE_ID` | no | Stable device UUID the token is keyed to (a default is provided). |
35
35
  | `UNTAPPD_UTV` | no | API version param (default `4.0.0`). |
36
36
  | `UNTAPPD_USER_AGENT` | no | Override the User-Agent (default mimics the app). |
37
+ | `UNTAPPD_TIMEZONE` | no | IANA timezone (e.g. `America/New_York`) that check-ins are stamped with when the call doesn't pass `timezone`. Set it when the server runs somewhere other than the drinker's zone (e.g. a hosted connector, usually UTC). Defaults to the server process's zone. |
38
+ | `UNTAPPD_PHOTO_DIR` | no | Restrict `untappd_checkin`'s `photo_path` to files inside this directory (several allowed, separated by `:`). Recommended wherever the model can be steered by untrusted content. |
37
39
  | `UNTAPPD_CACHE_DB` | no | Path to the local check-in cache SQLite file (default `~/.untappd-mcp/checkins.db`). Local/stdio only. |
38
40
 
39
41
  Copy `.env.example` to `.env` and fill it in for local use.
package/dist/bundle.js CHANGED
@@ -1585,8 +1585,8 @@ var require_regexes = __commonJS({
1585
1585
  }
1586
1586
  exports.ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
1587
1587
  exports.ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
1588
- var mac3 = (delimiter) => {
1589
- const escapedDelim = util.escapeRegex(delimiter ?? ":");
1588
+ var mac3 = (delimiter2) => {
1589
+ const escapedDelim = util.escapeRegex(delimiter2 ?? ":");
1590
1590
  return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
1591
1591
  };
1592
1592
  exports.mac = mac3;
@@ -5000,7 +5000,7 @@ var require_memoizer = __commonJS({
5000
5000
  return bucket;
5001
5001
  }
5002
5002
  var handoff2;
5003
- var open2 = [];
5003
+ var open3 = [];
5004
5004
  var memo3 = {
5005
5005
  alloc(_inst, payload, empty) {
5006
5006
  const bucket = handoff2;
@@ -5009,7 +5009,7 @@ var require_memoizer = __commonJS({
5009
5009
  handoff2 = void 0;
5010
5010
  const entry = { value: empty, issues: null };
5011
5011
  bucket.set(payload.value, entry);
5012
- open2.push(entry);
5012
+ open3.push(entry);
5013
5013
  return empty;
5014
5014
  },
5015
5015
  guard(inst) {
@@ -5080,10 +5080,10 @@ var require_memoizer = __commonJS({
5080
5080
  return payload;
5081
5081
  }
5082
5082
  handoff2 = bucket;
5083
- const depth = open2.length;
5083
+ const depth = open3.length;
5084
5084
  const result = base(payload, ctx);
5085
5085
  handoff2 = void 0;
5086
- const entry = open2.length > depth ? open2.pop() : void 0;
5086
+ const entry = open3.length > depth ? open3.pop() : void 0;
5087
5087
  if (result instanceof Promise) {
5088
5088
  return result.then((r) => {
5089
5089
  if (entry)
@@ -40052,8 +40052,8 @@ function emoji() {
40052
40052
  }
40053
40053
  var ipv4 = /^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/;
40054
40054
  var ipv6 = /^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:))$/;
40055
- var mac = (delimiter) => {
40056
- const escapedDelim = escapeRegex(delimiter ?? ":");
40055
+ var mac = (delimiter2) => {
40056
+ const escapedDelim = escapeRegex(delimiter2 ?? ":");
40057
40057
  return new RegExp(`^(?:[0-9A-F]{2}${escapedDelim}){5}[0-9A-F]{2}$|^(?:[0-9a-f]{2}${escapedDelim}){5}[0-9a-f]{2}$`);
40058
40058
  };
40059
40059
  var cidrv4 = /^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\/([0-9]|[1-2][0-9]|3[0-2])$/;
@@ -57983,6 +57983,7 @@ async function loadDotenvSafely(opts = {}) {
57983
57983
 
57984
57984
  // node_modules/@chrischall/mcp-utils/dist/fs/index.js
57985
57985
  import { openAsBlob } from "node:fs";
57986
+ import { open as open2 } from "node:fs/promises";
57986
57987
 
57987
57988
  // node_modules/@chrischall/mcp-utils/dist/fs/confine.js
57988
57989
  import { existsSync, realpathSync } from "node:fs";
@@ -58019,6 +58020,24 @@ function assertPathWithinRoots(path, roots) {
58019
58020
  throw new Error(`Path is outside the allowed directories: ${path}`);
58020
58021
  }
58021
58022
 
58023
+ // node_modules/@chrischall/mcp-utils/dist/fs/output.js
58024
+ function sniffMimeBytes(bytes) {
58025
+ const buf = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
58026
+ if (buf.length >= 8 && buf.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) {
58027
+ return "image/png";
58028
+ }
58029
+ if (buf.length >= 3 && buf[0] === 255 && buf[1] === 216 && buf[2] === 255) {
58030
+ return "image/jpeg";
58031
+ }
58032
+ if (buf.length >= 12 && buf.subarray(0, 4).toString("latin1") === "RIFF" && buf.subarray(8, 12).toString("latin1") === "WEBP") {
58033
+ return "image/webp";
58034
+ }
58035
+ if (buf.length >= 4 && buf.subarray(0, 4).toString("latin1") === "GIF8") {
58036
+ return "image/gif";
58037
+ }
58038
+ return void 0;
58039
+ }
58040
+
58022
58041
  // node_modules/@chrischall/mcp-utils/dist/fs/index.js
58023
58042
  async function fileBlob(path, opts = {}) {
58024
58043
  const target = opts.allowedRoots ? assertPathWithinRoots(path, opts.allowedRoots) : path;
@@ -58033,6 +58052,22 @@ async function fileBlob(path, opts = {}) {
58033
58052
  }
58034
58053
  return blob;
58035
58054
  }
58055
+ async function readFileHead(path, bytes, opts = {}) {
58056
+ const target = opts.allowedRoots ? assertPathWithinRoots(path, opts.allowedRoots) : path;
58057
+ let fh;
58058
+ try {
58059
+ fh = await open2(target, "r");
58060
+ } catch {
58061
+ throw new Error(`Cannot read file: ${path}`);
58062
+ }
58063
+ try {
58064
+ const buf = Buffer.alloc(bytes);
58065
+ const { bytesRead } = await fh.read(buf, 0, bytes, 0);
58066
+ return buf.subarray(0, bytesRead);
58067
+ } finally {
58068
+ await fh.close();
58069
+ }
58070
+ }
58036
58071
 
58037
58072
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
58038
58073
  function buildQueryString(params) {
@@ -58091,7 +58126,7 @@ function toolAnnotations(opts = {}) {
58091
58126
  }
58092
58127
 
58093
58128
  // src/version.ts
58094
- var VERSION = "2.1.2";
58129
+ var VERSION = "2.1.3";
58095
58130
 
58096
58131
  // src/client.ts
58097
58132
  import { dirname as dirname2, join as join2 } from "path";
@@ -58598,7 +58633,9 @@ var SCHEMA_STATEMENTS = [
58598
58633
  beers_offset INTEGER,
58599
58634
  beers_total INTEGER,
58600
58635
  beers_complete INTEGER NOT NULL DEFAULT 0,
58601
- served_backfill_last INTEGER NOT NULL DEFAULT 0
58636
+ served_backfill_last INTEGER NOT NULL DEFAULT 0,
58637
+ beers_accepted_gap INTEGER NOT NULL DEFAULT 0,
58638
+ beers_rescan_target INTEGER
58602
58639
  )`
58603
58640
  ];
58604
58641
  var MIGRATIONS = [
@@ -58607,7 +58644,9 @@ var MIGRATIONS = [
58607
58644
  "ALTER TABLE sync_state ADD COLUMN beers_offset INTEGER",
58608
58645
  "ALTER TABLE sync_state ADD COLUMN beers_total INTEGER",
58609
58646
  "ALTER TABLE sync_state ADD COLUMN beers_complete INTEGER NOT NULL DEFAULT 0",
58610
- "ALTER TABLE sync_state ADD COLUMN served_backfill_last INTEGER NOT NULL DEFAULT 0"
58647
+ "ALTER TABLE sync_state ADD COLUMN served_backfill_last INTEGER NOT NULL DEFAULT 0",
58648
+ "ALTER TABLE sync_state ADD COLUMN beers_accepted_gap INTEGER NOT NULL DEFAULT 0",
58649
+ "ALTER TABLE sync_state ADD COLUMN beers_rescan_target INTEGER"
58611
58650
  ];
58612
58651
  var CheckinStoreCore = class {
58613
58652
  constructor(db) {
@@ -58729,7 +58768,9 @@ var CheckinStoreCore = class {
58729
58768
  beers_offset: num(row.beers_offset),
58730
58769
  beers_total: num(row.beers_total),
58731
58770
  beers_complete: Number(row.beers_complete) === 1,
58732
- served_backfill_last: Number(row.served_backfill_last) === 1
58771
+ served_backfill_last: Number(row.served_backfill_last) === 1,
58772
+ beers_accepted_gap: num(row.beers_accepted_gap) ?? 0,
58773
+ beers_rescan_target: num(row.beers_rescan_target)
58733
58774
  };
58734
58775
  }
58735
58776
  /**
@@ -58753,19 +58794,22 @@ var CheckinStoreCore = class {
58753
58794
  beers_offset: pick2("beers_offset", null),
58754
58795
  beers_total: pick2("beers_total", null),
58755
58796
  beers_complete: pick2("beers_complete", false),
58756
- served_backfill_last: pick2("served_backfill_last", false)
58797
+ served_backfill_last: pick2("served_backfill_last", false),
58798
+ beers_accepted_gap: pick2("beers_accepted_gap", 0),
58799
+ beers_rescan_target: pick2("beers_rescan_target", null)
58757
58800
  };
58758
58801
  this.db.run(
58759
58802
  `INSERT INTO sync_state
58760
- (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last)
58761
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
58803
+ (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last, beers_accepted_gap, beers_rescan_target)
58804
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
58762
58805
  ON CONFLICT(username) DO UPDATE SET
58763
58806
  oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
58764
58807
  catchup_max_id=excluded.catchup_max_id, last_synced_at=excluded.last_synced_at,
58765
58808
  backfill_complete=excluded.backfill_complete, total_checkins=excluded.total_checkins,
58766
58809
  checkins_truncated=excluded.checkins_truncated, beers_offset=excluded.beers_offset,
58767
58810
  beers_total=excluded.beers_total, beers_complete=excluded.beers_complete,
58768
- served_backfill_last=excluded.served_backfill_last`,
58811
+ served_backfill_last=excluded.served_backfill_last, beers_accepted_gap=excluded.beers_accepted_gap,
58812
+ beers_rescan_target=excluded.beers_rescan_target`,
58769
58813
  [
58770
58814
  key,
58771
58815
  next.oldest_max_id,
@@ -58778,7 +58822,9 @@ var CheckinStoreCore = class {
58778
58822
  next.beers_offset,
58779
58823
  next.beers_total,
58780
58824
  next.beers_complete ? 1 : 0,
58781
- next.served_backfill_last ? 1 : 0
58825
+ next.served_backfill_last ? 1 : 0,
58826
+ next.beers_accepted_gap,
58827
+ next.beers_rescan_target
58782
58828
  ]
58783
58829
  );
58784
58830
  }
@@ -59740,19 +59786,108 @@ function registerWishlistTools(server, client2) {
59740
59786
  }
59741
59787
 
59742
59788
  // src/tools/checkin.ts
59743
- import { existsSync as existsSync2 } from "node:fs";
59744
- import { extname } from "node:path";
59789
+ import { realpathSync as realpathSync2, statSync } from "node:fs";
59790
+ import { delimiter, extname } from "node:path";
59745
59791
  var CheckinIdSchema = external_exports.number().int().positive().describe("Untappd check-in id");
59746
59792
  var PHOTO_CONTENT_TYPES = { jpg: "image/jpeg", png: "image/png" };
59747
59793
  function photoExt(path) {
59748
59794
  const ext = extname(path).slice(1).toLowerCase();
59749
59795
  return ext === "jpeg" ? "jpg" : ext;
59750
59796
  }
59797
+ var MAX_PHOTO_BYTES = 15 * 1024 * 1024;
59798
+ function photoRoots() {
59799
+ const raw = readEnvVar("UNTAPPD_PHOTO_DIR");
59800
+ const roots = raw?.split(delimiter).filter(Boolean);
59801
+ return roots && roots.length > 0 ? roots : void 0;
59802
+ }
59803
+ async function checkPhoto(photoPath) {
59804
+ const ext = photoExt(photoPath);
59805
+ if (!(ext in PHOTO_CONTENT_TYPES)) {
59806
+ throw createHelpfulError(`Unsupported photo type "${ext || "(none)"}".`, {
59807
+ hint: "Attach a .jpg, .jpeg, or .png file."
59808
+ });
59809
+ }
59810
+ const roots = photoRoots();
59811
+ if (roots) {
59812
+ try {
59813
+ assertPathWithinRoots(photoPath, roots);
59814
+ } catch {
59815
+ throw createHelpfulError("The photo is outside the allowed photo directory.", {
59816
+ hint: "UNTAPPD_PHOTO_DIR restricts which files can be attached to a check-in."
59817
+ });
59818
+ }
59819
+ }
59820
+ let real;
59821
+ let size;
59822
+ try {
59823
+ real = realpathSync2(photoPath);
59824
+ const st = statSync(real);
59825
+ if (!st.isFile()) throw new Error("not a file");
59826
+ size = st.size;
59827
+ } catch {
59828
+ throw new McpToolError("Photo file not found or not readable.");
59829
+ }
59830
+ if (size > MAX_PHOTO_BYTES) {
59831
+ throw createHelpfulError(`Photo is too large (${size} bytes; the limit is ${MAX_PHOTO_BYTES}).`, {
59832
+ hint: "Attach a normal-sized JPEG or PNG photo."
59833
+ });
59834
+ }
59835
+ const sniffed = sniffMimeBytes(await readFileHead(real, 16));
59836
+ if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
59837
+ throw createHelpfulError(
59838
+ sniffed === "image/jpeg" || sniffed === "image/png" ? `Photo content (${sniffed}) does not match its .${ext} extension.` : "The file is not a JPEG or PNG image.",
59839
+ { hint: "Attach a real .jpg/.jpeg or .png photo." }
59840
+ );
59841
+ }
59842
+ return { path: real, size_bytes: size, ext, content_type: sniffed };
59843
+ }
59751
59844
  var RatingSchema = external_exports.number().min(0).max(5).refine((r) => Math.round(r * 4) === r * 4, { message: "rating must be a multiple of 0.25" });
59752
- function localTimezone() {
59753
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
59754
- const gmt_offset = -(/* @__PURE__ */ new Date()).getTimezoneOffset() / 60;
59755
- return { timezone, gmt_offset };
59845
+ function gmtOffsetHours(timeZone, at) {
59846
+ const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
59847
+ const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(name ?? "");
59848
+ if (!m) return 0;
59849
+ const hours = Number(m[2]) + Number(m[3] ?? 0) / 60;
59850
+ return m[1] === "-" ? -hours : hours;
59851
+ }
59852
+ function checkinTimezone(requested) {
59853
+ const timezone = requested ?? readEnvVar("UNTAPPD_TIMEZONE") ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC");
59854
+ try {
59855
+ return { timezone, gmt_offset: gmtOffsetHours(timezone, /* @__PURE__ */ new Date()) };
59856
+ } catch {
59857
+ throw createHelpfulError(`Unknown timezone "${timezone}".`, {
59858
+ hint: 'Pass an IANA timezone name such as "America/New_York" or "Europe/London".'
59859
+ });
59860
+ }
59861
+ }
59862
+ var RECOVERY_WINDOW_MS = 3e4;
59863
+ async function nonIdempotentWrite(run, unknownOutcome) {
59864
+ try {
59865
+ return await run();
59866
+ } catch (e) {
59867
+ if (e instanceof UnreachableError) throw new McpToolError(unknownOutcome);
59868
+ throw e;
59869
+ }
59870
+ }
59871
+ async function findRecentCheckin(client2, bid, sentAt) {
59872
+ const self = client2.loginName;
59873
+ if (!self) return null;
59874
+ try {
59875
+ const data = await client2.get(
59876
+ `/user/checkins/${encodeURIComponent(self)}`,
59877
+ { limit: 5 }
59878
+ );
59879
+ const matches = [];
59880
+ for (const it of data?.checkins?.items ?? []) {
59881
+ const c = it;
59882
+ const at = Date.parse(c.created_at ?? "");
59883
+ if (c.beer?.bid === bid && typeof c.checkin_id === "number" && at >= sentAt - RECOVERY_WINDOW_MS) {
59884
+ matches.push(c.checkin_id);
59885
+ }
59886
+ }
59887
+ if (matches.length === 1) return matches[0];
59888
+ } catch {
59889
+ }
59890
+ return null;
59756
59891
  }
59757
59892
  function registerCheckinTools(server, client2) {
59758
59893
  server.registerTool(
@@ -59775,7 +59910,10 @@ function registerCheckinTools(server, client2) {
59775
59910
  note: "Dry run \u2014 re-run with confirm: true to toggle your toast on this check-in."
59776
59911
  });
59777
59912
  }
59778
- const data = await client2.write("POST", `/checkin/toast/${checkin_id}`);
59913
+ const data = await nonIdempotentWrite(
59914
+ () => client2.write("POST", `/checkin/toast/${checkin_id}`),
59915
+ `Untappd did not answer the toast request for check-in ${checkin_id} in time, so it may or may not have been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`
59916
+ );
59779
59917
  return minifiedResult({ toggled: true, checkin_id, result: data?.result, like_type: data?.like_type });
59780
59918
  }
59781
59919
  );
@@ -59801,7 +59939,10 @@ function registerCheckinTools(server, client2) {
59801
59939
  note: "Dry run \u2014 re-run with confirm: true to post this comment to your Untappd account."
59802
59940
  });
59803
59941
  }
59804
- const data = await client2.write("POST", `/checkin/addcomment/${checkin_id}`, { form: { comment } });
59942
+ const data = await nonIdempotentWrite(
59943
+ () => client2.write("POST", `/checkin/addcomment/${checkin_id}`, { form: { comment } }),
59944
+ `Untappd did not answer in time, so the comment may have been posted already. Check the comments with untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`
59945
+ );
59805
59946
  return minifiedResult({ posted: true, checkin_id, response: data });
59806
59947
  }
59807
59948
  );
@@ -59864,24 +60005,21 @@ function registerCheckinTools(server, client2) {
59864
60005
  rating: RatingSchema.optional().describe("Rating 0\u20135 in 0.25 increments (omit for no rating)"),
59865
60006
  shout: external_exports.string().max(2e3).optional().describe("Optional shout / comment text for the check-in"),
59866
60007
  foursquare_id: external_exports.string().optional().describe("Optional Foursquare venue id to tag the check-in location"),
59867
- photo_path: external_exports.string().optional().describe("Optional path to a local JPEG/PNG photo to attach to the check-in"),
60008
+ photo_path: external_exports.string().optional().describe(
60009
+ "Optional path to a local JPEG/PNG photo (max 15 MB) to attach \u2014 it is published publicly. Only use a file the user explicitly chose; the dry run shows the resolved path and size for them to confirm."
60010
+ ),
59868
60011
  geolat: external_exports.number().optional().describe("Optional latitude of the check-in"),
59869
60012
  geolng: external_exports.number().optional().describe("Optional longitude of the check-in"),
59870
60013
  container_id: external_exports.number().int().optional().describe("Optional serving container id (e.g. 1 = draft, 2 = bottle, 3 = can)"),
60014
+ timezone: external_exports.string().min(1).optional().describe(
60015
+ "The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to UNTAPPD_TIMEZONE, else the server's own zone \u2014 which on a hosted connector is usually UTC, so pass it when you know where the user is."
60016
+ ),
59871
60017
  confirm: schemaConfirm
59872
60018
  })
59873
60019
  },
59874
- async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, confirm }) => {
59875
- const { timezone, gmt_offset } = localTimezone();
59876
- let ext;
59877
- if (photo_path !== void 0) {
59878
- ext = photoExt(photo_path);
59879
- if (!(ext in PHOTO_CONTENT_TYPES)) {
59880
- throw createHelpfulError(`Unsupported photo type "${ext || "(none)"}".`, {
59881
- hint: "Attach a .jpg, .jpeg, or .png file."
59882
- });
59883
- }
59884
- }
60020
+ async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirm }) => {
60021
+ const { timezone, gmt_offset } = checkinTimezone(requestedTz);
60022
+ const photo = photo_path !== void 0 ? await checkPhoto(photo_path) : void 0;
59885
60023
  const form = {
59886
60024
  bid,
59887
60025
  rating: rating !== void 0 ? rating.toFixed(2) : void 0,
@@ -59892,8 +60030,8 @@ function registerCheckinTools(server, client2) {
59892
60030
  container_id,
59893
60031
  timezone,
59894
60032
  gmt_offset,
59895
- is_photo: photo_path !== void 0 ? "true" : "false",
59896
- photo_file_ext: photo_path !== void 0 ? ext : void 0,
60033
+ is_photo: photo ? "true" : "false",
60034
+ photo_file_ext: photo?.ext,
59897
60035
  platform: "ios"
59898
60036
  };
59899
60037
  if (confirm !== true) {
@@ -59901,22 +60039,41 @@ function registerCheckinTools(server, client2) {
59901
60039
  dryRun: true,
59902
60040
  action: "checkin",
59903
60041
  form,
59904
- photo: photo_path !== void 0 ? { path: photo_path, note: "will be uploaded after the check-in is created" } : void 0,
60042
+ photo: photo ? { ...photo, note: "this exact file will be uploaded PUBLICLY after the check-in is created" } : void 0,
59905
60043
  note: "Dry run \u2014 re-run with confirm: true to POST this check-in to your public Untappd feed."
59906
60044
  });
59907
60045
  }
59908
60046
  let blob;
59909
- if (photo_path !== void 0) {
59910
- if (!existsSync2(photo_path)) throw new McpToolError(`Photo file not found: ${photo_path}`);
59911
- blob = await fileBlob(photo_path);
60047
+ if (photo) {
60048
+ blob = await fileBlob(photo.path, { maxBytes: MAX_PHOTO_BYTES, label: "Photo", allowedRoots: photoRoots() });
60049
+ }
60050
+ const sentAt = Date.now();
60051
+ let data;
60052
+ try {
60053
+ data = await client2.write("POST", "/checkin/add", { form });
60054
+ } catch (e) {
60055
+ if (!(e instanceof UnreachableError)) throw e;
60056
+ const found = await findRecentCheckin(client2, bid, sentAt);
60057
+ if (found === null) {
60058
+ throw new McpToolError(
60059
+ "Untappd did not answer the check-in request in time, so the check-in may have been created anyway (it could not be confirmed either way). Look at your latest check-ins with untappd_user_checkins before retrying \u2014 retrying a check-in that did land posts it twice."
60060
+ );
60061
+ }
60062
+ return minifiedResult({
60063
+ checked_in: true,
60064
+ checkin_id: found,
60065
+ recovered: true,
60066
+ photo_attached: false,
60067
+ ...photo ? { photo_error: `Check-in ${found} was created, but its photo upload URL was lost with the timed-out response, so no photo was attached.` } : {},
60068
+ note: "Untappd did not answer in time, but the check-in was found on your account \u2014 it was created. Do not retry."
60069
+ });
59912
60070
  }
59913
- const data = await client2.write("POST", "/checkin/add", { form });
59914
60071
  let photo_attached = false;
59915
60072
  let photo_error;
59916
- if (photo_path !== void 0) {
60073
+ if (photo) {
59917
60074
  if (data?.photo_upload?.url && data.checkin_id) {
59918
60075
  try {
59919
- await client2.putBinary(data.photo_upload.url, blob, PHOTO_CONTENT_TYPES[ext]);
60076
+ await client2.putBinary(data.photo_upload.url, blob, photo.content_type);
59920
60077
  await client2.write("POST", "/photo/uploadComplete", {
59921
60078
  form: { checkin_id: data.checkin_id, destination_url: data.photo_upload.destination_url }
59922
60079
  });
@@ -60226,6 +60383,7 @@ async function syncCheckins(client2, cache, rawUsername, options = {}) {
60226
60383
 
60227
60384
  // src/cache/sync-beers.ts
60228
60385
  var PAGE_LIMIT2 = 50;
60386
+ var RESUME_OVERLAP = 10;
60229
60387
  async function fetchBeersPage(client2, encodedUser, offset) {
60230
60388
  const data = await client2.get(
60231
60389
  `/user/beers/${encodedUser}`,
@@ -60248,8 +60406,11 @@ async function syncUserBeers(client2, cache, rawUsername, maxPages = 10) {
60248
60406
  const now = () => (/* @__PURE__ */ new Date()).toISOString();
60249
60407
  const prior = await cache.getState(rawUsername);
60250
60408
  const incremental = (prior?.beers_complete ?? false) && !!prior?.last_synced_at;
60251
- let offset = incremental ? 0 : prior?.beers_offset ?? 0;
60409
+ let refreshing = incremental;
60410
+ let offset = incremental ? 0 : Math.max(0, (prior?.beers_offset ?? 0) - RESUME_OVERLAP);
60252
60411
  let total = prior?.beers_total ?? null;
60412
+ let acceptedGap = prior?.beers_accepted_gap ?? 0;
60413
+ let rescanTarget = incremental ? null : prior?.beers_rescan_target ?? null;
60253
60414
  let complete = false;
60254
60415
  let pages = 0;
60255
60416
  let added = 0;
@@ -60263,31 +60424,41 @@ async function syncUserBeers(client2, cache, rawUsername, maxPages = 10) {
60263
60424
  });
60264
60425
  }
60265
60426
  };
60427
+ const gapNow = async () => total === null ? 0 : total - await cache.distinctBeersCount(rawUsername);
60266
60428
  for (; pages < maxPages && !complete; ) {
60267
60429
  const page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client2, encodedUser, offset);
60268
60430
  pages++;
60269
60431
  if (page.total !== null) total = page.total;
60270
- if (page.items.length === 0) {
60271
- complete = true;
60272
- await cache.setState(rawUsername, {
60273
- beers_offset: incremental ? total ?? offset : offset,
60274
- beers_total: total,
60275
- beers_complete: true,
60276
- last_synced_at: now()
60277
- });
60278
- break;
60432
+ let atEnd = page.items.length === 0;
60433
+ if (!atEnd) {
60434
+ const rows = rowsOf2(rawUsername, page.items);
60435
+ const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
60436
+ added += netNew;
60437
+ offset += page.items.length;
60438
+ if (total !== null && offset >= total) atEnd = true;
60439
+ else if (page.items.length < PAGE_LIMIT2) atEnd = true;
60440
+ else if (refreshing && netNew === 0) atEnd = true;
60441
+ else if (rescanTarget !== null && await gapNow() <= rescanTarget) atEnd = true;
60442
+ }
60443
+ if (atEnd) {
60444
+ const gap = await gapNow();
60445
+ if (gap > acceptedGap && rescanTarget === null) {
60446
+ rescanTarget = acceptedGap;
60447
+ acceptedGap = gap;
60448
+ refreshing = false;
60449
+ offset = 0;
60450
+ } else {
60451
+ complete = true;
60452
+ rescanTarget = null;
60453
+ acceptedGap = Math.max(0, gap);
60454
+ }
60279
60455
  }
60280
- const rows = rowsOf2(rawUsername, page.items);
60281
- const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
60282
- added += netNew;
60283
- offset += page.items.length;
60284
- if (total !== null && offset >= total) complete = true;
60285
- else if (page.items.length < PAGE_LIMIT2) complete = true;
60286
- else if (incremental && netNew === 0) complete = true;
60287
60456
  await cache.setState(rawUsername, {
60288
- beers_offset: incremental ? total ?? offset : offset,
60457
+ beers_offset: refreshing ? total ?? offset : offset,
60289
60458
  beers_total: total,
60290
60459
  beers_complete: complete,
60460
+ beers_accepted_gap: acceptedGap,
60461
+ beers_rescan_target: rescanTarget,
60291
60462
  last_synced_at: now()
60292
60463
  });
60293
60464
  }
@@ -175,7 +175,9 @@ export const SCHEMA_STATEMENTS = [
175
175
  beers_offset INTEGER,
176
176
  beers_total INTEGER,
177
177
  beers_complete INTEGER NOT NULL DEFAULT 0,
178
- served_backfill_last INTEGER NOT NULL DEFAULT 0
178
+ served_backfill_last INTEGER NOT NULL DEFAULT 0,
179
+ beers_accepted_gap INTEGER NOT NULL DEFAULT 0,
180
+ beers_rescan_target INTEGER
179
181
  )`,
180
182
  ];
181
183
  /** Columns added after the initial release, each applied idempotently on open. */
@@ -186,6 +188,8 @@ export const MIGRATIONS = [
186
188
  'ALTER TABLE sync_state ADD COLUMN beers_total INTEGER',
187
189
  'ALTER TABLE sync_state ADD COLUMN beers_complete INTEGER NOT NULL DEFAULT 0',
188
190
  'ALTER TABLE sync_state ADD COLUMN served_backfill_last INTEGER NOT NULL DEFAULT 0',
191
+ 'ALTER TABLE sync_state ADD COLUMN beers_accepted_gap INTEGER NOT NULL DEFAULT 0',
192
+ 'ALTER TABLE sync_state ADD COLUMN beers_rescan_target INTEGER',
189
193
  ];
190
194
  /**
191
195
  * The cache logic over a synchronous {@link SqlDriver}. Usernames are keyed
@@ -298,6 +302,8 @@ export class CheckinStoreCore {
298
302
  beers_total: num(row.beers_total),
299
303
  beers_complete: Number(row.beers_complete) === 1,
300
304
  served_backfill_last: Number(row.served_backfill_last) === 1,
305
+ beers_accepted_gap: num(row.beers_accepted_gap) ?? 0,
306
+ beers_rescan_target: num(row.beers_rescan_target),
301
307
  };
302
308
  }
303
309
  /**
@@ -322,20 +328,24 @@ export class CheckinStoreCore {
322
328
  beers_total: pick('beers_total', null),
323
329
  beers_complete: pick('beers_complete', false),
324
330
  served_backfill_last: pick('served_backfill_last', false),
331
+ beers_accepted_gap: pick('beers_accepted_gap', 0),
332
+ beers_rescan_target: pick('beers_rescan_target', null),
325
333
  };
326
334
  this.db.run(`INSERT INTO sync_state
327
- (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last)
328
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
335
+ (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last, beers_accepted_gap, beers_rescan_target)
336
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
329
337
  ON CONFLICT(username) DO UPDATE SET
330
338
  oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
331
339
  catchup_max_id=excluded.catchup_max_id, last_synced_at=excluded.last_synced_at,
332
340
  backfill_complete=excluded.backfill_complete, total_checkins=excluded.total_checkins,
333
341
  checkins_truncated=excluded.checkins_truncated, beers_offset=excluded.beers_offset,
334
342
  beers_total=excluded.beers_total, beers_complete=excluded.beers_complete,
335
- served_backfill_last=excluded.served_backfill_last`, [
343
+ served_backfill_last=excluded.served_backfill_last, beers_accepted_gap=excluded.beers_accepted_gap,
344
+ beers_rescan_target=excluded.beers_rescan_target`, [
336
345
  key, next.oldest_max_id, next.newest_checkin_id, next.catchup_max_id, next.last_synced_at,
337
346
  next.backfill_complete ? 1 : 0, next.total_checkins, next.checkins_truncated ? 1 : 0,
338
347
  next.beers_offset, next.beers_total, next.beers_complete ? 1 : 0, next.served_backfill_last ? 1 : 0,
348
+ next.beers_accepted_gap, next.beers_rescan_target,
339
349
  ]);
340
350
  }
341
351
  /**
@@ -1,6 +1,10 @@
1
1
  import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
2
  import { mapBeerRow } from './store.js';
3
3
  const PAGE_LIMIT = 50; // Untappd's max page size for /user/beers.
4
+ // A resumed backfill re-reads this many already-fetched items: if beers dropped
5
+ // out of the list between runs, everything below shifted UP past the stored
6
+ // offset, and without the overlap the beers that crossed it would be skipped.
7
+ const RESUME_OVERLAP = 10;
4
8
  /** One `/user/beers` page fetch (offset-paged), tolerant of the fat shape. */
5
9
  async function fetchBeersPage(client, encodedUser, offset) {
6
10
  const data = await client.get(`/user/beers/${encodedUser}`, { limit: PAGE_LIMIT, offset, sort: 'date' });
@@ -35,8 +39,15 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
35
39
  // An already-complete cache re-scans from the top to pick up newly-had beers,
36
40
  // stopping on the first page with nothing new. Otherwise resume the backfill.
37
41
  const incremental = (prior?.beers_complete ?? false) && !!prior?.last_synced_at;
38
- let offset = incremental ? 0 : (prior?.beers_offset ?? 0);
42
+ // `refreshing` is the live mode: it starts as `incremental` and is cleared when
43
+ // a shortfall forces a rescan (which must page past "nothing new" pages).
44
+ let refreshing = incremental;
45
+ let offset = incremental ? 0 : Math.max(0, (prior?.beers_offset ?? 0) - RESUME_OVERLAP);
39
46
  let total = prior?.beers_total ?? null;
47
+ let acceptedGap = prior?.beers_accepted_gap ?? 0;
48
+ // Non-null while a coverage rescan is walking down from the top (possibly
49
+ // across runs): it stops as soon as the shortfall is back within this target.
50
+ let rescanTarget = incremental ? null : (prior?.beers_rescan_target ?? null);
40
51
  let complete = false;
41
52
  let pages = 0;
42
53
  let added = 0;
@@ -52,42 +63,60 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
52
63
  });
53
64
  }
54
65
  };
66
+ const gapNow = async () => total === null ? 0 : total - (await cache.distinctBeersCount(rawUsername));
55
67
  for (; pages < maxPages && !complete;) {
56
68
  const page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client, encodedUser, offset);
57
69
  pages++;
58
70
  if (page.total !== null)
59
71
  total = page.total;
60
- if (page.items.length === 0) {
61
- // An empty (e.g. first) page still means the list is fully paged — persist
62
- // completion so a subsequent call doesn't re-run the same empty fetch.
63
- complete = true;
64
- await cache.setState(rawUsername, {
65
- beers_offset: incremental ? (total ?? offset) : offset,
66
- beers_total: total,
67
- beers_complete: true,
68
- last_synced_at: now(),
69
- });
70
- break;
72
+ // An empty (e.g. first) page still means the list is fully paged.
73
+ let atEnd = page.items.length === 0;
74
+ if (!atEnd) {
75
+ const rows = rowsOf(rawUsername, page.items);
76
+ const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
77
+ added += netNew;
78
+ offset += page.items.length;
79
+ // End conditions: caught the whole list, a short (final) page, or — during an
80
+ // incremental top refresh — a page that added nothing new.
81
+ if (total !== null && offset >= total)
82
+ atEnd = true;
83
+ else if (page.items.length < PAGE_LIMIT)
84
+ atEnd = true;
85
+ else if (refreshing && netNew === 0)
86
+ atEnd = true;
87
+ else if (rescanTarget !== null && (await gapNow()) <= rescanTarget)
88
+ atEnd = true;
89
+ }
90
+ if (atEnd) {
91
+ // Offset paging across runs is not a snapshot: when the list churns between
92
+ // runs (a beer drops out, or is re-sorted) a beer can slip behind the resumed
93
+ // offset and never be fetched. Reaching the end is therefore only "complete"
94
+ // if the cache actually holds total_count beers — less whatever shortfall a
95
+ // previous full pass already proved the list can never close (a count Untappd
96
+ // reports but never lists). A NEW shortfall means skipped beers: rescan from
97
+ // the top (upserts are idempotent) instead of reporting false coverage.
98
+ const gap = await gapNow();
99
+ if (gap > acceptedGap && rescanTarget === null) {
100
+ rescanTarget = acceptedGap;
101
+ acceptedGap = gap; // a rescan that finds nothing new accepts this gap
102
+ refreshing = false;
103
+ offset = 0;
104
+ }
105
+ else {
106
+ complete = true;
107
+ rescanTarget = null;
108
+ acceptedGap = Math.max(0, gap);
109
+ }
71
110
  }
72
- const rows = rowsOf(rawUsername, page.items);
73
- const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
74
- added += netNew;
75
- offset += page.items.length;
76
- // End conditions: caught the whole list, a short (final) page, or — during an
77
- // incremental top refresh — a page that added nothing new.
78
- if (total !== null && offset >= total)
79
- complete = true;
80
- else if (page.items.length < PAGE_LIMIT)
81
- complete = true;
82
- else if (incremental && netNew === 0)
83
- complete = true;
84
111
  // Persist progress after EVERY page. During an incremental refresh the stored
85
112
  // offset stays at the completed total so the next run refreshes from the top
86
113
  // again rather than resuming a stale cursor.
87
114
  await cache.setState(rawUsername, {
88
- beers_offset: incremental ? (total ?? offset) : offset,
115
+ beers_offset: refreshing ? (total ?? offset) : offset,
89
116
  beers_total: total,
90
117
  beers_complete: complete,
118
+ beers_accepted_gap: acceptedGap,
119
+ beers_rescan_target: rescanTarget,
91
120
  last_synced_at: now(),
92
121
  });
93
122
  }
@@ -1,7 +1,7 @@
1
- import { existsSync } from 'node:fs';
2
- import { extname } from 'node:path';
1
+ import { realpathSync, statSync } from 'node:fs';
2
+ import { delimiter, extname } from 'node:path';
3
3
  import { z } from 'zod';
4
- import { McpToolError, createHelpfulError, fileBlob, messageOf, minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
4
+ import { McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, schemaConfirm, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
5
5
  const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
6
6
  // Keyed by the NORMALISED extension that photoExt() returns (jpeg → jpg), so
7
7
  // there is no dead `jpeg` entry.
@@ -10,17 +10,150 @@ function photoExt(path) {
10
10
  const ext = extname(path).slice(1).toLowerCase();
11
11
  return ext === 'jpeg' ? 'jpg' : ext;
12
12
  }
13
+ // Far above any phone photo; a file this big is not a beer picture.
14
+ const MAX_PHOTO_BYTES = 15 * 1024 * 1024;
15
+ /** UNTAPPD_PHOTO_DIR (one or more dirs, split on the platform path delimiter). */
16
+ function photoRoots() {
17
+ const raw = readEnvVar('UNTAPPD_PHOTO_DIR');
18
+ const roots = raw?.split(delimiter).filter(Boolean);
19
+ return roots && roots.length > 0 ? roots : undefined;
20
+ }
21
+ /**
22
+ * Vet a photo before it can be published to the public feed. photo_path is a
23
+ * free-form, model-supplied path, so an injected instruction could aim it at
24
+ * any file with an image-like name. Require the bytes to BE a JPEG/PNG that
25
+ * matches the extension, cap the size, honour the optional UNTAPPD_PHOTO_DIR
26
+ * allow-list, and return the resolved path + size so the dry run shows a human
27
+ * exactly which file would be uploaded. Errors never echo the path back.
28
+ */
29
+ async function checkPhoto(photoPath) {
30
+ const ext = photoExt(photoPath);
31
+ if (!(ext in PHOTO_CONTENT_TYPES)) {
32
+ throw createHelpfulError(`Unsupported photo type "${ext || '(none)'}".`, {
33
+ hint: 'Attach a .jpg, .jpeg, or .png file.',
34
+ });
35
+ }
36
+ const roots = photoRoots();
37
+ if (roots) {
38
+ try {
39
+ assertPathWithinRoots(photoPath, roots);
40
+ }
41
+ catch {
42
+ throw createHelpfulError('The photo is outside the allowed photo directory.', {
43
+ hint: 'UNTAPPD_PHOTO_DIR restricts which files can be attached to a check-in.',
44
+ });
45
+ }
46
+ }
47
+ let real;
48
+ let size;
49
+ try {
50
+ real = realpathSync(photoPath);
51
+ const st = statSync(real);
52
+ if (!st.isFile())
53
+ throw new Error('not a file');
54
+ size = st.size;
55
+ }
56
+ catch {
57
+ throw new McpToolError('Photo file not found or not readable.');
58
+ }
59
+ if (size > MAX_PHOTO_BYTES) {
60
+ throw createHelpfulError(`Photo is too large (${size} bytes; the limit is ${MAX_PHOTO_BYTES}).`, {
61
+ hint: 'Attach a normal-sized JPEG or PNG photo.',
62
+ });
63
+ }
64
+ const sniffed = sniffMimeBytes(await readFileHead(real, 16));
65
+ if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
66
+ throw createHelpfulError(sniffed === 'image/jpeg' || sniffed === 'image/png'
67
+ ? `Photo content (${sniffed}) does not match its .${ext} extension.`
68
+ : 'The file is not a JPEG or PNG image.', { hint: 'Attach a real .jpg/.jpeg or .png photo.' });
69
+ }
70
+ return { path: real, size_bytes: size, ext, content_type: sniffed };
71
+ }
13
72
  // Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
14
73
  const RatingSchema = z
15
74
  .number()
16
75
  .min(0)
17
76
  .max(5)
18
77
  .refine((r) => Math.round(r * 4) === r * 4, { message: 'rating must be a multiple of 0.25' });
19
- function localTimezone() {
20
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
21
- // getTimezoneOffset is minutes behind UTC (positive = behind), so negate for GMT offset in hours.
22
- const gmt_offset = -new Date().getTimezoneOffset() / 60;
23
- return { timezone, gmt_offset };
78
+ /** GMT offset in hours of an IANA zone at `at` (DST-aware), e.g. 5.5 for Asia/Kolkata. */
79
+ function gmtOffsetHours(timeZone, at) {
80
+ const name = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })
81
+ .formatToParts(at)
82
+ .find((p) => p.type === 'timeZoneName')?.value;
83
+ const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(name ?? '');
84
+ if (!m)
85
+ return 0; // plain "GMT"
86
+ const hours = Number(m[2]) + Number(m[3] ?? 0) / 60;
87
+ return m[1] === '-' ? -hours : hours;
88
+ }
89
+ /**
90
+ * The zone the check-in is stamped with: the caller's `timezone`, else
91
+ * UNTAPPD_TIMEZONE, else the server process's own zone. The process zone is
92
+ * only right when the server runs on the drinker's own machine — a hosted
93
+ * connector's process is typically UTC, which would stamp an evening check-in
94
+ * into the next day.
95
+ */
96
+ function checkinTimezone(requested) {
97
+ const timezone = requested ?? readEnvVar('UNTAPPD_TIMEZONE') ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
98
+ try {
99
+ return { timezone, gmt_offset: gmtOffsetHours(timezone, new Date()) };
100
+ }
101
+ catch {
102
+ throw createHelpfulError(`Unknown timezone "${timezone}".`, {
103
+ hint: 'Pass an IANA timezone name such as "America/New_York" or "Europe/London".',
104
+ });
105
+ }
106
+ }
107
+ // How far before the POST a recovered check-in's created_at may fall and still
108
+ // count as the one this call made. Only a small allowance for Untappd's
109
+ // whole-second created_at and modest clock skew: a wider window would match an
110
+ // earlier same-beer check-in (e.g. a prior timed-out attempt) and wrongly report
111
+ // it as this call's — telling the caller not to retry a check-in that never landed.
112
+ const RECOVERY_WINDOW_MS = 30_000;
113
+ /**
114
+ * Run a NON-idempotent write. A transport failure or timeout (UnreachableError)
115
+ * may fire after Untappd already received and applied the request, so it is
116
+ * reported as "outcome unknown" — with how to check — rather than "unreachable",
117
+ * which invites a blind retry that double-posts (or, for a toggle, undoes it).
118
+ */
119
+ async function nonIdempotentWrite(run, unknownOutcome) {
120
+ try {
121
+ return await run();
122
+ }
123
+ catch (e) {
124
+ if (e instanceof UnreachableError)
125
+ throw new McpToolError(unknownOutcome);
126
+ throw e;
127
+ }
128
+ }
129
+ /**
130
+ * After an outcome-unknown /checkin/add, look for the check-in it may have made:
131
+ * the caller's own most recent check-ins, same beer, created since just before
132
+ * the POST. Returns its id only when exactly one matches; null when none does,
133
+ * when more than one does (it can't tell which is this call's), or when the
134
+ * lookup fails — each of which the caller reports as an unknown outcome.
135
+ */
136
+ async function findRecentCheckin(client, bid, sentAt) {
137
+ const self = client.loginName;
138
+ if (!self)
139
+ return null;
140
+ try {
141
+ const data = await client.get(`/user/checkins/${encodeURIComponent(self)}`, { limit: 5 });
142
+ const matches = [];
143
+ for (const it of data?.checkins?.items ?? []) {
144
+ const c = it;
145
+ const at = Date.parse(c.created_at ?? '');
146
+ if (c.beer?.bid === bid && typeof c.checkin_id === 'number' && at >= sentAt - RECOVERY_WINDOW_MS) {
147
+ matches.push(c.checkin_id);
148
+ }
149
+ }
150
+ if (matches.length === 1)
151
+ return matches[0];
152
+ }
153
+ catch {
154
+ /* can't verify — the caller reports the outcome as unknown */
155
+ }
156
+ return null;
24
157
  }
25
158
  export function registerCheckinTools(server, client) {
26
159
  server.registerTool('untappd_toast', {
@@ -42,7 +175,9 @@ export function registerCheckinTools(server, client) {
42
175
  note: 'Dry run — re-run with confirm: true to toggle your toast on this check-in.',
43
176
  });
44
177
  }
45
- const data = await client.write('POST', `/checkin/toast/${checkin_id}`);
178
+ const data = await nonIdempotentWrite(() => client.write('POST', `/checkin/toast/${checkin_id}`), `Untappd did not answer the toast request for check-in ${checkin_id} in time, so it may or may not have ` +
179
+ 'been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with ' +
180
+ `untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`);
46
181
  return minifiedResult({ toggled: true, checkin_id, result: data?.result, like_type: data?.like_type });
47
182
  });
48
183
  server.registerTool('untappd_add_comment', {
@@ -65,7 +200,8 @@ export function registerCheckinTools(server, client) {
65
200
  note: 'Dry run — re-run with confirm: true to post this comment to your Untappd account.',
66
201
  });
67
202
  }
68
- const data = await client.write('POST', `/checkin/addcomment/${checkin_id}`, { form: { comment } });
203
+ const data = await nonIdempotentWrite(() => client.write('POST', `/checkin/addcomment/${checkin_id}`, { form: { comment } }), `Untappd did not answer in time, so the comment may have been posted already. Check the comments with ` +
204
+ `untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`);
69
205
  return minifiedResult({ posted: true, checkin_id, response: data });
70
206
  });
71
207
  server.registerTool('untappd_delete_comment', {
@@ -122,7 +258,11 @@ export function registerCheckinTools(server, client) {
122
258
  rating: RatingSchema.optional().describe('Rating 0–5 in 0.25 increments (omit for no rating)'),
123
259
  shout: z.string().max(2000).optional().describe('Optional shout / comment text for the check-in'),
124
260
  foursquare_id: z.string().optional().describe('Optional Foursquare venue id to tag the check-in location'),
125
- photo_path: z.string().optional().describe('Optional path to a local JPEG/PNG photo to attach to the check-in'),
261
+ photo_path: z
262
+ .string()
263
+ .optional()
264
+ .describe('Optional path to a local JPEG/PNG photo (max 15 MB) to attach — it is published publicly. Only use a ' +
265
+ 'file the user explicitly chose; the dry run shows the resolved path and size for them to confirm.'),
126
266
  geolat: z.number().optional().describe('Optional latitude of the check-in'),
127
267
  geolng: z.number().optional().describe('Optional longitude of the check-in'),
128
268
  container_id: z
@@ -130,19 +270,20 @@ export function registerCheckinTools(server, client) {
130
270
  .int()
131
271
  .optional()
132
272
  .describe('Optional serving container id (e.g. 1 = draft, 2 = bottle, 3 = can)'),
273
+ timezone: z
274
+ .string()
275
+ .min(1)
276
+ .optional()
277
+ .describe("The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to " +
278
+ "UNTAPPD_TIMEZONE, else the server's own zone — which on a hosted connector is usually UTC, so pass it " +
279
+ 'when you know where the user is.'),
133
280
  confirm: schemaConfirm,
134
281
  }),
135
- }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, confirm }) => {
136
- const { timezone, gmt_offset } = localTimezone();
137
- let ext;
138
- if (photo_path !== undefined) {
139
- ext = photoExt(photo_path);
140
- if (!(ext in PHOTO_CONTENT_TYPES)) {
141
- throw createHelpfulError(`Unsupported photo type "${ext || '(none)'}".`, {
142
- hint: 'Attach a .jpg, .jpeg, or .png file.',
143
- });
144
- }
145
- }
282
+ }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirm }) => {
283
+ const { timezone, gmt_offset } = checkinTimezone(requestedTz);
284
+ // Vetted on the dry run too, so the preview names the exact file (resolved
285
+ // path + size) that confirm: true would publish.
286
+ const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
146
287
  const form = {
147
288
  bid,
148
289
  rating: rating !== undefined ? rating.toFixed(2) : undefined,
@@ -153,8 +294,8 @@ export function registerCheckinTools(server, client) {
153
294
  container_id,
154
295
  timezone,
155
296
  gmt_offset,
156
- is_photo: photo_path !== undefined ? 'true' : 'false',
157
- photo_file_ext: photo_path !== undefined ? ext : undefined,
297
+ is_photo: photo ? 'true' : 'false',
298
+ photo_file_ext: photo?.ext,
158
299
  platform: 'ios',
159
300
  };
160
301
  if (confirm !== true) {
@@ -162,29 +303,55 @@ export function registerCheckinTools(server, client) {
162
303
  dryRun: true,
163
304
  action: 'checkin',
164
305
  form,
165
- photo: photo_path !== undefined ? { path: photo_path, note: 'will be uploaded after the check-in is created' } : undefined,
306
+ photo: photo ? { ...photo, note: 'this exact file will be uploaded PUBLICLY after the check-in is created' } : undefined,
166
307
  note: 'Dry run — re-run with confirm: true to POST this check-in to your public Untappd feed.',
167
308
  });
168
309
  }
169
310
  // Open the photo BEFORE creating the check-in, so a missing/unreadable
170
311
  // file fails fast without leaving an orphaned photo-less check-in behind.
171
312
  let blob;
172
- if (photo_path !== undefined) {
173
- if (!existsSync(photo_path))
174
- throw new McpToolError(`Photo file not found: ${photo_path}`);
175
- blob = await fileBlob(photo_path); // file-backed, streamed — not heap-buffered
313
+ if (photo) {
314
+ // file-backed, streamed — not heap-buffered; re-checks the size cap/roots at open.
315
+ blob = await fileBlob(photo.path, { maxBytes: MAX_PHOTO_BYTES, label: 'Photo', allowedRoots: photoRoots() });
316
+ }
317
+ const sentAt = Date.now();
318
+ let data;
319
+ try {
320
+ data = await client.write('POST', '/checkin/add', { form });
321
+ }
322
+ catch (e) {
323
+ if (!(e instanceof UnreachableError))
324
+ throw e;
325
+ // The POST may have landed before the connection failed. Look before
326
+ // reporting failure: a retry of a check-in that DID land double-posts it
327
+ // to the public feed (and double-counts stats and badges).
328
+ const found = await findRecentCheckin(client, bid, sentAt);
329
+ if (found === null) {
330
+ throw new McpToolError('Untappd did not answer the check-in request in time, so the check-in may have been created anyway ' +
331
+ '(it could not be confirmed either way). Look at your latest check-ins with untappd_user_checkins ' +
332
+ 'before retrying — retrying a check-in that did land posts it twice.');
333
+ }
334
+ return minifiedResult({
335
+ checked_in: true,
336
+ checkin_id: found,
337
+ recovered: true,
338
+ photo_attached: false,
339
+ ...(photo
340
+ ? { photo_error: `Check-in ${found} was created, but its photo upload URL was lost with the timed-out response, so no photo was attached.` }
341
+ : {}),
342
+ note: 'Untappd did not answer in time, but the check-in was found on your account — it was created. Do not retry.',
343
+ });
176
344
  }
177
- const data = await client.write('POST', '/checkin/add', { form });
178
345
  // Photo is a follow-up S3 upload keyed to the returned checkin_id, then an
179
346
  // uploadComplete call. The check-in already exists at this point, so a
180
347
  // photo failure is surfaced explicitly (photo_error) rather than thrown —
181
348
  // never silently dropped.
182
349
  let photo_attached = false;
183
350
  let photo_error;
184
- if (photo_path !== undefined) {
351
+ if (photo) {
185
352
  if (data?.photo_upload?.url && data.checkin_id) {
186
353
  try {
187
- await client.putBinary(data.photo_upload.url, blob, PHOTO_CONTENT_TYPES[ext]);
354
+ await client.putBinary(data.photo_upload.url, blob, photo.content_type);
188
355
  await client.write('POST', '/photo/uploadComplete', {
189
356
  form: { checkin_id: data.checkin_id, destination_url: data.photo_upload.destination_url },
190
357
  });
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@
3
3
  // json's `extra-files`), and `versionSyncTest` guards that it stays equal to
4
4
  // package.json. Import VERSION wherever the version is needed rather than
5
5
  // re-declaring it.
6
- export const VERSION = '2.1.2'; // x-release-please-version
6
+ export const VERSION = '2.1.3'; // x-release-please-version
package/mint.yaml CHANGED
@@ -26,6 +26,15 @@ env:
26
26
  required: true
27
27
  help: >-
28
28
  Untappd mobile app client secret.
29
+ - name: UNTAPPD_TIMEZONE
30
+ required: false
31
+ help: >-
32
+ IANA timezone (e.g. America/New_York) that check-ins are stamped with
33
+ when a call passes no timezone. Defaults to the server's own zone.
34
+ - name: UNTAPPD_PHOTO_DIR
35
+ required: false
36
+ help: >-
37
+ Restrict check-in photo uploads to files inside this directory.
29
38
  - name: UNTAPPD_CACHE_DB
30
39
  required: false
31
40
  help: >-
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "2.1.2",
3
+ "version": "2.1.3",
4
4
  "mcpName": "io.github.chrischall/untappd-mcp",
5
5
  "description": "Untappd MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/untappd-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "2.1.2",
9
+ "version": "2.1.3",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.1.2",
14
+ "version": "2.1.3",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },