untappd-mcp 2.1.3 → 2.2.0

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.
@@ -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) {
@@ -69,6 +72,16 @@ async function checkPhoto(photoPath) {
69
72
  }
70
73
  return { path: real, size_bytes: size, ext, content_type: sniffed };
71
74
  }
75
+ /**
76
+ * sha256 of the photo's bytes, streamed. Bound into the confirm token so a
77
+ * different image swapped in at the same path — even one of identical size and
78
+ * type — no longer matches the preview the user approved.
79
+ */
80
+ async function photoSha256(path) {
81
+ const hash = createHash('sha256');
82
+ await pipeline(createReadStream(path), hash);
83
+ return hash.digest('hex');
84
+ }
72
85
  // Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
73
86
  const RatingSchema = z
74
87
  .number()
@@ -159,22 +172,30 @@ export function registerCheckinTools(server, client) {
159
172
  server.registerTool('untappd_toast', {
160
173
  title: 'Toast an Untappd check-in',
161
174
  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.',
175
+ `already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
164
176
  annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
165
177
  inputSchema: z.object({
166
178
  checkin_id: CheckinIdSchema,
167
- confirm: schemaConfirm,
179
+ confirmToken: confirmTokenParam,
168
180
  }),
169
- }, async ({ checkin_id, confirm }) => {
170
- if (confirm !== true) {
171
- return minifiedResult({
172
- dryRun: true,
181
+ }, async ({ checkin_id, confirmToken }, ctx) => {
182
+ const request = { method: 'POST', path: `/checkin/toast/${checkin_id}` };
183
+ const gate = await confirmWrite(ctx, {
184
+ tool: 'untappd_toast',
185
+ action: 'untappd.toast',
186
+ message: 'Review and confirm toggling your toast on this Untappd check-in:',
187
+ confirmToken,
188
+ target: checkin_id,
189
+ payload: request,
190
+ preview: {
173
191
  action: 'toast',
174
192
  checkin_id,
175
- note: 'Dry run — re-run with confirm: true to toggle your toast on this check-in.',
176
- });
177
- }
193
+ ...request,
194
+ note: 'Toggles your toast on this check-in (removes it if you have already toasted).',
195
+ },
196
+ });
197
+ if (gate)
198
+ return gate;
178
199
  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
200
  'been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with ' +
180
201
  `untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`);
@@ -182,67 +203,87 @@ export function registerCheckinTools(server, client) {
182
203
  });
183
204
  server.registerTool('untappd_add_comment', {
184
205
  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.',
206
+ description: 'Post a comment on a check-in from YOUR account. Writes to your Untappd account and is visible to others. ' +
207
+ CONFIRM_FLOW,
187
208
  annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
188
209
  inputSchema: z.object({
189
210
  checkin_id: CheckinIdSchema,
190
211
  comment: z.string().min(1).max(2000).describe('Comment text to post'),
191
- confirm: schemaConfirm,
212
+ confirmToken: confirmTokenParam,
192
213
  }),
193
- }, async ({ checkin_id, comment, confirm }) => {
194
- if (confirm !== true) {
195
- return minifiedResult({
196
- dryRun: true,
214
+ }, async ({ checkin_id, comment, confirmToken }, ctx) => {
215
+ const request = { method: 'POST', path: `/checkin/addcomment/${checkin_id}`, form: { comment } };
216
+ const gate = await confirmWrite(ctx, {
217
+ tool: 'untappd_add_comment',
218
+ action: 'untappd.add_comment',
219
+ message: 'Review and confirm posting this comment from your Untappd account:',
220
+ confirmToken,
221
+ target: checkin_id,
222
+ payload: request,
223
+ preview: {
197
224
  action: 'add_comment',
198
225
  checkin_id,
199
226
  comment,
200
- note: 'Dry run — re-run with confirm: true to post this comment to your Untappd account.',
201
- });
202
- }
227
+ ...request,
228
+ note: 'Posts this comment to the check-in from your Untappd account, visible to others.',
229
+ },
230
+ });
231
+ if (gate)
232
+ return gate;
203
233
  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
234
  `untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`);
205
235
  return minifiedResult({ posted: true, checkin_id, response: data });
206
236
  });
207
237
  server.registerTool('untappd_delete_comment', {
208
238
  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.',
239
+ description: `Delete one of YOUR comments by its comment id (the id from a check-in's comments list). ${CONFIRM_FLOW}`,
211
240
  annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
212
241
  inputSchema: z.object({
213
242
  comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
214
- confirm: schemaConfirm,
243
+ confirmToken: confirmTokenParam,
215
244
  }),
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
- }
245
+ }, async ({ comment_id, confirmToken }, ctx) => {
246
+ const request = { method: 'POST', path: `/checkin/deletecomment/${comment_id}` };
247
+ const gate = await confirmWrite(ctx, {
248
+ tool: 'untappd_delete_comment',
249
+ action: 'untappd.delete_comment',
250
+ message: 'Review and confirm deleting this comment from your Untappd account:',
251
+ confirmToken,
252
+ target: comment_id,
253
+ payload: request,
254
+ preview: { action: 'delete_comment', comment_id, ...request, note: 'Deletes this comment from your Untappd account.' },
255
+ });
256
+ if (gate)
257
+ return gate;
225
258
  const data = await client.write('POST', `/checkin/deletecomment/${comment_id}`);
226
259
  return minifiedResult({ deleted: true, comment_id, result: data?.result });
227
260
  });
228
261
  server.registerTool('untappd_delete_checkin', {
229
262
  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.',
263
+ description: `Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. ${CONFIRM_FLOW}`,
232
264
  annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
233
265
  inputSchema: z.object({
234
266
  checkin_id: CheckinIdSchema,
235
- confirm: schemaConfirm,
267
+ confirmToken: confirmTokenParam,
236
268
  }),
237
- }, async ({ checkin_id, confirm }) => {
238
- if (confirm !== true) {
239
- return minifiedResult({
240
- dryRun: true,
269
+ }, async ({ checkin_id, confirmToken }, ctx) => {
270
+ const request = { method: 'POST', path: `/checkin/delete/${checkin_id}` };
271
+ const gate = await confirmWrite(ctx, {
272
+ tool: 'untappd_delete_checkin',
273
+ action: 'untappd.delete_checkin',
274
+ message: 'Review and confirm PERMANENTLY deleting this Untappd check-in:',
275
+ confirmToken,
276
+ target: checkin_id,
277
+ payload: request,
278
+ preview: {
241
279
  action: 'delete_checkin',
242
280
  checkin_id,
243
- note: 'Dry run — re-run with confirm: true to PERMANENTLY delete this check-in. This cannot be undone.',
244
- });
245
- }
281
+ ...request,
282
+ note: 'PERMANENTLY deletes this check-in. This cannot be undone.',
283
+ },
284
+ });
285
+ if (gate)
286
+ return gate;
246
287
  const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
247
288
  return minifiedResult({ deleted: true, checkin_id, result: data?.result });
248
289
  });
@@ -250,8 +291,8 @@ export function registerCheckinTools(server, client) {
250
291
  title: 'Check in a beer on Untappd',
251
292
  description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
252
293
  '(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.',
294
+ 'foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo ' +
295
+ `that will be posted. ${CONFIRM_FLOW}`,
255
296
  annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
256
297
  inputSchema: z.object({
257
298
  bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
@@ -262,7 +303,7 @@ export function registerCheckinTools(server, client) {
262
303
  .string()
263
304
  .optional()
264
305
  .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.'),
306
+ 'file the user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
266
307
  geolat: z.number().optional().describe('Optional latitude of the check-in'),
267
308
  geolng: z.number().optional().describe('Optional longitude of the check-in'),
268
309
  container_id: z
@@ -277,13 +318,15 @@ export function registerCheckinTools(server, client) {
277
318
  .describe("The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to " +
278
319
  "UNTAPPD_TIMEZONE, else the server's own zone — which on a hosted connector is usually UTC, so pass it " +
279
320
  'when you know where the user is.'),
280
- confirm: schemaConfirm,
321
+ confirmToken: confirmTokenParam,
281
322
  }),
282
- }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirm }) => {
323
+ }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken }, ctx) => {
283
324
  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.
325
+ // Vetted on every call, so the preview names the exact file (resolved path
326
+ // + size) that will be published — and a file whose bytes change between
327
+ // the preview and the confirmed call no longer matches the token.
286
328
  const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
329
+ const photo_sha256 = photo ? await photoSha256(photo.path) : undefined;
287
330
  const form = {
288
331
  bid,
289
332
  rating: rating !== undefined ? rating.toFixed(2) : undefined,
@@ -298,15 +341,23 @@ export function registerCheckinTools(server, client) {
298
341
  photo_file_ext: photo?.ext,
299
342
  platform: 'ios',
300
343
  };
301
- if (confirm !== true) {
302
- return minifiedResult({
303
- dryRun: true,
344
+ const request = { method: 'POST', path: '/checkin/add', form };
345
+ const gate = await confirmWrite(ctx, {
346
+ tool: 'untappd_checkin',
347
+ action: 'untappd.checkin',
348
+ message: 'Review and confirm posting this check-in to your public Untappd feed:',
349
+ confirmToken,
350
+ target: bid,
351
+ payload: { ...request, photo, photo_sha256 },
352
+ preview: {
304
353
  action: 'checkin',
305
- form,
354
+ ...request,
306
355
  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
- }
356
+ note: 'POSTs this check-in to your public Untappd feed.',
357
+ },
358
+ });
359
+ if (gate)
360
+ return gate;
310
361
  // Open the photo BEFORE creating the check-in, so a missing/unreadable
311
362
  // file fails fast without leaving an orphaned photo-less check-in behind.
312
363
  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.0'; // 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.0",
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",
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.3",
9
+ "version": "2.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.1.3",
14
+ "version": "2.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -109,9 +109,9 @@ one that exists.
109
109
  Each for its own reason — and none of them will tell you it ignored the
110
110
  parameter, because an undeclared key is dropped by zod without a warning:
111
111
 
112
- - **The 11 confirm-gated writes** (`untappd_checkin`, `untappd_toast`,
112
+ - **The 11 confirmation-gated writes** (`untappd_checkin`, `untappd_toast`,
113
113
  `untappd_add_comment`, the two deletes, the wishlist pair, the four friend
114
- actions) answer with a dry-run preview or a receipt. Nothing in a receipt is
114
+ actions) answer with a confirmation preview or a receipt. Nothing in a receipt is
115
115
  decoration.
116
116
  - **`untappd_sync_checkins` / `untappd_sync_user_beers`** answer with sync
117
117
  PROGRESS — pages walked, `another_run_needed`, `backfill_complete`. Slimming
@@ -140,10 +140,15 @@ parameter, because an undeclared key is dropped by zod without a warning:
140
140
  (#161) — it takes a `view` now, and its field shape is documented with the
141
141
  other projections above.
142
142
 
143
- ## Write tools (confirm-gated — these post to your public account)
143
+ ## Write tools (confirmation-gated — these post to your public account)
144
144
 
145
- Each returns a dry-run preview and makes NO network call unless called with
146
- `confirm: true`:
145
+ Each asks the user to confirm first. Where the client can show a confirmation
146
+ prompt, it does. Otherwise the first call makes NO network call and returns
147
+ `status: "confirmation-required"` with a `preview` of the exact request and a
148
+ `confirmToken`; show the preview to the user, and only after they approve call
149
+ the tool again with the same arguments plus `confirmToken`. A token works once
150
+ and only for those arguments (a change returns `DRAFT_CHANGED` with a fresh
151
+ preview). `MCP_CONFIRM_MODE` on the server can make this `auto` or `refuse`.
147
152
 
148
153
  - `untappd_toast` — toast/like a check-in (this endpoint is a toggle).
149
154
  - `untappd_add_comment` — comment on a check-in.