untappd-mcp 2.6.1 → 2.7.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.
@@ -6,6 +6,7 @@ import { delimiter, extname, join } from 'node:path';
6
6
  import { z } from 'zod';
7
7
  import { foodMeta } from './hints.js';
8
8
  import { confirmTokenParam, McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
9
+ import { blobStoreFromEnv, inlineFileSchema, isHostedEnv, MAX_UPLOAD_FILES, mintUploadSlot, resolveUploadMime, resolveUploadSource, sanitizeUploadFilename, uploadSlotRequestSchema, } from '@chrischall/mcp-utils/export';
9
10
  import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
10
11
  const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
11
12
  // Keyed by the NORMALISED extension that photoExt() returns (jpeg → jpg), so
@@ -98,6 +99,38 @@ async function photoSha256(path) {
98
99
  await pipeline(createReadStream(path), hash);
99
100
  return hash.digest('hex');
100
101
  }
102
+ /** The parameter names the photo routes are spelled with here, for resolveUploadSource's messages. */
103
+ const PHOTO_PARAM_NAMES = {
104
+ path: 'photo_path',
105
+ inline: 'photo_inline',
106
+ blobKey: 'photo_blob_key',
107
+ slotTool: 'untappd_upload_slots',
108
+ };
109
+ /** The image types Untappd takes for a check-in photo, as bare MIME types. */
110
+ const PHOTO_MIME_TYPES = Object.values(PHOTO_CONTENT_TYPES);
111
+ /** Untappd's `photo_file_ext` for an accepted photo MIME type. */
112
+ function extForMime(mime) {
113
+ return Object.entries(PHOTO_CONTENT_TYPES).find(([, m]) => m === mime)[0];
114
+ }
115
+ /**
116
+ * A photo that arrived as bytes — inline base64 or an upload-slot blob —
117
+ * through {@link resolveUploadSource}: exactly one source, image types only,
118
+ * the same cap as a local path, the bytes sniffed against the type. Called on
119
+ * EVERY phase, so the confirmed call re-reads the blob and a changed one no
120
+ * longer matches the sha256 bound into the token. Reading never deletes.
121
+ *
122
+ * A local `photo_path` does NOT come through here (it keeps {@link checkPhoto}
123
+ * and its photo-directory rules) — except on a hosted deployment, where
124
+ * resolveUploadSource is what refuses it, and when it was combined with
125
+ * another source, which resolveUploadSource refuses as "exactly one".
126
+ */
127
+ function resolvePhotoSource(input) {
128
+ return resolveUploadSource(input, {
129
+ maxBytes: MAX_PHOTO_BYTES,
130
+ allowedMimeTypes: PHOTO_MIME_TYPES,
131
+ names: PHOTO_PARAM_NAMES,
132
+ });
133
+ }
101
134
  // Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
102
135
  const RatingSchema = z
103
136
  .number()
@@ -319,12 +352,54 @@ export function registerCheckinTools(server, client) {
319
352
  const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
320
353
  return minifiedResult({ deleted: true, checkin_id, result: data?.result });
321
354
  });
355
+ server.registerTool('untappd_upload_slots', {
356
+ title: 'Get upload URLs for Untappd check-in photos',
357
+ description: 'Get signed upload URLs for check-in photos — the way to attach a photo on the HOSTED connector, which cannot ' +
358
+ 'read files on your machine. For each photo (JPEG or PNG, max 15 MB) it returns a blobKey and a ready curl ' +
359
+ 'command: run the curl with the path to the photo (it must send exactly the returned Content-Type), then pass ' +
360
+ 'the blobKey to untappd_checkin as photo_blob_key. URLs expire after 30 minutes. Posts nothing to Untappd. ' +
361
+ 'Only available where the deployment provides a blob store; on a local install use photo_path or photo_inline.',
362
+ annotations: toolAnnotations({ title: 'Get upload URLs for Untappd check-in photos', readOnly: true, openWorld: false }),
363
+ _meta: foodMeta('lookup', { aliases: ['upload photo', 'beer photo'] }),
364
+ inputSchema: z.object({
365
+ slots: z
366
+ .array(uploadSlotRequestSchema)
367
+ .min(1)
368
+ .max(MAX_UPLOAD_FILES)
369
+ .describe(`One entry per photo to upload (at most ${MAX_UPLOAD_FILES}): {filename, mimeType?}. JPEG or PNG only.`),
370
+ }),
371
+ }, async ({ slots }) => {
372
+ const store = blobStoreFromEnv();
373
+ if (!store) {
374
+ throw createHelpfulError('Upload slots need the blob store a hosted deployment provides, and this server has no blob store.', {
375
+ hint: 'On a local install attach the photo with untappd_checkin photo_path (a file in UNTAPPD_PHOTO_DIR) or photo_inline (base64).',
376
+ });
377
+ }
378
+ // Refuse a non-photo before minting a URL the check-in would only reject later.
379
+ for (const req of slots) {
380
+ const mime = resolveUploadMime(sanitizeUploadFilename(req.filename), req.mimeType);
381
+ if (!PHOTO_MIME_TYPES.includes(mime)) {
382
+ throw createHelpfulError(`${req.filename}: ${mime} is not a check-in photo type.`, {
383
+ hint: 'Untappd check-in photos must be JPEG or PNG (.jpg, .jpeg or .png).',
384
+ });
385
+ }
386
+ }
387
+ return minifiedResult({
388
+ slots: slots.map((req) => mintUploadSlot(store, req)),
389
+ next: 'PUT each photo to its uploadUrl (the curl command, with the placeholder replaced by the file path), then ' +
390
+ 'call untappd_checkin with photo_blob_key set to that slot\'s blobKey. The upload is deleted once the ' +
391
+ 'check-in and its photo succeed.',
392
+ });
393
+ });
322
394
  server.registerTool('untappd_checkin', {
323
395
  title: 'Check in a beer on Untappd',
324
396
  description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
325
397
  '(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), a venue via ' +
326
- 'foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo ' +
327
- `that will be posted. ${CONFIRM_FLOW}`,
398
+ 'foursquare_id, and ONE photo (JPEG/PNG, max 15 MB) via exactly one of photo_path (a local file), ' +
399
+ 'photo_inline (base64, for a small photo) or photo_blob_key. On the hosted connector, use the slot flow: call ' +
400
+ 'untappd_upload_slots, PUT the photo with the returned curl, then pass its blobKey as photo_blob_key (a path ' +
401
+ 'cannot work there). The preview shows the exact fields and photo that will be posted. ' +
402
+ `${CONFIRM_FLOW}`,
328
403
  // Destructive: it publishes to your public feed, where friends see it; untappd_delete_checkin cannot unpublish what they saw.
329
404
  annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
330
405
  _meta: foodMeta('change', { aliases: ['check in', 'log a beer', 'rate a beer', 'drinking'] }),
@@ -338,7 +413,18 @@ export function registerCheckinTools(server, client) {
338
413
  .optional()
339
414
  .describe('Optional path to a local JPEG/PNG photo (max 15 MB) to attach — it is published publicly. It must be ' +
340
415
  'inside the photo directory (UNTAPPD_PHOTO_DIR, default ~/Downloads/untappd-mcp). Only use a file the ' +
341
- 'user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
416
+ 'user explicitly chose; the preview shows the resolved path and size for them to confirm. Local installs ' +
417
+ 'only — refused on the hosted connector (use photo_blob_key).'),
418
+ photo_inline: inlineFileSchema
419
+ .optional()
420
+ .describe('Optional photo as base64: {filename, contentBase64, mimeType?}, JPEG or PNG, max 15 MB — it is published ' +
421
+ 'publicly. Works on any deployment; for anything but a small photo prefer the upload slot flow ' +
422
+ '(photo_blob_key). Only use a photo the user explicitly chose.'),
423
+ photo_blob_key: z
424
+ .string()
425
+ .optional()
426
+ .describe('Optional blobKey of a photo PUT to a slot from untappd_upload_slots (hosted connector) — it is published ' +
427
+ 'publicly. Deleted from the upload store once the check-in and its photo succeed; kept if they fail.'),
342
428
  geolat: z.number().optional().describe('Optional latitude of the check-in'),
343
429
  geolng: z.number().optional().describe('Optional longitude of the check-in'),
344
430
  container_id: z
@@ -356,13 +442,37 @@ export function registerCheckinTools(server, client) {
356
442
  confirmToken: confirmTokenParam,
357
443
  }),
358
444
  }, async (args, ctx) => {
359
- const { bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken } = args;
445
+ const { bid, rating, shout, foursquare_id, photo_path, photo_inline, photo_blob_key, geolat, geolng, container_id, timezone: requestedTz, confirmToken, } = args;
360
446
  const { timezone, gmt_offset } = checkinTimezone(requestedTz);
361
- // Vetted on every call, so the preview names the exact file (resolved path
362
- // + size) that will be published — and a file whose bytes change between
363
- // the preview and the confirmed call no longer matches the token.
364
- const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
365
- const photo_sha256 = photo ? await photoSha256(photo.path) : undefined;
447
+ // Vetted on every call, so the preview names the exact photo that will be
448
+ // published — and one whose bytes change between the preview and the
449
+ // confirmed call no longer matches the token. A lone local photo_path keeps
450
+ // its own checks; every other route (and a path on a hosted deployment,
451
+ // which is refused there) goes through resolveUploadSource.
452
+ const localPath = photo_path !== undefined && photo_inline === undefined && photo_blob_key === undefined && !isHostedEnv();
453
+ const anyPhoto = photo_path !== undefined || photo_inline !== undefined || photo_blob_key !== undefined;
454
+ const source = anyPhoto && !localPath
455
+ ? await resolvePhotoSource({ path: photo_path, inline: photo_inline, blobKey: photo_blob_key })
456
+ : undefined;
457
+ const photo = localPath ? await checkPhoto(photo_path) : undefined;
458
+ const photo_sha256 = photo ? await photoSha256(photo.path) : source?.sha256;
459
+ const photo_ext = photo?.ext ?? (source ? extForMime(source.mimeType) : undefined);
460
+ const photo_content_type = photo?.content_type ?? source?.mimeType;
461
+ const hasPhoto = photo !== undefined || source !== undefined;
462
+ // What the preview (and the token) names: a local file by its resolved
463
+ // path; bytes by name, type, size and hash (never the signed URL).
464
+ const photoPreview = photo
465
+ ? photo
466
+ : source
467
+ ? {
468
+ origin: source.origin,
469
+ file_name: source.fileName,
470
+ content_type: source.mimeType,
471
+ size_bytes: source.sizeBytes,
472
+ sha256: source.sha256,
473
+ ...(source.blobKey !== undefined ? { blob_key: source.blobKey } : {}),
474
+ }
475
+ : undefined;
366
476
  const form = {
367
477
  bid,
368
478
  rating: rating !== undefined ? rating.toFixed(2) : undefined,
@@ -373,8 +483,8 @@ export function registerCheckinTools(server, client) {
373
483
  container_id,
374
484
  timezone,
375
485
  gmt_offset,
376
- is_photo: photo ? 'true' : 'false',
377
- photo_file_ext: photo?.ext,
486
+ is_photo: hasPhoto ? 'true' : 'false',
487
+ photo_file_ext: photo_ext,
378
488
  platform: 'ios',
379
489
  };
380
490
  const request = { method: 'POST', path: '/checkin/add', form };
@@ -385,11 +495,13 @@ export function registerCheckinTools(server, client) {
385
495
  confirmToken,
386
496
  args,
387
497
  target: bid,
388
- payload: { ...request, photo, photo_sha256 },
498
+ payload: { ...request, photo: photoPreview, photo_sha256 },
389
499
  preview: {
390
500
  action: 'checkin',
391
501
  ...request,
392
- photo: photo ? { ...photo, note: 'this exact file will be uploaded PUBLICLY after the check-in is created' } : undefined,
502
+ photo: photoPreview
503
+ ? { ...photoPreview, note: 'this exact photo will be uploaded PUBLICLY after the check-in is created' }
504
+ : undefined,
393
505
  note: 'POSTs this check-in to your public Untappd feed.',
394
506
  },
395
507
  });
@@ -402,6 +514,17 @@ export function registerCheckinTools(server, client) {
402
514
  // file-backed, streamed — not heap-buffered; re-checks the size cap/roots at open.
403
515
  blob = await fileBlob(photo.path, { maxBytes: MAX_PHOTO_BYTES, label: 'Photo', allowedRoots: photoRoots() });
404
516
  }
517
+ else if (source) {
518
+ // Already read and hashed above; copied so the Blob owns a plain ArrayBuffer.
519
+ blob = new Blob([new Uint8Array(source.bytes)], { type: source.mimeType });
520
+ }
521
+ // An uploaded blob is deleted only after the check-in AND its photo
522
+ // succeed; on any other outcome it is kept (and said so), so the caller
523
+ // can delete the check-in and retry with the same photo_blob_key. The
524
+ // blob store's retention prune reclaims it otherwise.
525
+ const keptBlob = source?.origin === 'blob'
526
+ ? { photo_blob: { deleted: false, kept: true, blob_key: source.blobKey, note: 'The uploaded photo was kept because it was not attached.' } }
527
+ : {};
405
528
  const sentAt = Date.now();
406
529
  let data;
407
530
  try {
@@ -424,8 +547,8 @@ export function registerCheckinTools(server, client) {
424
547
  checkin_id: found,
425
548
  recovered: true,
426
549
  photo_attached: false,
427
- ...(photo
428
- ? { photo_error: `Check-in ${found} was created, but its photo upload URL was lost with the timed-out response, so no photo was attached.` }
550
+ ...(hasPhoto
551
+ ? { photo_error: `Check-in ${found} was created, but its photo upload URL was lost with the timed-out response, so no photo was attached.`, ...keptBlob }
429
552
  : {}),
430
553
  note: 'Untappd did not answer in time, but the check-in was found on your account — it was created. Do not retry.',
431
554
  });
@@ -436,10 +559,10 @@ export function registerCheckinTools(server, client) {
436
559
  // never silently dropped.
437
560
  let photo_attached = false;
438
561
  let photo_error;
439
- if (photo) {
562
+ if (hasPhoto) {
440
563
  if (data?.photo_upload?.url && data.checkin_id) {
441
564
  try {
442
- await client.putBinary(data.photo_upload.url, blob, photo.content_type);
565
+ await client.putBinary(data.photo_upload.url, blob, photo_content_type);
443
566
  await client.write('POST', '/photo/uploadComplete', {
444
567
  form: { checkin_id: data.checkin_id, destination_url: data.photo_upload.destination_url },
445
568
  });
@@ -453,11 +576,15 @@ export function registerCheckinTools(server, client) {
453
576
  photo_error = `Check-in ${data?.checkin_id} was created, but Untappd returned no photo upload URL, so no photo was attached.`;
454
577
  }
455
578
  }
579
+ // Release (delete) an uploaded blob only now that the photo is attached; a
580
+ // failed delete is reported, never thrown — the check-in already exists.
581
+ const blobOutcome = source?.origin === 'blob' ? (photo_attached ? { photo_blob: await source.release() } : keptBlob) : {};
456
582
  return minifiedResult({
457
583
  checked_in: true,
458
584
  checkin_id: data?.checkin_id,
459
585
  photo_attached,
460
586
  ...(photo_error ? { photo_error } : {}),
587
+ ...blobOutcome,
461
588
  response: data,
462
589
  });
463
590
  });
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.1'; // x-release-please-version
6
+ export const VERSION = '2.7.0'; // x-release-please-version
package/mint.yaml CHANGED
@@ -92,3 +92,8 @@ egress:
92
92
  # - a signed upload URL returned by the API (often storage.googleapis.com / s3)
93
93
  # Confirm the concrete host(s) against a live run and add them, or
94
94
  # the isolated tier will block the call.
95
+ #
96
+ # Not listed: mcp-host's own blob store (MCP_BLOB_BASE_URL, the gateway
97
+ # serving this connector — its host differs per deployment). The server
98
+ # GETs + DELETEs check-in photos the caller uploaded through
99
+ # untappd_upload_slots. Signed requests only; no Untappd credential is sent.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "2.6.1",
3
+ "version": "2.7.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": "^3.0.3",
48
+ "@chrischall/mcp-utils": "^3.7.2",
49
49
  "@modelcontextprotocol/server": "^2.3.0",
50
50
  "dotenv": "^18.0.1",
51
51
  "zod": "^4.6.5"
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.6.1",
9
+ "version": "2.7.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.6.1",
14
+ "version": "2.7.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -25,9 +25,13 @@ client credentials):
25
25
 
26
26
  Two things the server enforces that you should plan around:
27
27
 
28
- - **Photos:** `untappd_checkin`'s `photo_path` must be a file inside
29
- `UNTAPPD_PHOTO_DIR` (default `~/Downloads/untappd-mcp`); anything else (e.g.
30
- `~/Pictures/beer.jpg`) is refused. Ask the user to move the photo there first.
28
+ - **Photos:** pass exactly one of `photo_path`, `photo_inline` or
29
+ `photo_blob_key` (JPEG/PNG, max 15 MB). Locally, `photo_path` must be a file
30
+ inside `UNTAPPD_PHOTO_DIR` (default `~/Downloads/untappd-mcp`); anything else
31
+ (e.g. `~/Pictures/beer.jpg`) is refused — ask the user to move the photo there
32
+ first. On the **hosted connector** a path cannot work: call
33
+ `untappd_upload_slots`, PUT the photo with the returned `curl`, then pass its
34
+ `blobKey` as `photo_blob_key` (or, for a small photo, `photo_inline` base64).
31
35
  - **Time zone:** on a hosted connector the server usually runs in UTC, so pass
32
36
  `timezone` (the drinker's IANA zone, e.g. `America/New_York`) on
33
37
  `untappd_checkin` unless `UNTAPPD_TIMEZONE` is set — otherwise the check-in is
@@ -179,9 +183,8 @@ preview). `MCP_CONFIRM_MODE` on the server can make this `auto` or `refuse`.
179
183
  - `untappd_toast` — toast/like a check-in (this endpoint is a toggle).
180
184
  - `untappd_add_comment` — comment on a check-in.
181
185
  - `untappd_checkin` — post a new beer check-in to your feed (needs a `bid` from
182
- `untappd_search_beer`; optional `rating` 0–5 in 0.25 steps, `shout`, venue).
183
-
184
- Photo attachment and wishlist add/remove are not yet supported.
186
+ `untappd_search_beer`; optional `rating` 0–5 in 0.25 steps, `shout`, venue,
187
+ one photo — see **Photos** above).
185
188
 
186
189
  ## Check-in cache
187
190