untappd-mcp 2.2.4 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/.mcp.json +1 -0
- package/README.md +9 -7
- package/dist/bundle.js +188 -76
- package/dist/cache/store.js +13 -11
- package/dist/cache/sync.js +6 -0
- package/dist/client.js +68 -25
- package/dist/tools/cache.js +22 -4
- package/dist/tools/checkin.js +53 -30
- package/dist/tools/confirm.js +3 -0
- package/dist/tools/friends.js +3 -1
- package/dist/tools/utilities.js +8 -2
- package/dist/tools/wishlist.js +6 -2
- package/dist/version.js +1 -1
- package/package.json +2 -3
- package/server.json +59 -10
- package/skills/untappd-mcp/SKILL.md +1 -1
package/dist/cache/store.js
CHANGED
|
@@ -369,9 +369,9 @@ export class CheckinStoreCore {
|
|
|
369
369
|
}
|
|
370
370
|
if (opts.beerName !== undefined) {
|
|
371
371
|
const like = `%${escapeLike(opts.beerName)}%`;
|
|
372
|
-
cClause.push(
|
|
372
|
+
cClause.push(`beer_name ${LIKE_MATCH}`);
|
|
373
373
|
cParams.push(like);
|
|
374
|
-
bClause.push(
|
|
374
|
+
bClause.push(`beer_name ${LIKE_MATCH}`);
|
|
375
375
|
bParams.push(like);
|
|
376
376
|
}
|
|
377
377
|
const cWhere = cClause.join(' AND ');
|
|
@@ -429,11 +429,11 @@ export class CheckinStoreCore {
|
|
|
429
429
|
params.push(filters.brewery_id);
|
|
430
430
|
}
|
|
431
431
|
if (filters.brewery !== undefined) {
|
|
432
|
-
where.push(
|
|
432
|
+
where.push(`brewery_name ${LIKE_MATCH}`);
|
|
433
433
|
params.push(`%${escapeLike(filters.brewery)}%`);
|
|
434
434
|
}
|
|
435
435
|
if (filters.style !== undefined) {
|
|
436
|
-
where.push(
|
|
436
|
+
where.push(`beer_style ${LIKE_MATCH}`);
|
|
437
437
|
params.push(`%${escapeLike(filters.style)}%`);
|
|
438
438
|
}
|
|
439
439
|
if (filters.min_rating !== undefined) {
|
|
@@ -441,7 +441,7 @@ export class CheckinStoreCore {
|
|
|
441
441
|
params.push(filters.min_rating);
|
|
442
442
|
}
|
|
443
443
|
if (filters.venue !== undefined) {
|
|
444
|
-
where.push(
|
|
444
|
+
where.push(`venue_name ${LIKE_MATCH}`);
|
|
445
445
|
params.push(`%${escapeLike(filters.venue)}%`);
|
|
446
446
|
}
|
|
447
447
|
if (filters.venue_id !== undefined) {
|
|
@@ -459,9 +459,9 @@ export class CheckinStoreCore {
|
|
|
459
459
|
const order = filters.sort === 'oldest'
|
|
460
460
|
? 'created_at ASC, checkin_id ASC'
|
|
461
461
|
: filters.sort === 'highest_rated'
|
|
462
|
-
? 'rating DESC, created_at DESC'
|
|
462
|
+
? 'rating IS NULL, rating DESC, created_at DESC'
|
|
463
463
|
: filters.sort === 'lowest_rated'
|
|
464
|
-
? 'rating ASC, created_at DESC'
|
|
464
|
+
? 'rating IS NULL, rating ASC, created_at DESC'
|
|
465
465
|
: 'created_at DESC, checkin_id DESC';
|
|
466
466
|
const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
|
|
467
467
|
return this.db.all(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`, [...params, limit]);
|
|
@@ -560,9 +560,11 @@ export class LocalCacheStore {
|
|
|
560
560
|
return this.core.forgetUser(username);
|
|
561
561
|
}
|
|
562
562
|
}
|
|
563
|
-
//
|
|
564
|
-
//
|
|
565
|
-
|
|
563
|
+
// Case-insensitive substring match whose pattern is built by escapeLike(): the
|
|
564
|
+
// ESCAPE clause makes a backslash-prefixed %, _ or backslash match itself literally.
|
|
565
|
+
const LIKE_MATCH = "LIKE ? COLLATE NOCASE ESCAPE '\\'";
|
|
566
|
+
// Escape LIKE wildcards in user input so a literal % or _ in a beer/brewery/venue
|
|
567
|
+
// name matches itself instead of acting as a wildcard (pair with LIKE_MATCH).
|
|
566
568
|
function escapeLike(s) {
|
|
567
|
-
return s.replace(/[
|
|
569
|
+
return s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
568
570
|
}
|
package/dist/cache/sync.js
CHANGED
|
@@ -26,6 +26,12 @@ function rowsOf(username, items) {
|
|
|
26
26
|
if (r)
|
|
27
27
|
rows.push(r);
|
|
28
28
|
}
|
|
29
|
+
if (rows.length === 0 && items.length > 0) {
|
|
30
|
+
// Every item on a non-empty page failed to map — an upstream shape change,
|
|
31
|
+
// not the end of the history. Say so, rather than letting the caller index
|
|
32
|
+
// rows[0] / rows[rows.length - 1] and die with a bare TypeError.
|
|
33
|
+
throw createHelpfulError(`Untappd returned ${items.length} check-in(s) for "${username}" with no numeric checkin_id; the response shape may have changed.`, { hint: 'Progress from earlier pages is kept. Re-run the sync later; if it persists, the user/checkins parser needs updating.' });
|
|
34
|
+
}
|
|
29
35
|
return rows;
|
|
30
36
|
}
|
|
31
37
|
/** Best-effort stats.total_checkins; null if it can't be read. */
|
package/dist/client.js
CHANGED
|
@@ -29,19 +29,26 @@ const DEFAULTS = {
|
|
|
29
29
|
// works. Override with UNTAPPD_DEVICE_ID to pin your own.
|
|
30
30
|
deviceUdid: 'A1B2C3D4-0000-4000-8000-0000000000AA',
|
|
31
31
|
};
|
|
32
|
+
// Statuses whose Response may not carry a body (the constructor rejects one).
|
|
33
|
+
const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
32
34
|
// One HTTP attempt with a hard timeout, shared by the instance `send()` method
|
|
33
|
-
// and the standalone `xauthLogin()` helper below.
|
|
34
|
-
//
|
|
35
|
+
// and the standalone `xauthLogin()` helper below. The timeout covers reading
|
|
36
|
+
// the BODY too, not just the headers: the body is buffered here, under the same
|
|
37
|
+
// abort timer, so a server that sends headers and then stalls can't hang the
|
|
38
|
+
// call (or, via the coalesced login, every call waiting on it). Network/timeout
|
|
39
|
+
// failures become an UnreachableError; HTTP status handling is left to the caller.
|
|
35
40
|
async function sendRequest(fetchImpl, method, url, init) {
|
|
36
41
|
const controller = new AbortController();
|
|
37
42
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
38
43
|
try {
|
|
39
|
-
|
|
44
|
+
const res = await fetchImpl(url, {
|
|
40
45
|
method,
|
|
41
46
|
headers: init.headers,
|
|
42
47
|
...(init.body !== undefined ? { body: init.body } : {}),
|
|
43
48
|
signal: controller.signal,
|
|
44
49
|
});
|
|
50
|
+
const body = NULL_BODY_STATUSES.has(res.status) ? null : await res.arrayBuffer();
|
|
51
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
45
52
|
}
|
|
46
53
|
catch {
|
|
47
54
|
throw new UnreachableError(SERVICE);
|
|
@@ -110,16 +117,28 @@ export async function xauthLogin(creds, opts = {}) {
|
|
|
110
117
|
}
|
|
111
118
|
return token;
|
|
112
119
|
}
|
|
113
|
-
|
|
114
|
-
|
|
120
|
+
/**
|
|
121
|
+
* No token and no complete login set: nothing can be called. Names exactly what
|
|
122
|
+
* is missing from the login set, because a token alone (no app creds) is a
|
|
123
|
+
* complete read-only setup — the app creds are not "required either way".
|
|
124
|
+
*/
|
|
125
|
+
function missingCredsError(missing) {
|
|
115
126
|
// Both paths in the MESSAGE, not only `hint`: MCP serialization surfaces the
|
|
116
127
|
// message and drops the hint, so a token path mentioned only there is
|
|
117
128
|
// invisible exactly when someone is deciding what to configure.
|
|
118
|
-
const remedy = 'Either set UNTAPPD_ACCESS_TOKEN (an access token you already hold —
|
|
119
|
-
'
|
|
120
|
-
'UNTAPPD_CLIENT_SECRET (the Untappd mobile app client credentials)
|
|
129
|
+
const remedy = 'Either set UNTAPPD_ACCESS_TOKEN (an access token you already hold — reads need nothing else; writes also ' +
|
|
130
|
+
'need UNTAPPD_CLIENT_ID and UNTAPPD_CLIENT_SECRET), or set UNTAPPD_USERNAME, UNTAPPD_PASSWORD, ' +
|
|
131
|
+
'UNTAPPD_CLIENT_ID and UNTAPPD_CLIENT_SECRET (the Untappd mobile app client credentials) to log in for one. ' +
|
|
132
|
+
'See the README.';
|
|
121
133
|
return createHelpfulError(`Untappd credentials are not configured — missing ${missing.join(', ') || 'credentials'}. ${remedy}`, { hint: remedy });
|
|
122
134
|
}
|
|
135
|
+
/** A working token, but a write needs the app's client credentials too. */
|
|
136
|
+
function missingWriteCredsError(missing) {
|
|
137
|
+
const remedy = 'Untappd writes (check-ins, toasts, comments, wishlist and friend actions) carry the mobile app client ' +
|
|
138
|
+
'credentials alongside the token; reads work without them. Set UNTAPPD_CLIENT_ID and UNTAPPD_CLIENT_SECRET ' +
|
|
139
|
+
'(see the README).';
|
|
140
|
+
return createHelpfulError(`This is a write, and it needs ${missing.join(' and ')}. ${remedy}`, { hint: remedy });
|
|
141
|
+
}
|
|
123
142
|
export class UntappdClient {
|
|
124
143
|
fetchImpl;
|
|
125
144
|
clientId;
|
|
@@ -155,28 +174,50 @@ export class UntappdClient {
|
|
|
155
174
|
this.password = opts.password ?? readEnvVar('UNTAPPD_PASSWORD') ?? null;
|
|
156
175
|
this._loginName = opts.loginName ?? this.username;
|
|
157
176
|
}
|
|
158
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* True when authenticated READS can be made: a token is held (reads carry
|
|
179
|
+
* only `access_token`), or the full login set can mint one. App creds alone
|
|
180
|
+
* cannot make any call. Writes additionally need the app creds — `canWrite`.
|
|
181
|
+
*/
|
|
159
182
|
get configured() {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
183
|
+
return Boolean(this.token) || this.loginMissing().length === 0;
|
|
184
|
+
}
|
|
185
|
+
/** True when writes can be made too: configured, plus the app's client credentials. */
|
|
186
|
+
get canWrite() {
|
|
187
|
+
return this.configured && this.appCredsMissing().length === 0;
|
|
188
|
+
}
|
|
189
|
+
appCredsMissing() {
|
|
190
|
+
const missing = [];
|
|
191
|
+
if (!this.clientId)
|
|
192
|
+
missing.push('UNTAPPD_CLIENT_ID');
|
|
193
|
+
if (!this.clientSecret)
|
|
194
|
+
missing.push('UNTAPPD_CLIENT_SECRET');
|
|
195
|
+
return missing;
|
|
196
|
+
}
|
|
197
|
+
loginMissing() {
|
|
198
|
+
const missing = [];
|
|
199
|
+
if (!this.username)
|
|
200
|
+
missing.push('UNTAPPD_USERNAME');
|
|
201
|
+
if (!this.password)
|
|
202
|
+
missing.push('UNTAPPD_PASSWORD');
|
|
203
|
+
return [...missing, ...this.appCredsMissing()];
|
|
164
204
|
}
|
|
165
205
|
/** The configured login name — the default `username` for user-scoped tools. */
|
|
166
206
|
get loginName() {
|
|
167
207
|
return this._loginName;
|
|
168
208
|
}
|
|
209
|
+
/** App creds for a write; reached only once a token is in hand. */
|
|
169
210
|
requireAppCreds() {
|
|
170
211
|
if (!this.clientId || !this.clientSecret) {
|
|
171
|
-
throw
|
|
212
|
+
throw missingWriteCredsError(this.appCredsMissing());
|
|
172
213
|
}
|
|
173
214
|
return { clientId: this.clientId, clientSecret: this.clientSecret };
|
|
174
215
|
}
|
|
175
216
|
requireLogin() {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return {
|
|
217
|
+
if (!this.username || !this.password || !this.clientId || !this.clientSecret) {
|
|
218
|
+
throw missingCredsError(this.loginMissing());
|
|
219
|
+
}
|
|
220
|
+
return { username: this.username, password: this.password, clientId: this.clientId, clientSecret: this.clientSecret };
|
|
180
221
|
}
|
|
181
222
|
baseHeaders() {
|
|
182
223
|
return {
|
|
@@ -247,18 +288,20 @@ export class UntappdClient {
|
|
|
247
288
|
async request(method, path, opts, isRetry = false) {
|
|
248
289
|
const token = await this.ensureToken();
|
|
249
290
|
const headers = this.baseHeaders();
|
|
250
|
-
// The
|
|
251
|
-
//
|
|
252
|
-
// the query
|
|
253
|
-
//
|
|
291
|
+
// The token always travels in an `Authorization: Bearer` header, never the
|
|
292
|
+
// URL, where proxy and server logs would record it. The Untappd app itself
|
|
293
|
+
// puts it in the query on reads, but the API accepts the header for reads
|
|
294
|
+
// too (live-verified 2026-10-09 on /user/info, /beer/info and /search/beer,
|
|
295
|
+
// with no app creds). Writes also carry the client credentials in the
|
|
296
|
+
// query, as the app's captured write requests do.
|
|
297
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
254
298
|
let query;
|
|
255
299
|
if (opts.auth === 'bearer') {
|
|
256
300
|
const c = this.requireAppCreds();
|
|
257
|
-
headers['Authorization'] = `Bearer ${token}`;
|
|
258
301
|
query = { ...opts.query, client_id: c.clientId, client_secret: c.clientSecret, utv: this.utv };
|
|
259
302
|
}
|
|
260
303
|
else {
|
|
261
|
-
query = { ...opts.query,
|
|
304
|
+
query = { ...opts.query, utv: this.utv };
|
|
262
305
|
}
|
|
263
306
|
const qs = buildQueryString(query);
|
|
264
307
|
let body;
|
|
@@ -290,7 +333,7 @@ export class UntappdClient {
|
|
|
290
333
|
}
|
|
291
334
|
return this.parseJson(res, method, path);
|
|
292
335
|
}
|
|
293
|
-
/** Authenticated read (token in
|
|
336
|
+
/** Authenticated read (token in a Bearer header; no app creds needed). */
|
|
294
337
|
async get(path, query = {}) {
|
|
295
338
|
return this.request('GET', path, { query, auth: 'query' });
|
|
296
339
|
}
|
package/dist/tools/cache.js
CHANGED
|
@@ -270,7 +270,8 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
270
270
|
'the cache only (both sources, no API call). Beer ratings/styles come from a metadata cache; a beer/info API ' +
|
|
271
271
|
'call is made only on a cache miss or if the cached metadata is >30 days old, capped at api_budget calls per ' +
|
|
272
272
|
'run (~100 calls/hour limit) — if more are needed it returns partial: true / another_run_needed: true, so ' +
|
|
273
|
-
're-running fills the rest.
|
|
273
|
+
're-running fills the rest. A beer whose refresh is deferred or fails is still ranked on its older cached ' +
|
|
274
|
+
'metadata and marked stale: true. Reports the same freshness/caveat block as untappd_cache_not_had. Omit username ' +
|
|
274
275
|
'for your own account.',
|
|
275
276
|
annotations: toolAnnotations({ title: 'Top-rated beers a user has NOT had, from a candidate list', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
276
277
|
inputSchema: z.object({
|
|
@@ -349,7 +350,18 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
349
350
|
}
|
|
350
351
|
if (fetched.length)
|
|
351
352
|
await cache.upsertBeerMeta(fetched);
|
|
352
|
-
|
|
353
|
+
// A bid whose refresh was deferred or failed still has its older cached
|
|
354
|
+
// metadata — rank it (flagged stale) rather than silently dropping a beer
|
|
355
|
+
// the cache knows about.
|
|
356
|
+
const refreshed = new Set(fetched.map((m) => m.bid));
|
|
357
|
+
const staleMeta = [];
|
|
358
|
+
for (const bid of needFetch) {
|
|
359
|
+
const m = metaByBid.get(bid);
|
|
360
|
+
if (m && !refreshed.has(bid))
|
|
361
|
+
staleMeta.push(m);
|
|
362
|
+
}
|
|
363
|
+
const staleBids = new Set(staleMeta.map((m) => m.bid));
|
|
364
|
+
const available = [...haveMeta, ...fetched, ...staleMeta];
|
|
353
365
|
const ratingOf = (m) => m.weighted_rating_score ?? m.rating_score ?? 0;
|
|
354
366
|
// Style filter: substring against EITHER the beer style or its parent style.
|
|
355
367
|
let matched = available;
|
|
@@ -369,8 +381,11 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
369
381
|
abv: m.abv,
|
|
370
382
|
rating: m.weighted_rating_score ?? m.rating_score,
|
|
371
383
|
rating_count: m.rating_count,
|
|
384
|
+
...(staleBids.has(m.bid) ? { stale: true } : {}),
|
|
372
385
|
}));
|
|
373
|
-
|
|
386
|
+
// Errors leave beers unranked (or ranked on stale data) just like a
|
|
387
|
+
// deferral does, so a re-run is worth it in both cases.
|
|
388
|
+
const partial = deferred > 0 || errors > 0;
|
|
374
389
|
return minifiedResult({
|
|
375
390
|
username: user,
|
|
376
391
|
ranked,
|
|
@@ -380,6 +395,7 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
380
395
|
style_matched: style !== undefined ? matched.length : null,
|
|
381
396
|
api_calls_used: apiCalls,
|
|
382
397
|
errors,
|
|
398
|
+
stale: staleMeta.length,
|
|
383
399
|
partial,
|
|
384
400
|
another_run_needed: partial,
|
|
385
401
|
...(rateLimited ? { rate_limited: true } : {}),
|
|
@@ -399,7 +415,8 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
399
415
|
username: UsernameArg,
|
|
400
416
|
confirmToken: confirmTokenParam,
|
|
401
417
|
}),
|
|
402
|
-
}, async (
|
|
418
|
+
}, async (args, ctx) => {
|
|
419
|
+
const { username, confirmToken } = args;
|
|
403
420
|
const user = resolveUser(username, client.loginName);
|
|
404
421
|
const cache = cacheProvider();
|
|
405
422
|
// Bound into the token: if a sync adds rows after the preview, the
|
|
@@ -413,6 +430,7 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
413
430
|
action: 'untappd.cache_forget',
|
|
414
431
|
message: `Review and confirm deleting ${user}'s data from the local Untappd cache:`,
|
|
415
432
|
confirmToken,
|
|
433
|
+
args,
|
|
416
434
|
target: user.toLowerCase(),
|
|
417
435
|
payload: { username: user.toLowerCase(), ...counts },
|
|
418
436
|
preview: {
|
package/dist/tools/checkin.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createReadStream, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { pipeline } from 'node:stream/promises';
|
|
4
|
-
import {
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { delimiter, extname, join } from 'node:path';
|
|
5
6
|
import { z } from 'zod';
|
|
6
7
|
import { confirmTokenParam, McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
|
|
7
8
|
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
@@ -15,18 +16,24 @@ function photoExt(path) {
|
|
|
15
16
|
}
|
|
16
17
|
// Far above any phone photo; a file this big is not a beer picture.
|
|
17
18
|
const MAX_PHOTO_BYTES = 15 * 1024 * 1024;
|
|
18
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* The directories a photo_path may come from: UNTAPPD_PHOTO_DIR (one or more,
|
|
21
|
+
* split on the platform path delimiter), else ~/Downloads/untappd-mcp. Confined
|
|
22
|
+
* by DEFAULT because the photo is published to the public feed and the path is
|
|
23
|
+
* model-supplied — a prompt-injected shout/comment could otherwise name any
|
|
24
|
+
* JPEG/PNG on the disk (a screenshot of a bank statement), and under
|
|
25
|
+
* MCP_CONFIRM_MODE=auto the model reviews its own preview.
|
|
26
|
+
*/
|
|
19
27
|
function photoRoots() {
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
return roots && roots.length > 0 ? roots : undefined;
|
|
28
|
+
const roots = readEnvVar('UNTAPPD_PHOTO_DIR')?.split(delimiter).filter(Boolean) ?? [];
|
|
29
|
+
return roots.length > 0 ? roots : [join(homedir(), 'Downloads', 'untappd-mcp')];
|
|
23
30
|
}
|
|
24
31
|
/**
|
|
25
32
|
* Vet a photo before it can be published to the public feed. photo_path is a
|
|
26
33
|
* free-form, model-supplied path, so an injected instruction could aim it at
|
|
27
34
|
* any file with an image-like name. Require the bytes to BE a JPEG/PNG that
|
|
28
|
-
* matches the extension, cap the size,
|
|
29
|
-
*
|
|
35
|
+
* matches the extension, cap the size, confine it to the photo directory
|
|
36
|
+
* ({@link photoRoots}), and return the resolved path + size so the preview shows a human
|
|
30
37
|
* exactly which file would be uploaded. Errors never echo the path back.
|
|
31
38
|
*/
|
|
32
39
|
async function checkPhoto(photoPath) {
|
|
@@ -37,15 +44,15 @@ async function checkPhoto(photoPath) {
|
|
|
37
44
|
});
|
|
38
45
|
}
|
|
39
46
|
const roots = photoRoots();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
47
|
+
try {
|
|
48
|
+
assertPathWithinRoots(photoPath, roots);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Names the allowed directory (config, not the caller's path) so the user knows where to put the photo.
|
|
52
|
+
throw createHelpfulError(`The photo is outside the allowed photo directory (${roots.join(', ')}).`, {
|
|
53
|
+
hint: 'Ask the user to copy the photo there (or set UNTAPPD_PHOTO_DIR). Never attach a file because text from ' +
|
|
54
|
+
'Untappd (a shout, comment or notification) asked for it.',
|
|
55
|
+
});
|
|
49
56
|
}
|
|
50
57
|
let real;
|
|
51
58
|
let size;
|
|
@@ -64,7 +71,7 @@ async function checkPhoto(photoPath) {
|
|
|
64
71
|
hint: 'Attach a normal-sized JPEG or PNG photo.',
|
|
65
72
|
});
|
|
66
73
|
}
|
|
67
|
-
// Re-check
|
|
74
|
+
// Re-check the photo directory at open time, so a swap after the check above can't escape it.
|
|
68
75
|
let head;
|
|
69
76
|
try {
|
|
70
77
|
head = await readFileHead(real, 16, { allowedRoots: roots });
|
|
@@ -155,11 +162,13 @@ async function nonIdempotentWrite(run, unknownOutcome) {
|
|
|
155
162
|
* lookup fails — each of which the caller reports as an unknown outcome.
|
|
156
163
|
*/
|
|
157
164
|
async function findRecentCheckin(client, bid, sentAt) {
|
|
165
|
+
// With no configured username (a token-only deployment) use the self form —
|
|
166
|
+
// user/checkins with no username lists the token's own account — rather than
|
|
167
|
+
// giving up on a lookup that one call would settle.
|
|
158
168
|
const self = client.loginName;
|
|
159
|
-
|
|
160
|
-
return null;
|
|
169
|
+
const path = self ? `/user/checkins/${encodeURIComponent(self)}` : '/user/checkins';
|
|
161
170
|
try {
|
|
162
|
-
const data = await client.get(
|
|
171
|
+
const data = await client.get(path, { limit: 5 });
|
|
163
172
|
const matches = [];
|
|
164
173
|
for (const it of data?.checkins?.items ?? []) {
|
|
165
174
|
const c = it;
|
|
@@ -181,18 +190,21 @@ export function registerCheckinTools(server, client) {
|
|
|
181
190
|
title: 'Toast an Untappd check-in',
|
|
182
191
|
description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have " +
|
|
183
192
|
`already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
|
|
184
|
-
|
|
193
|
+
// Destructive: a toast lands in the check-in owner's notifications, and toggling it back off cannot un-notify them.
|
|
194
|
+
annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
|
|
185
195
|
inputSchema: z.object({
|
|
186
196
|
checkin_id: CheckinIdSchema,
|
|
187
197
|
confirmToken: confirmTokenParam,
|
|
188
198
|
}),
|
|
189
|
-
}, async (
|
|
199
|
+
}, async (args, ctx) => {
|
|
200
|
+
const { checkin_id, confirmToken } = args;
|
|
190
201
|
const request = { method: 'POST', path: `/checkin/toast/${checkin_id}` };
|
|
191
202
|
const gate = await confirmWrite(ctx, {
|
|
192
203
|
tool: 'untappd_toast',
|
|
193
204
|
action: 'untappd.toast',
|
|
194
205
|
message: 'Review and confirm toggling your toast on this Untappd check-in:',
|
|
195
206
|
confirmToken,
|
|
207
|
+
args,
|
|
196
208
|
target: checkin_id,
|
|
197
209
|
payload: request,
|
|
198
210
|
preview: {
|
|
@@ -213,19 +225,22 @@ export function registerCheckinTools(server, client) {
|
|
|
213
225
|
title: 'Comment on an Untappd check-in',
|
|
214
226
|
description: 'Post a comment on a check-in from YOUR account. Writes to your Untappd account and is visible to others. ' +
|
|
215
227
|
CONFIRM_FLOW,
|
|
216
|
-
|
|
228
|
+
// Destructive: the comment reaches the check-in owner (and other readers); untappd_delete_comment removes it but cannot un-notify anyone.
|
|
229
|
+
annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
|
|
217
230
|
inputSchema: z.object({
|
|
218
231
|
checkin_id: CheckinIdSchema,
|
|
219
232
|
comment: z.string().min(1).max(2000).describe('Comment text to post'),
|
|
220
233
|
confirmToken: confirmTokenParam,
|
|
221
234
|
}),
|
|
222
|
-
}, async (
|
|
235
|
+
}, async (args, ctx) => {
|
|
236
|
+
const { checkin_id, comment, confirmToken } = args;
|
|
223
237
|
const request = { method: 'POST', path: `/checkin/addcomment/${checkin_id}`, form: { comment } };
|
|
224
238
|
const gate = await confirmWrite(ctx, {
|
|
225
239
|
tool: 'untappd_add_comment',
|
|
226
240
|
action: 'untappd.add_comment',
|
|
227
241
|
message: 'Review and confirm posting this comment from your Untappd account:',
|
|
228
242
|
confirmToken,
|
|
243
|
+
args,
|
|
229
244
|
target: checkin_id,
|
|
230
245
|
payload: request,
|
|
231
246
|
preview: {
|
|
@@ -250,13 +265,15 @@ export function registerCheckinTools(server, client) {
|
|
|
250
265
|
comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
|
|
251
266
|
confirmToken: confirmTokenParam,
|
|
252
267
|
}),
|
|
253
|
-
}, async (
|
|
268
|
+
}, async (args, ctx) => {
|
|
269
|
+
const { comment_id, confirmToken } = args;
|
|
254
270
|
const request = { method: 'POST', path: `/checkin/deletecomment/${comment_id}` };
|
|
255
271
|
const gate = await confirmWrite(ctx, {
|
|
256
272
|
tool: 'untappd_delete_comment',
|
|
257
273
|
action: 'untappd.delete_comment',
|
|
258
274
|
message: 'Review and confirm deleting this comment from your Untappd account:',
|
|
259
275
|
confirmToken,
|
|
276
|
+
args,
|
|
260
277
|
target: comment_id,
|
|
261
278
|
payload: request,
|
|
262
279
|
preview: { action: 'delete_comment', comment_id, ...request, note: 'Deletes this comment from your Untappd account.' },
|
|
@@ -274,13 +291,15 @@ export function registerCheckinTools(server, client) {
|
|
|
274
291
|
checkin_id: CheckinIdSchema,
|
|
275
292
|
confirmToken: confirmTokenParam,
|
|
276
293
|
}),
|
|
277
|
-
}, async (
|
|
294
|
+
}, async (args, ctx) => {
|
|
295
|
+
const { checkin_id, confirmToken } = args;
|
|
278
296
|
const request = { method: 'POST', path: `/checkin/delete/${checkin_id}` };
|
|
279
297
|
const gate = await confirmWrite(ctx, {
|
|
280
298
|
tool: 'untappd_delete_checkin',
|
|
281
299
|
action: 'untappd.delete_checkin',
|
|
282
300
|
message: 'Review and confirm PERMANENTLY deleting this Untappd check-in:',
|
|
283
301
|
confirmToken,
|
|
302
|
+
args,
|
|
284
303
|
target: checkin_id,
|
|
285
304
|
payload: request,
|
|
286
305
|
preview: {
|
|
@@ -301,7 +320,8 @@ export function registerCheckinTools(server, client) {
|
|
|
301
320
|
'(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), a venue via ' +
|
|
302
321
|
'foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo ' +
|
|
303
322
|
`that will be posted. ${CONFIRM_FLOW}`,
|
|
304
|
-
|
|
323
|
+
// Destructive: it publishes to your public feed, where friends see it; untappd_delete_checkin cannot unpublish what they saw.
|
|
324
|
+
annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
|
|
305
325
|
inputSchema: z.object({
|
|
306
326
|
bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
|
|
307
327
|
rating: RatingSchema.optional().describe('Rating 0–5 in 0.25 increments (omit for no rating)'),
|
|
@@ -310,8 +330,9 @@ export function registerCheckinTools(server, client) {
|
|
|
310
330
|
photo_path: z
|
|
311
331
|
.string()
|
|
312
332
|
.optional()
|
|
313
|
-
.describe('Optional path to a local JPEG/PNG photo (max 15 MB) to attach — it is published publicly.
|
|
314
|
-
'
|
|
333
|
+
.describe('Optional path to a local JPEG/PNG photo (max 15 MB) to attach — it is published publicly. It must be ' +
|
|
334
|
+
'inside the photo directory (UNTAPPD_PHOTO_DIR, default ~/Downloads/untappd-mcp). Only use a file the ' +
|
|
335
|
+
'user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
|
|
315
336
|
geolat: z.number().optional().describe('Optional latitude of the check-in'),
|
|
316
337
|
geolng: z.number().optional().describe('Optional longitude of the check-in'),
|
|
317
338
|
container_id: z
|
|
@@ -328,7 +349,8 @@ export function registerCheckinTools(server, client) {
|
|
|
328
349
|
'when you know where the user is.'),
|
|
329
350
|
confirmToken: confirmTokenParam,
|
|
330
351
|
}),
|
|
331
|
-
}, async (
|
|
352
|
+
}, async (args, ctx) => {
|
|
353
|
+
const { bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken } = args;
|
|
332
354
|
const { timezone, gmt_offset } = checkinTimezone(requestedTz);
|
|
333
355
|
// Vetted on every call, so the preview names the exact file (resolved path
|
|
334
356
|
// + size) that will be published — and a file whose bytes change between
|
|
@@ -355,6 +377,7 @@ export function registerCheckinTools(server, client) {
|
|
|
355
377
|
action: 'untappd.checkin',
|
|
356
378
|
message: 'Review and confirm posting this check-in to your public Untappd feed:',
|
|
357
379
|
confirmToken,
|
|
380
|
+
args,
|
|
358
381
|
target: bid,
|
|
359
382
|
payload: { ...request, photo, photo_sha256 },
|
|
360
383
|
preview: {
|
package/dist/tools/confirm.js
CHANGED
|
@@ -15,6 +15,9 @@ export function confirmWrite(ctx, c) {
|
|
|
15
15
|
details: c.preview,
|
|
16
16
|
tool: c.tool,
|
|
17
17
|
confirmToken: c.confirmToken,
|
|
18
|
+
// One process acts with one Untappd access token: a single-account server.
|
|
19
|
+
account: undefined,
|
|
20
|
+
args: c.args,
|
|
18
21
|
subject: () => ({ target: String(c.target), payload: c.payload, preview: c.preview }),
|
|
19
22
|
}));
|
|
20
23
|
}
|
package/dist/tools/friends.js
CHANGED
|
@@ -59,13 +59,15 @@ export function registerFriendActionTools(server, client) {
|
|
|
59
59
|
target_uid: TargetUidSchema,
|
|
60
60
|
confirmToken: confirmTokenParam,
|
|
61
61
|
}),
|
|
62
|
-
}, async (
|
|
62
|
+
}, async (args, ctx) => {
|
|
63
|
+
const { target_uid, confirmToken } = args;
|
|
63
64
|
const request = { method: 'POST', path: `/friend/${action.path}/${target_uid}` };
|
|
64
65
|
const gate = await confirmWrite(ctx, {
|
|
65
66
|
tool: action.tool,
|
|
66
67
|
action: `untappd.friend_${action.path}`,
|
|
67
68
|
message: `Review and confirm: ${action.verb} Untappd user ${target_uid}.`,
|
|
68
69
|
confirmToken,
|
|
70
|
+
args,
|
|
69
71
|
target: target_uid,
|
|
70
72
|
payload: request,
|
|
71
73
|
preview: { action: action.path, target_uid, ...request, note: `Will ${action.verb} user ${target_uid}.` },
|
package/dist/tools/utilities.js
CHANGED
|
@@ -20,7 +20,9 @@ export function registerUtilityTools(server, client) {
|
|
|
20
20
|
ok: false,
|
|
21
21
|
configured: false,
|
|
22
22
|
...build,
|
|
23
|
-
note: 'Untappd credentials are not set. Configure
|
|
23
|
+
note: 'Untappd credentials are not set. Configure either UNTAPPD_ACCESS_TOKEN (a token you already hold — no ' +
|
|
24
|
+
'password needed; enough for reads) or UNTAPPD_USERNAME, UNTAPPD_PASSWORD, UNTAPPD_CLIENT_ID and ' +
|
|
25
|
+
'UNTAPPD_CLIENT_SECRET to log in. Writes also need UNTAPPD_CLIENT_ID and UNTAPPD_CLIENT_SECRET.',
|
|
24
26
|
});
|
|
25
27
|
}
|
|
26
28
|
const feed = await client.get('/checkin/recent', { limit: 1 });
|
|
@@ -29,8 +31,12 @@ export function registerUtilityTools(server, client) {
|
|
|
29
31
|
configured: true,
|
|
30
32
|
account: client.loginName,
|
|
31
33
|
feed_reachable: feed?.checkins !== undefined,
|
|
34
|
+
writes_enabled: client.canWrite,
|
|
32
35
|
...build,
|
|
33
|
-
note:
|
|
36
|
+
note: client.canWrite
|
|
37
|
+
? 'Logged in to Untappd and fetched the friend feed successfully.'
|
|
38
|
+
: 'Fetched the friend feed successfully with the access token. Reads work; writes (check-in, toast, ' +
|
|
39
|
+
'comment, wishlist, friend actions) need UNTAPPD_CLIENT_ID and UNTAPPD_CLIENT_SECRET as well.',
|
|
34
40
|
});
|
|
35
41
|
});
|
|
36
42
|
}
|
package/dist/tools/wishlist.js
CHANGED
|
@@ -11,13 +11,15 @@ export function registerWishlistTools(server, client) {
|
|
|
11
11
|
bid: BidSchema,
|
|
12
12
|
confirmToken: confirmTokenParam,
|
|
13
13
|
}),
|
|
14
|
-
}, async (
|
|
14
|
+
}, async (args, ctx) => {
|
|
15
|
+
const { bid, confirmToken } = args;
|
|
15
16
|
const request = { method: 'GET', path: '/user/wishlist/add', query: { bid } };
|
|
16
17
|
const gate = await confirmWrite(ctx, {
|
|
17
18
|
tool: 'untappd_wishlist_add',
|
|
18
19
|
action: 'untappd.wishlist_add',
|
|
19
20
|
message: 'Review and confirm adding this beer to your Untappd wishlist:',
|
|
20
21
|
confirmToken,
|
|
22
|
+
args,
|
|
21
23
|
target: bid,
|
|
22
24
|
payload: request,
|
|
23
25
|
preview: { action: 'wishlist_add', bid, ...request, note: 'Adds this beer to your Untappd wishlist.' },
|
|
@@ -35,13 +37,15 @@ export function registerWishlistTools(server, client) {
|
|
|
35
37
|
bid: BidSchema,
|
|
36
38
|
confirmToken: confirmTokenParam,
|
|
37
39
|
}),
|
|
38
|
-
}, async (
|
|
40
|
+
}, async (args, ctx) => {
|
|
41
|
+
const { bid, confirmToken } = args;
|
|
39
42
|
const request = { method: 'GET', path: '/user/wishlist/delete', query: { bid } };
|
|
40
43
|
const gate = await confirmWrite(ctx, {
|
|
41
44
|
tool: 'untappd_wishlist_remove',
|
|
42
45
|
action: 'untappd.wishlist_remove',
|
|
43
46
|
message: 'Review and confirm removing this beer from your Untappd wishlist:',
|
|
44
47
|
confirmToken,
|
|
48
|
+
args,
|
|
45
49
|
target: bid,
|
|
46
50
|
payload: request,
|
|
47
51
|
preview: { action: 'wishlist_remove', bid, ...request, note: 'Removes this beer from your Untappd wishlist.' },
|
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.
|
|
6
|
+
export const VERSION = '2.3.1'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "untappd-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
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>",
|
|
@@ -45,9 +45,8 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^
|
|
48
|
+
"@chrischall/mcp-utils": "^3.0.0",
|
|
49
49
|
"@modelcontextprotocol/server": "^2.3.0",
|
|
50
|
-
"ai": "^7.0.19",
|
|
51
50
|
"dotenv": "^18.0.1",
|
|
52
51
|
"zod": "^4.6.5"
|
|
53
52
|
},
|