untappd-mcp 2.1.3 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cache/db.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { DatabaseSync } from 'node:sqlite';
2
- import { mkdirSync } from 'node:fs';
2
+ import { chmodSync, closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { readEnvVar } from '@chrischall/mcp-utils';
@@ -46,16 +46,53 @@ export class CheckinCache extends LocalCacheStore {
46
46
  super(new CheckinStoreCore(new NodeSqlDriver(db)));
47
47
  this.db = db;
48
48
  }
49
- /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
49
+ /**
50
+ * Open (creating parent dirs) a file-backed cache, or `:memory:` for tests.
51
+ * The file holds other people's dated check-in and venue history, so it is
52
+ * kept owner-only: dirs 0700, the db (and any -wal/-shm/-journal) 0600.
53
+ */
50
54
  static open(path) {
51
55
  if (path !== ':memory:')
52
- mkdirSync(dirname(path), { recursive: true });
53
- return new CheckinCache(new DatabaseSync(path));
56
+ preparePrivateFile(path);
57
+ const cache = new CheckinCache(new DatabaseSync(path));
58
+ if (path !== ':memory:')
59
+ tightenSidecars(path);
60
+ return cache;
54
61
  }
55
62
  close() {
56
63
  this.db.close();
57
64
  }
58
65
  }
66
+ /**
67
+ * Create the cache's directory (0700 — `mode` applies to every dir mkdir
68
+ * creates) and the db file itself (0600) BEFORE SQLite opens it, so the file is
69
+ * never briefly umask-readable. Pre-existing loose entries from older versions
70
+ * are tightened too; chmod failures (e.g. a shared dir the user doesn't own)
71
+ * are best-effort, never fatal.
72
+ */
73
+ function preparePrivateFile(path) {
74
+ const dir = dirname(path);
75
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
76
+ bestEffortChmod(dir, 0o700);
77
+ if (!existsSync(path))
78
+ closeSync(openSync(path, 'a', 0o600));
79
+ bestEffortChmod(path, 0o600);
80
+ }
81
+ /** SQLite side files inherit the db's mode, but re-assert in case a loose one lingers. */
82
+ function tightenSidecars(path) {
83
+ for (const suffix of ['-wal', '-shm', '-journal']) {
84
+ if (existsSync(path + suffix))
85
+ bestEffortChmod(path + suffix, 0o600);
86
+ }
87
+ }
88
+ function bestEffortChmod(path, mode) {
89
+ try {
90
+ chmodSync(path, mode);
91
+ }
92
+ catch {
93
+ /* best-effort — not every filesystem supports POSIX modes */
94
+ }
95
+ }
59
96
  /** Default on-disk cache path: `$UNTAPPD_CACHE_DB` or `~/.untappd-mcp/checkins.db`. */
60
97
  export function defaultCachePath() {
61
98
  return readEnvVar('UNTAPPD_CACHE_DB') ?? join(homedir(), '.untappd-mcp', 'checkins.db');
@@ -466,6 +466,22 @@ export class CheckinStoreCore {
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]);
468
468
  }
469
+ /** Delete one user's cached rows (case-insensitive) in a single transaction. */
470
+ forgetUser(username) {
471
+ const key = username.toLowerCase();
472
+ const count = (table) => Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
473
+ const removed = {
474
+ checkins: count('checkins'),
475
+ distinct_beers: count('distinct_beers'),
476
+ sync_state: count('sync_state'),
477
+ };
478
+ this.db.transaction(() => {
479
+ this.db.run('DELETE FROM checkins WHERE username = ?', [key]);
480
+ this.db.run('DELETE FROM distinct_beers WHERE username = ?', [key]);
481
+ this.db.run('DELETE FROM sync_state WHERE username = ?', [key]);
482
+ });
483
+ return removed;
484
+ }
469
485
  getBeerMeta(bids) {
470
486
  if (bids.length === 0)
471
487
  return [];
@@ -540,6 +556,9 @@ export class LocalCacheStore {
540
556
  async upsertBeerMeta(rows) {
541
557
  this.core.upsertBeerMeta(rows);
542
558
  }
559
+ async forgetUser(username) {
560
+ return this.core.forgetUser(username);
561
+ }
543
562
  }
544
563
  // Escape LIKE wildcards in user input so a literal % or _ isn't treated as a
545
564
  // wildcard. Our LIKE patterns don't set an ESCAPE clause, so we simply strip the
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
- import { RateLimitError, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { RateLimitError, confirmTokenParam, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { beerMetaFrom } from '../cache/store.js';
4
4
  import { syncCheckins } from '../cache/sync.js';
5
5
  import { syncUserBeers } from '../cache/sync-beers.js';
6
+ import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
6
7
  // Re-fetch cached beer metadata at most this often; a hit newer than this skips
7
8
  // the beer/info API call.
8
9
  const BEER_META_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -386,4 +387,44 @@ export function registerCacheTools(server, client, cacheProvider) {
386
387
  freshness: await freshness(cache, user),
387
388
  });
388
389
  });
390
+ server.registerTool('untappd_cache_forget', {
391
+ title: "Forget a user's cached check-ins",
392
+ description: "Delete everything the LOCAL cache holds for one user — their cached check-ins (beer, rating, comment, venue, " +
393
+ 'date), distinct-beers list and sync state — e.g. after syncing a friend you no longer want a history of. Only ' +
394
+ 'the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it. Shared beer ' +
395
+ 'metadata is kept. The preview shows the username and exactly how many rows will be removed. Omit username ' +
396
+ `for your own account. ${CONFIRM_FLOW}`,
397
+ annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
398
+ inputSchema: z.object({
399
+ username: UsernameArg,
400
+ confirmToken: confirmTokenParam,
401
+ }),
402
+ }, async ({ username, confirmToken }, ctx) => {
403
+ const user = resolveUser(username, client.loginName);
404
+ const cache = cacheProvider();
405
+ // Bound into the token: if a sync adds rows after the preview, the
406
+ // token no longer matches and the user re-confirms the real counts.
407
+ const counts = {
408
+ checkins: await cache.cachedCount(user),
409
+ distinct_beers: await cache.distinctBeersCount(user),
410
+ };
411
+ const gate = await confirmWrite(ctx, {
412
+ tool: 'untappd_cache_forget',
413
+ action: 'untappd.cache_forget',
414
+ message: `Review and confirm deleting ${user}'s data from the local Untappd cache:`,
415
+ confirmToken,
416
+ target: user.toLowerCase(),
417
+ payload: { username: user.toLowerCase(), ...counts },
418
+ preview: {
419
+ action: 'cache_forget',
420
+ username: user,
421
+ ...counts,
422
+ note: 'Deletes these rows from the LOCAL cache only; Untappd itself is unchanged and a re-sync can restore them.',
423
+ },
424
+ });
425
+ if (gate)
426
+ return gate;
427
+ const removed = await cache.forgetUser(user);
428
+ return minifiedResult({ forgotten: true, username: user, removed });
429
+ });
389
430
  }
@@ -1,7 +1,10 @@
1
- import { realpathSync, statSync } from 'node:fs';
1
+ import { createReadStream, realpathSync, statSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { pipeline } from 'node:stream/promises';
2
4
  import { delimiter, extname } from 'node:path';
3
5
  import { z } from 'zod';
4
- import { McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, schemaConfirm, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
6
+ import { confirmTokenParam, McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
7
+ import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
5
8
  const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
6
9
  // Keyed by the NORMALISED extension that photoExt() returns (jpeg → jpg), so
7
10
  // there is no dead `jpeg` entry.
@@ -23,7 +26,7 @@ function photoRoots() {
23
26
  * free-form, model-supplied path, so an injected instruction could aim it at
24
27
  * any file with an image-like name. Require the bytes to BE a JPEG/PNG that
25
28
  * 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
29
+ * allow-list, and return the resolved path + size so the preview shows a human
27
30
  * exactly which file would be uploaded. Errors never echo the path back.
28
31
  */
29
32
  async function checkPhoto(photoPath) {
@@ -61,7 +64,15 @@ async function checkPhoto(photoPath) {
61
64
  hint: 'Attach a normal-sized JPEG or PNG photo.',
62
65
  });
63
66
  }
64
- const sniffed = sniffMimeBytes(await readFileHead(real, 16));
67
+ // Re-check UNTAPPD_PHOTO_DIR at open time, so a swap after the check above can't escape it.
68
+ let head;
69
+ try {
70
+ head = await readFileHead(real, 16, { allowedRoots: roots });
71
+ }
72
+ catch {
73
+ throw new McpToolError('Photo file not found or not readable.');
74
+ }
75
+ const sniffed = sniffMimeBytes(head);
65
76
  if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
66
77
  throw createHelpfulError(sniffed === 'image/jpeg' || sniffed === 'image/png'
67
78
  ? `Photo content (${sniffed}) does not match its .${ext} extension.`
@@ -69,6 +80,16 @@ async function checkPhoto(photoPath) {
69
80
  }
70
81
  return { path: real, size_bytes: size, ext, content_type: sniffed };
71
82
  }
83
+ /**
84
+ * sha256 of the photo's bytes, streamed. Bound into the confirm token so a
85
+ * different image swapped in at the same path — even one of identical size and
86
+ * type — no longer matches the preview the user approved.
87
+ */
88
+ async function photoSha256(path) {
89
+ const hash = createHash('sha256');
90
+ await pipeline(createReadStream(path), hash);
91
+ return hash.digest('hex');
92
+ }
72
93
  // Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
73
94
  const RatingSchema = z
74
95
  .number()
@@ -159,22 +180,30 @@ export function registerCheckinTools(server, client) {
159
180
  server.registerTool('untappd_toast', {
160
181
  title: 'Toast an Untappd check-in',
161
182
  description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have " +
162
- 'already toasted removes the toast. Without confirm: true it returns a dry-run preview and makes NO network ' +
163
- 'call; with confirm: true it posts. Writes to your Untappd account and is visible to others.',
183
+ `already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
164
184
  annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
165
185
  inputSchema: z.object({
166
186
  checkin_id: CheckinIdSchema,
167
- confirm: schemaConfirm,
187
+ confirmToken: confirmTokenParam,
168
188
  }),
169
- }, async ({ checkin_id, confirm }) => {
170
- if (confirm !== true) {
171
- return minifiedResult({
172
- dryRun: true,
189
+ }, async ({ checkin_id, confirmToken }, ctx) => {
190
+ const request = { method: 'POST', path: `/checkin/toast/${checkin_id}` };
191
+ const gate = await confirmWrite(ctx, {
192
+ tool: 'untappd_toast',
193
+ action: 'untappd.toast',
194
+ message: 'Review and confirm toggling your toast on this Untappd check-in:',
195
+ confirmToken,
196
+ target: checkin_id,
197
+ payload: request,
198
+ preview: {
173
199
  action: 'toast',
174
200
  checkin_id,
175
- note: 'Dry run — re-run with confirm: true to toggle your toast on this check-in.',
176
- });
177
- }
201
+ ...request,
202
+ note: 'Toggles your toast on this check-in (removes it if you have already toasted).',
203
+ },
204
+ });
205
+ if (gate)
206
+ return gate;
178
207
  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
208
  'been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with ' +
180
209
  `untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`);
@@ -182,67 +211,87 @@ export function registerCheckinTools(server, client) {
182
211
  });
183
212
  server.registerTool('untappd_add_comment', {
184
213
  title: 'Comment on an Untappd check-in',
185
- description: 'Post a comment on a check-in from YOUR account. Without confirm: true it returns a dry-run preview and ' +
186
- 'makes NO network call; with confirm: true it posts. Writes to your Untappd account and is visible to others.',
214
+ description: 'Post a comment on a check-in from YOUR account. Writes to your Untappd account and is visible to others. ' +
215
+ CONFIRM_FLOW,
187
216
  annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
188
217
  inputSchema: z.object({
189
218
  checkin_id: CheckinIdSchema,
190
219
  comment: z.string().min(1).max(2000).describe('Comment text to post'),
191
- confirm: schemaConfirm,
220
+ confirmToken: confirmTokenParam,
192
221
  }),
193
- }, async ({ checkin_id, comment, confirm }) => {
194
- if (confirm !== true) {
195
- return minifiedResult({
196
- dryRun: true,
222
+ }, async ({ checkin_id, comment, confirmToken }, ctx) => {
223
+ const request = { method: 'POST', path: `/checkin/addcomment/${checkin_id}`, form: { comment } };
224
+ const gate = await confirmWrite(ctx, {
225
+ tool: 'untappd_add_comment',
226
+ action: 'untappd.add_comment',
227
+ message: 'Review and confirm posting this comment from your Untappd account:',
228
+ confirmToken,
229
+ target: checkin_id,
230
+ payload: request,
231
+ preview: {
197
232
  action: 'add_comment',
198
233
  checkin_id,
199
234
  comment,
200
- note: 'Dry run — re-run with confirm: true to post this comment to your Untappd account.',
201
- });
202
- }
235
+ ...request,
236
+ note: 'Posts this comment to the check-in from your Untappd account, visible to others.',
237
+ },
238
+ });
239
+ if (gate)
240
+ return gate;
203
241
  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
242
  `untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`);
205
243
  return minifiedResult({ posted: true, checkin_id, response: data });
206
244
  });
207
245
  server.registerTool('untappd_delete_comment', {
208
246
  title: 'Delete a comment from an Untappd check-in',
209
- description: 'Delete one of YOUR comments by its comment id (the id from a check-in\'s comments list). Without ' +
210
- 'confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.',
247
+ description: `Delete one of YOUR comments by its comment id (the id from a check-in's comments list). ${CONFIRM_FLOW}`,
211
248
  annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
212
249
  inputSchema: z.object({
213
250
  comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
214
- confirm: schemaConfirm,
251
+ confirmToken: confirmTokenParam,
215
252
  }),
216
- }, async ({ comment_id, confirm }) => {
217
- if (confirm !== true) {
218
- return minifiedResult({
219
- dryRun: true,
220
- action: 'delete_comment',
221
- comment_id,
222
- note: 'Dry run — re-run with confirm: true to delete this comment from your Untappd account.',
223
- });
224
- }
253
+ }, async ({ comment_id, confirmToken }, ctx) => {
254
+ const request = { method: 'POST', path: `/checkin/deletecomment/${comment_id}` };
255
+ const gate = await confirmWrite(ctx, {
256
+ tool: 'untappd_delete_comment',
257
+ action: 'untappd.delete_comment',
258
+ message: 'Review and confirm deleting this comment from your Untappd account:',
259
+ confirmToken,
260
+ target: comment_id,
261
+ payload: request,
262
+ preview: { action: 'delete_comment', comment_id, ...request, note: 'Deletes this comment from your Untappd account.' },
263
+ });
264
+ if (gate)
265
+ return gate;
225
266
  const data = await client.write('POST', `/checkin/deletecomment/${comment_id}`);
226
267
  return minifiedResult({ deleted: true, comment_id, result: data?.result });
227
268
  });
228
269
  server.registerTool('untappd_delete_checkin', {
229
270
  title: 'Delete an Untappd check-in',
230
- description: 'Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. Without ' +
231
- 'confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.',
271
+ description: `Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. ${CONFIRM_FLOW}`,
232
272
  annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
233
273
  inputSchema: z.object({
234
274
  checkin_id: CheckinIdSchema,
235
- confirm: schemaConfirm,
275
+ confirmToken: confirmTokenParam,
236
276
  }),
237
- }, async ({ checkin_id, confirm }) => {
238
- if (confirm !== true) {
239
- return minifiedResult({
240
- dryRun: true,
277
+ }, async ({ checkin_id, confirmToken }, ctx) => {
278
+ const request = { method: 'POST', path: `/checkin/delete/${checkin_id}` };
279
+ const gate = await confirmWrite(ctx, {
280
+ tool: 'untappd_delete_checkin',
281
+ action: 'untappd.delete_checkin',
282
+ message: 'Review and confirm PERMANENTLY deleting this Untappd check-in:',
283
+ confirmToken,
284
+ target: checkin_id,
285
+ payload: request,
286
+ preview: {
241
287
  action: 'delete_checkin',
242
288
  checkin_id,
243
- note: 'Dry run — re-run with confirm: true to PERMANENTLY delete this check-in. This cannot be undone.',
244
- });
245
- }
289
+ ...request,
290
+ note: 'PERMANENTLY deletes this check-in. This cannot be undone.',
291
+ },
292
+ });
293
+ if (gate)
294
+ return gate;
246
295
  const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
247
296
  return minifiedResult({ deleted: true, checkin_id, result: data?.result });
248
297
  });
@@ -250,8 +299,8 @@ export function registerCheckinTools(server, client) {
250
299
  title: 'Check in a beer on Untappd',
251
300
  description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
252
301
  '(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), a venue via ' +
253
- 'foursquare_id, and a local photo via photo_path (JPEG/PNG). Without confirm: true it returns a dry-run ' +
254
- 'preview of the exact fields and makes NO network call; with confirm: true it posts.',
302
+ 'foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo ' +
303
+ `that will be posted. ${CONFIRM_FLOW}`,
255
304
  annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
256
305
  inputSchema: z.object({
257
306
  bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
@@ -262,7 +311,7 @@ export function registerCheckinTools(server, client) {
262
311
  .string()
263
312
  .optional()
264
313
  .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.'),
314
+ 'file the user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
266
315
  geolat: z.number().optional().describe('Optional latitude of the check-in'),
267
316
  geolng: z.number().optional().describe('Optional longitude of the check-in'),
268
317
  container_id: z
@@ -277,13 +326,15 @@ export function registerCheckinTools(server, client) {
277
326
  .describe("The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to " +
278
327
  "UNTAPPD_TIMEZONE, else the server's own zone — which on a hosted connector is usually UTC, so pass it " +
279
328
  'when you know where the user is.'),
280
- confirm: schemaConfirm,
329
+ confirmToken: confirmTokenParam,
281
330
  }),
282
- }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirm }) => {
331
+ }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken }, ctx) => {
283
332
  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.
333
+ // Vetted on every call, so the preview names the exact file (resolved path
334
+ // + size) that will be published — and a file whose bytes change between
335
+ // the preview and the confirmed call no longer matches the token.
286
336
  const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
337
+ const photo_sha256 = photo ? await photoSha256(photo.path) : undefined;
287
338
  const form = {
288
339
  bid,
289
340
  rating: rating !== undefined ? rating.toFixed(2) : undefined,
@@ -298,15 +349,23 @@ export function registerCheckinTools(server, client) {
298
349
  photo_file_ext: photo?.ext,
299
350
  platform: 'ios',
300
351
  };
301
- if (confirm !== true) {
302
- return minifiedResult({
303
- dryRun: true,
352
+ const request = { method: 'POST', path: '/checkin/add', form };
353
+ const gate = await confirmWrite(ctx, {
354
+ tool: 'untappd_checkin',
355
+ action: 'untappd.checkin',
356
+ message: 'Review and confirm posting this check-in to your public Untappd feed:',
357
+ confirmToken,
358
+ target: bid,
359
+ payload: { ...request, photo, photo_sha256 },
360
+ preview: {
304
361
  action: 'checkin',
305
- form,
362
+ ...request,
306
363
  photo: photo ? { ...photo, note: 'this exact file will be uploaded PUBLICLY after the check-in is created' } : undefined,
307
- note: 'Dry run — re-run with confirm: true to POST this check-in to your public Untappd feed.',
308
- });
309
- }
364
+ note: 'POSTs this check-in to your public Untappd feed.',
365
+ },
366
+ });
367
+ if (gate)
368
+ return gate;
310
369
  // Open the photo BEFORE creating the check-in, so a missing/unreadable
311
370
  // file fails fast without leaving an orphaned photo-less check-in behind.
312
371
  let blob;
@@ -0,0 +1,20 @@
1
+ import { confirmationFromEnv, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
2
+ /** The sentence every gated write's description carries. */
3
+ export const CONFIRM_FLOW = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call ' +
4
+ 'returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).';
5
+ /**
6
+ * Gate a write behind the fleet confirmation flow. `undefined` means proceed;
7
+ * anything else is the result to return unchanged. Callers rebuild `payload`
8
+ * and `preview` from the arguments on every call, so a token only authorises
9
+ * the exact request its preview showed.
10
+ */
11
+ export function confirmWrite(ctx, c) {
12
+ return requireConfirmationWithFallback(ctx, confirmationFromEnv({
13
+ action: c.action,
14
+ message: c.message,
15
+ details: c.preview,
16
+ tool: c.tool,
17
+ confirmToken: c.confirmToken,
18
+ subject: () => ({ target: String(c.target), payload: c.payload, preview: c.preview }),
19
+ }));
20
+ }
@@ -1,12 +1,13 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { confirmTokenParam, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
3
4
  // The endpoint PATHS below are confirmed from the Untappd app's own JS bundle
4
5
  // (`friend/request`, `friend/accept`, `friend/reject`, `friend/remove`). The
5
6
  // HTTP method (POST) mirrors the other captured action-writes (toast/comment),
6
7
  // but — unlike the rest of this server's writes — these were NOT live-verified
7
8
  // against the API, because doing so would send real friend requests to / alter
8
- // real relationships with other people. They are confirm-gated so nothing fires
9
- // without an explicit confirm: true.
9
+ // real relationships with other people. They are confirmation-gated so nothing
10
+ // fires without the user approving the exact request first.
10
11
  const TargetUidSchema = z
11
12
  .number()
12
13
  .int()
@@ -47,8 +48,7 @@ export function registerFriendActionTools(server, client) {
47
48
  server.registerTool(action.tool, {
48
49
  title: action.title,
49
50
  description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. ` +
50
- 'Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it ' +
51
- 'performs the action. Note: this endpoint path is taken from the Untappd app but is not otherwise ' +
51
+ `${CONFIRM_FLOW} Note: this endpoint path is taken from the Untappd app but is not otherwise ` +
52
52
  'independently verified.',
53
53
  // All four reach ANOTHER PERSON — the description says so itself: "affects
54
54
  // a real relationship with another person". A friend request cannot be
@@ -57,17 +57,21 @@ export function registerFriendActionTools(server, client) {
57
57
  annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true, destructive: true }),
58
58
  inputSchema: z.object({
59
59
  target_uid: TargetUidSchema,
60
- confirm: schemaConfirm,
60
+ confirmToken: confirmTokenParam,
61
61
  }),
62
- }, async ({ target_uid, confirm }) => {
63
- if (confirm !== true) {
64
- return minifiedResult({
65
- dryRun: true,
66
- action: action.path,
67
- target_uid,
68
- note: `Dry run — re-run with confirm: true to ${action.verb} user ${target_uid}.`,
69
- });
70
- }
62
+ }, async ({ target_uid, confirmToken }, ctx) => {
63
+ const request = { method: 'POST', path: `/friend/${action.path}/${target_uid}` };
64
+ const gate = await confirmWrite(ctx, {
65
+ tool: action.tool,
66
+ action: `untappd.friend_${action.path}`,
67
+ message: `Review and confirm: ${action.verb} Untappd user ${target_uid}.`,
68
+ confirmToken,
69
+ target: target_uid,
70
+ payload: request,
71
+ preview: { action: action.path, target_uid, ...request, note: `Will ${action.verb} user ${target_uid}.` },
72
+ });
73
+ if (gate)
74
+ return gate;
71
75
  const data = await client.write('POST', `/friend/${action.path}/${target_uid}`);
72
76
  return minifiedResult({ done: true, action: action.path, target_uid, result: data?.result });
73
77
  });
@@ -1,46 +1,53 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, schemaConfirm, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { confirmTokenParam, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
3
4
  const BidSchema = z.number().int().positive().describe('Untappd beer id (bid) — from untappd_search_beer');
4
5
  export function registerWishlistTools(server, client) {
5
6
  server.registerTool('untappd_wishlist_add', {
6
7
  title: 'Add a beer to your wishlist',
7
- description: 'Add a beer to YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and ' +
8
- 'makes NO network call; with confirm: true it adds. Writes to your account.',
8
+ description: `Add a beer to YOUR Untappd wishlist by its bid. Writes to your account. ${CONFIRM_FLOW}`,
9
9
  annotations: toolAnnotations({ title: 'Add a beer to your wishlist', readOnly: false, idempotent: true, openWorld: true, destructive: false }),
10
10
  inputSchema: z.object({
11
11
  bid: BidSchema,
12
- confirm: schemaConfirm,
12
+ confirmToken: confirmTokenParam,
13
13
  }),
14
- }, async ({ bid, confirm }) => {
15
- if (confirm !== true) {
16
- return minifiedResult({
17
- dryRun: true,
18
- action: 'wishlist_add',
19
- bid,
20
- note: 'Dry run — re-run with confirm: true to add this beer to your wishlist.',
21
- });
22
- }
14
+ }, async ({ bid, confirmToken }, ctx) => {
15
+ const request = { method: 'GET', path: '/user/wishlist/add', query: { bid } };
16
+ const gate = await confirmWrite(ctx, {
17
+ tool: 'untappd_wishlist_add',
18
+ action: 'untappd.wishlist_add',
19
+ message: 'Review and confirm adding this beer to your Untappd wishlist:',
20
+ confirmToken,
21
+ target: bid,
22
+ payload: request,
23
+ preview: { action: 'wishlist_add', bid, ...request, note: 'Adds this beer to your Untappd wishlist.' },
24
+ });
25
+ if (gate)
26
+ return gate;
23
27
  const data = await client.write('GET', '/user/wishlist/add', { query: { bid } });
24
28
  return minifiedResult({ added: true, bid, result: data?.result });
25
29
  });
26
30
  server.registerTool('untappd_wishlist_remove', {
27
31
  title: 'Remove a beer from your wishlist',
28
- description: 'Remove a beer from YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview ' +
29
- 'and makes NO network call; with confirm: true it removes. Writes to your account.',
32
+ description: `Remove a beer from YOUR Untappd wishlist by its bid. Writes to your account. ${CONFIRM_FLOW}`,
30
33
  annotations: toolAnnotations({ title: 'Remove a beer from your wishlist', readOnly: false, idempotent: true, openWorld: true, destructive: false }),
31
34
  inputSchema: z.object({
32
35
  bid: BidSchema,
33
- confirm: schemaConfirm,
36
+ confirmToken: confirmTokenParam,
34
37
  }),
35
- }, async ({ bid, confirm }) => {
36
- if (confirm !== true) {
37
- return minifiedResult({
38
- dryRun: true,
39
- action: 'wishlist_remove',
40
- bid,
41
- note: 'Dry run — re-run with confirm: true to remove this beer from your wishlist.',
42
- });
43
- }
38
+ }, async ({ bid, confirmToken }, ctx) => {
39
+ const request = { method: 'GET', path: '/user/wishlist/delete', query: { bid } };
40
+ const gate = await confirmWrite(ctx, {
41
+ tool: 'untappd_wishlist_remove',
42
+ action: 'untappd.wishlist_remove',
43
+ message: 'Review and confirm removing this beer from your Untappd wishlist:',
44
+ confirmToken,
45
+ target: bid,
46
+ payload: request,
47
+ preview: { action: 'wishlist_remove', bid, ...request, note: 'Removes this beer from your Untappd wishlist.' },
48
+ });
49
+ if (gate)
50
+ return gate;
44
51
  const data = await client.write('GET', '/user/wishlist/delete', { query: { bid } });
45
52
  return minifiedResult({ removed: true, bid, result: data?.result });
46
53
  });
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.3'; // x-release-please-version
6
+ export const VERSION = '2.2.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "2.1.3",
3
+ "version": "2.2.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,7 +45,7 @@
45
45
  "typecheck": "tsc -p tsconfig.json --noEmit"
46
46
  },
47
47
  "dependencies": {
48
- "@chrischall/mcp-utils": "^2.4.0",
48
+ "@chrischall/mcp-utils": "^2.6.0",
49
49
  "@modelcontextprotocol/server": "^2.0.0",
50
50
  "ai": "^7.0.19",
51
51
  "dotenv": "^18.0.1",