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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +2 -0
- package/dist/bundle.js +235 -64
- package/dist/cache/store.js +14 -4
- package/dist/cache/sync-beers.js +54 -25
- package/dist/tools/checkin.js +199 -32
- package/dist/version.js +1 -1
- package/mint.yaml +9 -0
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -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.
|
|
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.
|
|
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.
|
|
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 = (
|
|
1589
|
-
const escapedDelim = util.escapeRegex(
|
|
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
|
|
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
|
-
|
|
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 =
|
|
5083
|
+
const depth = open3.length;
|
|
5084
5084
|
const result = base(payload, ctx);
|
|
5085
5085
|
handoff2 = void 0;
|
|
5086
|
-
const entry =
|
|
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 = (
|
|
40056
|
-
const escapedDelim = escapeRegex(
|
|
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.
|
|
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 {
|
|
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
|
|
59753
|
-
const
|
|
59754
|
-
const
|
|
59755
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 } =
|
|
59876
|
-
|
|
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:
|
|
59896
|
-
photo_file_ext:
|
|
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:
|
|
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 (
|
|
59910
|
-
|
|
59911
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
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
|
-
|
|
60271
|
-
|
|
60272
|
-
|
|
60273
|
-
|
|
60274
|
-
|
|
60275
|
-
|
|
60276
|
-
|
|
60277
|
-
|
|
60278
|
-
|
|
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:
|
|
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
|
}
|
package/dist/cache/store.js
CHANGED
|
@@ -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
|
/**
|
package/dist/cache/sync-beers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await cache.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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:
|
|
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
|
}
|
package/dist/tools/checkin.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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 } =
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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:
|
|
157
|
-
photo_file_ext:
|
|
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:
|
|
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 (
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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 (
|
|
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,
|
|
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.1.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "2.1.
|
|
14
|
+
"version": "2.1.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|