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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -1
- package/dist/bundle.js +943 -91
- package/dist/tools/checkin.js +144 -17
- package/dist/tools/feed.js +2 -1
- package/dist/tools/hints.js +34 -6
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/untappd-mcp/SKILL.md +14 -7
package/dist/tools/checkin.js
CHANGED
|
@@ -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
|
|
327
|
-
|
|
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
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
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:
|
|
377
|
-
photo_file_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:
|
|
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
|
-
...(
|
|
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 (
|
|
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,
|
|
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/tools/feed.js
CHANGED
|
@@ -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',
|
package/dist/tools/hints.js
CHANGED
|
@@ -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
|
|
23
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
+
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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "2.7.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "2.
|
|
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:**
|
|
29
|
-
`
|
|
30
|
-
`~/
|
|
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
|
|
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
|
|