untappd-mcp 2.6.0 → 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
  });
@@ -11,7 +11,8 @@ const FEED_VIEW = z
11
11
  .optional()
12
12
  .describe('Response shape: "compact" (default) projects each check-in to {id, user, beer, brewery, venue, rating, comment, ' +
13
13
  'toast/comment counts}; "full" returns Untappd\'s whole ~5 KB record; "brief" returns one line per check-in: ' +
14
- '<user>: <beer> (<brewery>) at <venue> — <ISO date or "no date"> — <rated N|not rated>.');
14
+ '<user>: <beer> (<brewery>) at <venue> — <ISO date or "no date"> — <rated N|not rated>, inside the untrusted-data ' +
15
+ 'envelope as data: string[], with "-- " note lines ("-- none", "-- more").');
15
16
  export function registerFeedTools(server, client) {
16
17
  server.registerTool('untappd_activity_feed', {
17
18
  title: 'Get Untappd friend activity feed',
@@ -1,3 +1,4 @@
1
+ import { UNTRUSTED_CONTENT_RULE, untrustedEnvelope } from '@chrischall/mcp-utils';
1
2
  /** `_meta` for an Untappd tool: every tool here is in the `food` domain. */
2
3
  export function foodMeta(intent, extra = {}) {
3
4
  return { mint: { domain: 'food', intent, ...extra } };
@@ -19,8 +20,10 @@ function isoInstant(v) {
19
20
  }
20
21
  /**
21
22
  * One check-in as one line, `<user>: <beer> (<brewery>) at <venue> — <ISO date | no date> — <rated N | not rated>`,
22
- * at most 120 characters (the title is shortened). Read only from the fields a
23
- * check-in record carries; a rating of 0 is Untappd's "no rating".
23
+ * cut to at most 120 characters as a whole, title first. A title starting with
24
+ * `-- ` or `\\` is escaped with one leading `\\`, so an item never reads as a
25
+ * note. Read only from the fields a check-in record carries; a rating of 0 is
26
+ * Untappd's "no rating".
24
27
  */
25
28
  export function briefCheckinLine(item) {
26
29
  const c = dict(item);
@@ -28,15 +31,40 @@ export function briefCheckinLine(item) {
28
31
  const beer = str(dict(c.beer).beer_name) ?? 'a beer';
29
32
  const brewery = str(dict(c.brewery).brewery_name);
30
33
  const venue = str(dict(c.venue).venue_name);
31
- const title = `${who}: ${beer}${brewery ? ` (${brewery})` : ''}${venue ? ` at ${venue}` : ''}`;
34
+ const raw = `${who}: ${beer}${brewery ? ` (${brewery})` : ''}${venue ? ` at ${venue}` : ''}`;
35
+ // A title that could read as a note (`-- `) or lose a character to the host's one-backslash strip is escaped.
36
+ const title = raw.startsWith('-- ') || raw.startsWith('\\') ? `\\${raw}` : raw;
32
37
  const rating = typeof c.rating_score === 'number' && c.rating_score > 0 ? `rated ${c.rating_score}` : 'not rated';
33
38
  const tail = ` — ${isoInstant(c.created_at) ?? 'no date'} — ${rating}`;
34
39
  const room = BRIEF_MAX - tail.length;
35
40
  const shown = title.length > room ? `${title.slice(0, Math.max(room - 1, 0))}…` : title;
36
41
  return `${shown}${tail}`;
37
42
  }
38
- /** A `{ checkins: { items } }` response as brief lines, one per check-in (none for an empty or unexpected page). */
43
+ /** Who writes the brief lines' text — the head of the untrusted-data envelope's note. */
44
+ const BRIEF_WHO_WROTE = 'Check-in user names and beer, brewery and venue names are written by other Untappd users and breweries.';
45
+ /**
46
+ * A `{ checkins: { items, pagination } }` response as brief lines, one per
47
+ * check-in, plus `-- ` notes: `-- none` for an empty page, `-- more` when
48
+ * Untappd returned an older-page cursor (`pagination.max_id`), and
49
+ * `-- error: …` for a shape this view does not read.
50
+ */
51
+ export function briefCheckinLines(resp) {
52
+ const box = dict(dict(resp).checkins);
53
+ if (!Array.isArray(box.items)) {
54
+ return ['-- error: Untappd answered in a shape this brief view does not read; ask with view "full"'];
55
+ }
56
+ if (box.items.length === 0)
57
+ return ['-- none'];
58
+ const lines = box.items.map(briefCheckinLine);
59
+ const next = dict(box.pagination).max_id;
60
+ if (typeof next === 'number' && next > 0)
61
+ lines.push('-- more');
62
+ return lines;
63
+ }
64
+ /**
65
+ * The brief answer: the lines inside the fleet's untrusted-data envelope as
66
+ * `data: string[]`, minified, note first — the text in them is other people's.
67
+ */
39
68
  export function briefCheckins(resp) {
40
- const items = dict(dict(resp).checkins).items;
41
- return Array.isArray(items) ? items.map(briefCheckinLine).join('\n') : '';
69
+ return JSON.stringify(untrustedEnvelope(briefCheckinLines(resp), { note: `${BRIEF_WHO_WROTE} ${UNTRUSTED_CONTENT_RULE}` }));
42
70
  }
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.0'; // 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.0",
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.0",
9
+ "version": "2.7.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.6.0",
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
@@ -75,7 +79,11 @@ and you still get compact. `view: "full"` is the only way back.
75
79
 
76
80
  `untappd_activity_feed` also takes a third rung, `view: "brief"`: one line per
77
81
  check-in, `<user>: <beer> (<brewery>) at <venue> — <ISO date | no date> —
78
- <rated N | not rated>`, each at most 120 characters. It is the shape a
82
+ <rated N | not rated>`, each at most 120 characters, answered inside the
83
+ untrusted-data envelope as `data: string[]` (the names are other Untappd
84
+ users'). A line starting `-- ` is a note, not a check-in: `-- none` for an
85
+ empty feed, `-- more` when an older page exists; a name that itself starts
86
+ with `-- ` or `\` is escaped with one leading `\`. It is the shape a
79
87
  context-group host reads aloud (below); use it when you only need to say what
80
88
  friends have been drinking.
81
89
 
@@ -175,9 +183,8 @@ preview). `MCP_CONFIRM_MODE` on the server can make this `auto` or `refuse`.
175
183
  - `untappd_toast` — toast/like a check-in (this endpoint is a toggle).
176
184
  - `untappd_add_comment` — comment on a check-in.
177
185
  - `untappd_checkin` — post a new beer check-in to your feed (needs a `bid` from
178
- `untappd_search_beer`; optional `rating` 0–5 in 0.25 steps, `shout`, venue).
179
-
180
- 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).
181
188
 
182
189
  ## Check-in cache
183
190