untappd-mcp 2.1.2 → 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 { existsSync } from 'node:fs';
2
- import { extname } from 'node:path';
1
+ import { createReadStream, realpathSync, statSync } from 'node:fs';
2
+ import { createHash } from 'node:crypto';
3
+ import { pipeline } from 'node:stream/promises';
4
+ import { delimiter, extname } from 'node:path';
3
5
  import { z } from 'zod';
4
- import { McpToolError, createHelpfulError, fileBlob, messageOf, minifiedResult, schemaConfirm, toolAnnotations } 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.
@@ -10,103 +13,277 @@ function photoExt(path) {
10
13
  const ext = extname(path).slice(1).toLowerCase();
11
14
  return ext === 'jpeg' ? 'jpg' : ext;
12
15
  }
16
+ // Far above any phone photo; a file this big is not a beer picture.
17
+ const MAX_PHOTO_BYTES = 15 * 1024 * 1024;
18
+ /** UNTAPPD_PHOTO_DIR (one or more dirs, split on the platform path delimiter). */
19
+ function photoRoots() {
20
+ const raw = readEnvVar('UNTAPPD_PHOTO_DIR');
21
+ const roots = raw?.split(delimiter).filter(Boolean);
22
+ return roots && roots.length > 0 ? roots : undefined;
23
+ }
24
+ /**
25
+ * Vet a photo before it can be published to the public feed. photo_path is a
26
+ * free-form, model-supplied path, so an injected instruction could aim it at
27
+ * any file with an image-like name. Require the bytes to BE a JPEG/PNG that
28
+ * matches the extension, cap the size, honour the optional UNTAPPD_PHOTO_DIR
29
+ * allow-list, and return the resolved path + size so the preview shows a human
30
+ * exactly which file would be uploaded. Errors never echo the path back.
31
+ */
32
+ async function checkPhoto(photoPath) {
33
+ const ext = photoExt(photoPath);
34
+ if (!(ext in PHOTO_CONTENT_TYPES)) {
35
+ throw createHelpfulError(`Unsupported photo type "${ext || '(none)'}".`, {
36
+ hint: 'Attach a .jpg, .jpeg, or .png file.',
37
+ });
38
+ }
39
+ const roots = photoRoots();
40
+ if (roots) {
41
+ try {
42
+ assertPathWithinRoots(photoPath, roots);
43
+ }
44
+ catch {
45
+ throw createHelpfulError('The photo is outside the allowed photo directory.', {
46
+ hint: 'UNTAPPD_PHOTO_DIR restricts which files can be attached to a check-in.',
47
+ });
48
+ }
49
+ }
50
+ let real;
51
+ let size;
52
+ try {
53
+ real = realpathSync(photoPath);
54
+ const st = statSync(real);
55
+ if (!st.isFile())
56
+ throw new Error('not a file');
57
+ size = st.size;
58
+ }
59
+ catch {
60
+ throw new McpToolError('Photo file not found or not readable.');
61
+ }
62
+ if (size > MAX_PHOTO_BYTES) {
63
+ throw createHelpfulError(`Photo is too large (${size} bytes; the limit is ${MAX_PHOTO_BYTES}).`, {
64
+ hint: 'Attach a normal-sized JPEG or PNG photo.',
65
+ });
66
+ }
67
+ const sniffed = sniffMimeBytes(await readFileHead(real, 16));
68
+ if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
69
+ throw createHelpfulError(sniffed === 'image/jpeg' || sniffed === 'image/png'
70
+ ? `Photo content (${sniffed}) does not match its .${ext} extension.`
71
+ : 'The file is not a JPEG or PNG image.', { hint: 'Attach a real .jpg/.jpeg or .png photo.' });
72
+ }
73
+ return { path: real, size_bytes: size, ext, content_type: sniffed };
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
+ }
13
85
  // Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
14
86
  const RatingSchema = z
15
87
  .number()
16
88
  .min(0)
17
89
  .max(5)
18
90
  .refine((r) => Math.round(r * 4) === r * 4, { message: 'rating must be a multiple of 0.25' });
19
- function localTimezone() {
20
- const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
21
- // getTimezoneOffset is minutes behind UTC (positive = behind), so negate for GMT offset in hours.
22
- const gmt_offset = -new Date().getTimezoneOffset() / 60;
23
- return { timezone, gmt_offset };
91
+ /** GMT offset in hours of an IANA zone at `at` (DST-aware), e.g. 5.5 for Asia/Kolkata. */
92
+ function gmtOffsetHours(timeZone, at) {
93
+ const name = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' })
94
+ .formatToParts(at)
95
+ .find((p) => p.type === 'timeZoneName')?.value;
96
+ const m = /GMT([+-])(\d{1,2})(?::(\d{2}))?/.exec(name ?? '');
97
+ if (!m)
98
+ return 0; // plain "GMT"
99
+ const hours = Number(m[2]) + Number(m[3] ?? 0) / 60;
100
+ return m[1] === '-' ? -hours : hours;
101
+ }
102
+ /**
103
+ * The zone the check-in is stamped with: the caller's `timezone`, else
104
+ * UNTAPPD_TIMEZONE, else the server process's own zone. The process zone is
105
+ * only right when the server runs on the drinker's own machine — a hosted
106
+ * connector's process is typically UTC, which would stamp an evening check-in
107
+ * into the next day.
108
+ */
109
+ function checkinTimezone(requested) {
110
+ const timezone = requested ?? readEnvVar('UNTAPPD_TIMEZONE') ?? (Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC');
111
+ try {
112
+ return { timezone, gmt_offset: gmtOffsetHours(timezone, new Date()) };
113
+ }
114
+ catch {
115
+ throw createHelpfulError(`Unknown timezone "${timezone}".`, {
116
+ hint: 'Pass an IANA timezone name such as "America/New_York" or "Europe/London".',
117
+ });
118
+ }
119
+ }
120
+ // How far before the POST a recovered check-in's created_at may fall and still
121
+ // count as the one this call made. Only a small allowance for Untappd's
122
+ // whole-second created_at and modest clock skew: a wider window would match an
123
+ // earlier same-beer check-in (e.g. a prior timed-out attempt) and wrongly report
124
+ // it as this call's — telling the caller not to retry a check-in that never landed.
125
+ const RECOVERY_WINDOW_MS = 30_000;
126
+ /**
127
+ * Run a NON-idempotent write. A transport failure or timeout (UnreachableError)
128
+ * may fire after Untappd already received and applied the request, so it is
129
+ * reported as "outcome unknown" — with how to check — rather than "unreachable",
130
+ * which invites a blind retry that double-posts (or, for a toggle, undoes it).
131
+ */
132
+ async function nonIdempotentWrite(run, unknownOutcome) {
133
+ try {
134
+ return await run();
135
+ }
136
+ catch (e) {
137
+ if (e instanceof UnreachableError)
138
+ throw new McpToolError(unknownOutcome);
139
+ throw e;
140
+ }
141
+ }
142
+ /**
143
+ * After an outcome-unknown /checkin/add, look for the check-in it may have made:
144
+ * the caller's own most recent check-ins, same beer, created since just before
145
+ * the POST. Returns its id only when exactly one matches; null when none does,
146
+ * when more than one does (it can't tell which is this call's), or when the
147
+ * lookup fails — each of which the caller reports as an unknown outcome.
148
+ */
149
+ async function findRecentCheckin(client, bid, sentAt) {
150
+ const self = client.loginName;
151
+ if (!self)
152
+ return null;
153
+ try {
154
+ const data = await client.get(`/user/checkins/${encodeURIComponent(self)}`, { limit: 5 });
155
+ const matches = [];
156
+ for (const it of data?.checkins?.items ?? []) {
157
+ const c = it;
158
+ const at = Date.parse(c.created_at ?? '');
159
+ if (c.beer?.bid === bid && typeof c.checkin_id === 'number' && at >= sentAt - RECOVERY_WINDOW_MS) {
160
+ matches.push(c.checkin_id);
161
+ }
162
+ }
163
+ if (matches.length === 1)
164
+ return matches[0];
165
+ }
166
+ catch {
167
+ /* can't verify — the caller reports the outcome as unknown */
168
+ }
169
+ return null;
24
170
  }
25
171
  export function registerCheckinTools(server, client) {
26
172
  server.registerTool('untappd_toast', {
27
173
  title: 'Toast an Untappd check-in',
28
174
  description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have " +
29
- 'already toasted removes the toast. Without confirm: true it returns a dry-run preview and makes NO network ' +
30
- '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}`,
31
176
  annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
32
177
  inputSchema: z.object({
33
178
  checkin_id: CheckinIdSchema,
34
- confirm: schemaConfirm,
179
+ confirmToken: confirmTokenParam,
35
180
  }),
36
- }, async ({ checkin_id, confirm }) => {
37
- if (confirm !== true) {
38
- return minifiedResult({
39
- 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: {
40
191
  action: 'toast',
41
192
  checkin_id,
42
- note: 'Dry run — re-run with confirm: true to toggle your toast on this check-in.',
43
- });
44
- }
45
- const data = await client.write('POST', `/checkin/toast/${checkin_id}`);
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;
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 ` +
200
+ 'been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with ' +
201
+ `untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`);
46
202
  return minifiedResult({ toggled: true, checkin_id, result: data?.result, like_type: data?.like_type });
47
203
  });
48
204
  server.registerTool('untappd_add_comment', {
49
205
  title: 'Comment on an Untappd check-in',
50
- description: 'Post a comment on a check-in from YOUR account. Without confirm: true it returns a dry-run preview and ' +
51
- '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,
52
208
  annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
53
209
  inputSchema: z.object({
54
210
  checkin_id: CheckinIdSchema,
55
211
  comment: z.string().min(1).max(2000).describe('Comment text to post'),
56
- confirm: schemaConfirm,
212
+ confirmToken: confirmTokenParam,
57
213
  }),
58
- }, async ({ checkin_id, comment, confirm }) => {
59
- if (confirm !== true) {
60
- return minifiedResult({
61
- 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: {
62
224
  action: 'add_comment',
63
225
  checkin_id,
64
226
  comment,
65
- note: 'Dry run — re-run with confirm: true to post this comment to your Untappd account.',
66
- });
67
- }
68
- const data = await client.write('POST', `/checkin/addcomment/${checkin_id}`, { form: { comment } });
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;
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 ` +
234
+ `untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`);
69
235
  return minifiedResult({ posted: true, checkin_id, response: data });
70
236
  });
71
237
  server.registerTool('untappd_delete_comment', {
72
238
  title: 'Delete a comment from an Untappd check-in',
73
- description: 'Delete one of YOUR comments by its comment id (the id from a check-in\'s comments list). Without ' +
74
- '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}`,
75
240
  annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
76
241
  inputSchema: z.object({
77
242
  comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
78
- confirm: schemaConfirm,
243
+ confirmToken: confirmTokenParam,
79
244
  }),
80
- }, async ({ comment_id, confirm }) => {
81
- if (confirm !== true) {
82
- return minifiedResult({
83
- dryRun: true,
84
- action: 'delete_comment',
85
- comment_id,
86
- note: 'Dry run — re-run with confirm: true to delete this comment from your Untappd account.',
87
- });
88
- }
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;
89
258
  const data = await client.write('POST', `/checkin/deletecomment/${comment_id}`);
90
259
  return minifiedResult({ deleted: true, comment_id, result: data?.result });
91
260
  });
92
261
  server.registerTool('untappd_delete_checkin', {
93
262
  title: 'Delete an Untappd check-in',
94
- description: 'Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. Without ' +
95
- '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}`,
96
264
  annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
97
265
  inputSchema: z.object({
98
266
  checkin_id: CheckinIdSchema,
99
- confirm: schemaConfirm,
267
+ confirmToken: confirmTokenParam,
100
268
  }),
101
- }, async ({ checkin_id, confirm }) => {
102
- if (confirm !== true) {
103
- return minifiedResult({
104
- 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: {
105
279
  action: 'delete_checkin',
106
280
  checkin_id,
107
- note: 'Dry run — re-run with confirm: true to PERMANENTLY delete this check-in. This cannot be undone.',
108
- });
109
- }
281
+ ...request,
282
+ note: 'PERMANENTLY deletes this check-in. This cannot be undone.',
283
+ },
284
+ });
285
+ if (gate)
286
+ return gate;
110
287
  const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
111
288
  return minifiedResult({ deleted: true, checkin_id, result: data?.result });
112
289
  });
@@ -114,15 +291,19 @@ export function registerCheckinTools(server, client) {
114
291
  title: 'Check in a beer on Untappd',
115
292
  description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
116
293
  '(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), a venue via ' +
117
- 'foursquare_id, and a local photo via photo_path (JPEG/PNG). Without confirm: true it returns a dry-run ' +
118
- '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}`,
119
296
  annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
120
297
  inputSchema: z.object({
121
298
  bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
122
299
  rating: RatingSchema.optional().describe('Rating 0–5 in 0.25 increments (omit for no rating)'),
123
300
  shout: z.string().max(2000).optional().describe('Optional shout / comment text for the check-in'),
124
301
  foursquare_id: z.string().optional().describe('Optional Foursquare venue id to tag the check-in location'),
125
- photo_path: z.string().optional().describe('Optional path to a local JPEG/PNG photo to attach to the check-in'),
302
+ photo_path: z
303
+ .string()
304
+ .optional()
305
+ .describe('Optional path to a local JPEG/PNG photo (max 15 MB) to attach — it is published publicly. Only use a ' +
306
+ 'file the user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
126
307
  geolat: z.number().optional().describe('Optional latitude of the check-in'),
127
308
  geolng: z.number().optional().describe('Optional longitude of the check-in'),
128
309
  container_id: z
@@ -130,19 +311,22 @@ export function registerCheckinTools(server, client) {
130
311
  .int()
131
312
  .optional()
132
313
  .describe('Optional serving container id (e.g. 1 = draft, 2 = bottle, 3 = can)'),
133
- confirm: schemaConfirm,
314
+ timezone: z
315
+ .string()
316
+ .min(1)
317
+ .optional()
318
+ .describe("The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to " +
319
+ "UNTAPPD_TIMEZONE, else the server's own zone — which on a hosted connector is usually UTC, so pass it " +
320
+ 'when you know where the user is.'),
321
+ confirmToken: confirmTokenParam,
134
322
  }),
135
- }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, confirm }) => {
136
- const { timezone, gmt_offset } = localTimezone();
137
- let ext;
138
- if (photo_path !== undefined) {
139
- ext = photoExt(photo_path);
140
- if (!(ext in PHOTO_CONTENT_TYPES)) {
141
- throw createHelpfulError(`Unsupported photo type "${ext || '(none)'}".`, {
142
- hint: 'Attach a .jpg, .jpeg, or .png file.',
143
- });
144
- }
145
- }
323
+ }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken }, ctx) => {
324
+ const { timezone, gmt_offset } = checkinTimezone(requestedTz);
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.
328
+ const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
329
+ const photo_sha256 = photo ? await photoSha256(photo.path) : undefined;
146
330
  const form = {
147
331
  bid,
148
332
  rating: rating !== undefined ? rating.toFixed(2) : undefined,
@@ -153,38 +337,72 @@ export function registerCheckinTools(server, client) {
153
337
  container_id,
154
338
  timezone,
155
339
  gmt_offset,
156
- is_photo: photo_path !== undefined ? 'true' : 'false',
157
- photo_file_ext: photo_path !== undefined ? ext : undefined,
340
+ is_photo: photo ? 'true' : 'false',
341
+ photo_file_ext: photo?.ext,
158
342
  platform: 'ios',
159
343
  };
160
- if (confirm !== true) {
161
- return minifiedResult({
162
- 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: {
163
353
  action: 'checkin',
164
- form,
165
- photo: photo_path !== undefined ? { path: photo_path, note: 'will be uploaded after the check-in is created' } : undefined,
166
- note: 'Dry run — re-run with confirm: true to POST this check-in to your public Untappd feed.',
167
- });
168
- }
354
+ ...request,
355
+ photo: photo ? { ...photo, note: 'this exact file will be uploaded PUBLICLY after the check-in is created' } : undefined,
356
+ note: 'POSTs this check-in to your public Untappd feed.',
357
+ },
358
+ });
359
+ if (gate)
360
+ return gate;
169
361
  // Open the photo BEFORE creating the check-in, so a missing/unreadable
170
362
  // file fails fast without leaving an orphaned photo-less check-in behind.
171
363
  let blob;
172
- if (photo_path !== undefined) {
173
- if (!existsSync(photo_path))
174
- throw new McpToolError(`Photo file not found: ${photo_path}`);
175
- blob = await fileBlob(photo_path); // file-backed, streamed — not heap-buffered
364
+ if (photo) {
365
+ // file-backed, streamed — not heap-buffered; re-checks the size cap/roots at open.
366
+ blob = await fileBlob(photo.path, { maxBytes: MAX_PHOTO_BYTES, label: 'Photo', allowedRoots: photoRoots() });
367
+ }
368
+ const sentAt = Date.now();
369
+ let data;
370
+ try {
371
+ data = await client.write('POST', '/checkin/add', { form });
372
+ }
373
+ catch (e) {
374
+ if (!(e instanceof UnreachableError))
375
+ throw e;
376
+ // The POST may have landed before the connection failed. Look before
377
+ // reporting failure: a retry of a check-in that DID land double-posts it
378
+ // to the public feed (and double-counts stats and badges).
379
+ const found = await findRecentCheckin(client, bid, sentAt);
380
+ if (found === null) {
381
+ throw new McpToolError('Untappd did not answer the check-in request in time, so the check-in may have been created anyway ' +
382
+ '(it could not be confirmed either way). Look at your latest check-ins with untappd_user_checkins ' +
383
+ 'before retrying — retrying a check-in that did land posts it twice.');
384
+ }
385
+ return minifiedResult({
386
+ checked_in: true,
387
+ checkin_id: found,
388
+ recovered: true,
389
+ photo_attached: false,
390
+ ...(photo
391
+ ? { photo_error: `Check-in ${found} was created, but its photo upload URL was lost with the timed-out response, so no photo was attached.` }
392
+ : {}),
393
+ note: 'Untappd did not answer in time, but the check-in was found on your account — it was created. Do not retry.',
394
+ });
176
395
  }
177
- const data = await client.write('POST', '/checkin/add', { form });
178
396
  // Photo is a follow-up S3 upload keyed to the returned checkin_id, then an
179
397
  // uploadComplete call. The check-in already exists at this point, so a
180
398
  // photo failure is surfaced explicitly (photo_error) rather than thrown —
181
399
  // never silently dropped.
182
400
  let photo_attached = false;
183
401
  let photo_error;
184
- if (photo_path !== undefined) {
402
+ if (photo) {
185
403
  if (data?.photo_upload?.url && data.checkin_id) {
186
404
  try {
187
- await client.putBinary(data.photo_upload.url, blob, PHOTO_CONTENT_TYPES[ext]);
405
+ await client.putBinary(data.photo_upload.url, blob, photo.content_type);
188
406
  await client.write('POST', '/photo/uploadComplete', {
189
407
  form: { checkin_id: data.checkin_id, destination_url: data.photo_upload.destination_url },
190
408
  });
@@ -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
  });