untappd-mcp 2.5.2 → 2.6.1

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,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
4
  import { compactBeerSearch, compactCheckins, UNTAPPD_VIEWS, upstreamCompact } from '../compact.js';
4
5
  import { beerMetaFrom } from '../cache/store.js';
@@ -33,6 +34,7 @@ export function registerBeerTools(server, client, cache) {
33
34
  'beer id (bid), brewery, style, ABV, IBU, and global rating. Feed a bid into untappd_beer_info for full ' +
34
35
  'detail. Read-only.',
35
36
  annotations: toolAnnotations({ title: 'Search Untappd beers', readOnly: true, idempotent: true, openWorld: true }),
37
+ _meta: foodMeta('lookup', { aliases: ['beer', 'ipa', 'stout', 'brew'] }),
36
38
  inputSchema: z.object({
37
39
  query: z.string().min(1).describe('Beer name to search for'),
38
40
  limit: z.number().int().min(1).max(50).optional().describe('Max results (1–50, default 25)'),
@@ -57,6 +59,7 @@ export function registerBeerTools(server, client, cache) {
57
59
  description: 'Get full detail for a beer by its Untappd beer id (bid): description, style, ABV, IBU, brewery, rating, ' +
58
60
  'total check-in count, and — on view:"full" — recent activity. Get a bid from untappd_search_beer. Read-only.',
59
61
  annotations: toolAnnotations({ title: 'Get Untappd beer detail', readOnly: true, idempotent: true, openWorld: true }),
62
+ _meta: foodMeta('lookup'),
60
63
  inputSchema: z.object({
61
64
  bid: BidSchema,
62
65
  // No local projection here: compact is forwarded to Untappd as its OWN
@@ -75,6 +78,7 @@ export function registerBeerTools(server, client, cache) {
75
78
  description: 'Get the recent public check-ins for a beer by its bid — who drank it, their rating, comment, and venue. ' +
76
79
  'Page backwards with max_id (the pagination.max_id from a prior call). Read-only.',
77
80
  annotations: toolAnnotations({ title: 'Get recent check-ins for a beer', readOnly: true, idempotent: false, openWorld: true }),
81
+ _meta: foodMeta('lookup'),
78
82
  inputSchema: z.object({
79
83
  bid: BidSchema,
80
84
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
4
  import { UNTAPPD_VIEWS, compactBreweryBeerList, upstreamCompact } from '../compact.js';
4
5
  import { BREWERY_BEERS_DEFAULT_LIMIT, BREWERY_BEERS_TTL_MS, getBreweryBeers } from '../cache/brewery-beers.js';
@@ -11,6 +12,7 @@ export function registerBreweryTools(server, client, cacheProvider) {
11
12
  description: 'Search Untappd for breweries by name. Returns matches with their brewery id, location, type, and beer ' +
12
13
  'count. Feed a brewery id into untappd_brewery_info for full detail. Read-only.',
13
14
  annotations: toolAnnotations({ title: 'Search Untappd breweries', readOnly: true, idempotent: true, openWorld: true }),
15
+ _meta: foodMeta('lookup', { aliases: ['brewery', 'brewer'] }),
14
16
  inputSchema: z.object({
15
17
  query: z.string().min(1).describe('Brewery name to search for'),
16
18
  limit: z.number().int().min(1).max(50).optional().describe('Max results (1–50, default 25)'),
@@ -27,6 +29,7 @@ export function registerBreweryTools(server, client, cacheProvider) {
27
29
  'claimed_status.follow_status is whether the SIGNED-IN account follows/likes the brewery — never another ' +
28
30
  "user's; for theirs, run untappd_sync_brewery_likes and read untappd_favorites_list (source \"untappd\").",
29
31
  annotations: toolAnnotations({ title: 'Get Untappd brewery detail', readOnly: true, idempotent: true, openWorld: true }),
32
+ _meta: foodMeta('lookup'),
30
33
  inputSchema: z.object({
31
34
  brewery_id: z.number().int().positive().describe('Untappd brewery id'),
32
35
  view: viewParam(UNTAPPD_VIEWS, { note: 'compact also asks Untappd for its own slim record, dropping the embedded activity/list blocks; "full" returns everything.' }),
@@ -50,6 +53,7 @@ export function registerBreweryTools(server, client, cacheProvider) {
50
53
  'reason} for the brewery and on each favorite beer (derived from username\'s history or added with ' +
51
54
  'untappd_favorite_add; default your account). view:"full" always calls Untappd and returns its raw page. Read-only.',
52
55
  annotations: toolAnnotations({ title: "Get a brewery's beer list", readOnly: true, idempotent: true, openWorld: true }),
56
+ _meta: foodMeta('lookup'),
53
57
  inputSchema: z.object({
54
58
  brewery_id: z.number().int().positive().describe('Untappd brewery id'),
55
59
  limit: z.number().int().min(1).max(50).optional().describe('Max beers (1–50, default 25)'),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { RateLimitError, confirmTokenParam, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
4
  import { beerMetaFrom, DEFAULT_VISIT_RADIUS_M } from '../cache/store.js';
4
5
  import { syncCheckins } from '../cache/sync.js';
@@ -190,6 +191,7 @@ export function registerCacheTools(server, client, cacheProvider) {
190
191
  'is ignored until it reports a live value) and any 429 wait. Use it to confirm the cache persisted across ' +
191
192
  'restarts, to see how far a backfill has got, or to check whether a sync can run now.',
192
193
  annotations: toolAnnotations({ title: 'Show the local cache location, schema and per-user coverage', readOnly: true, idempotent: true, openWorld: false }),
194
+ _meta: foodMeta('lookup'),
193
195
  inputSchema: z.object({}),
194
196
  }, async () => {
195
197
  const cache = cacheProvider();
@@ -219,6 +221,7 @@ export function registerCacheTools(server, client, cacheProvider) {
219
221
  'with stopped_for_rate_limit and rate_limit.resume_at — progress is saved; re-run after that time. Omit ' +
220
222
  'username for your own account.',
221
223
  annotations: toolAnnotations({ title: "Sync a user's check-ins into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
224
+ _meta: foodMeta('other'),
222
225
  inputSchema: z.object({
223
226
  username: UsernameArg,
224
227
  max_pages: z
@@ -255,6 +258,7 @@ export function registerCacheTools(server, client, cacheProvider) {
255
258
  'cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at — progress is saved; re-run after ' +
256
259
  'that time. Omit username for your own account.',
257
260
  annotations: toolAnnotations({ title: "Sync a user's complete distinct-beers list into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
261
+ _meta: foodMeta('other'),
258
262
  inputSchema: z.object({
259
263
  username: UsernameArg,
260
264
  max_pages: z
@@ -283,6 +287,7 @@ export function registerCacheTools(server, client, cacheProvider) {
283
287
  'stopped; another_run_needed stays true until it finishes). Stops BEFORE the ~100 calls/hour rate ' +
284
288
  'limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at. Omit username for your own account.',
285
289
  annotations: toolAnnotations({ title: "Sync a user's complete venue history into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
290
+ _meta: foodMeta('other'),
286
291
  inputSchema: z.object({
287
292
  username: UsernameArg,
288
293
  max_pages: z
@@ -309,6 +314,7 @@ export function registerCacheTools(server, client, cacheProvider) {
309
314
  'Only reads Untappd — never changes the wishlist (that is untappd_wishlist_add / _remove). Omit username for ' +
310
315
  'your own account; another user\'s wishlist needs a public account or friendship.',
311
316
  annotations: toolAnnotations({ title: "Sync a user's wishlist into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
317
+ _meta: foodMeta('other'),
312
318
  inputSchema: z.object({
313
319
  username: UsernameArg,
314
320
  max_pages: z
@@ -344,6 +350,7 @@ export function registerCacheTools(server, client, cacheProvider) {
344
350
  openWorld: true,
345
351
  destructive: false,
346
352
  }),
353
+ _meta: foodMeta('other'),
347
354
  inputSchema: z.object({
348
355
  username: UsernameArg,
349
356
  max_pages: z
@@ -378,6 +385,7 @@ export function registerCacheTools(server, client, cacheProvider) {
378
385
  openWorld: true,
379
386
  destructive: false,
380
387
  }),
388
+ _meta: foodMeta('other'),
381
389
  inputSchema: z.object({
382
390
  username: UsernameArg,
383
391
  max_calls: z
@@ -438,6 +446,7 @@ export function registerCacheTools(server, client, cacheProvider) {
438
446
  'reason}: derived from the visit history or added with untappd_favorite_add), and total_visits; plus a freshness ' +
439
447
  'block (last_synced_at, coverage_complete, caveat). Run untappd_sync_user_venues first.',
440
448
  annotations: toolAnnotations({ title: 'Check if a user has been to a venue (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
449
+ _meta: foodMeta('lookup'),
441
450
  inputSchema: z.object({
442
451
  username: UsernameArg,
443
452
  queries: z.array(VenueQueryArg).min(1).max(50).describe('Places to check (1–50); each needs venue_ids, name, or lat+lng'),
@@ -516,6 +525,7 @@ export function registerCacheTools(server, client, cacheProvider) {
516
525
  '≥4.25 / had ≥3 times, or added with untappd_favorite_add). Reports per-source freshness so you can ' +
517
526
  'caveat incomplete data. Requires bid or beer_name. Run untappd_sync_user_beers first for full coverage.',
518
527
  annotations: toolAnnotations({ title: 'Check if a user has had a beer (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
528
+ _meta: foodMeta('lookup', { aliases: ['have i had', 'tried before'] }),
519
529
  inputSchema: z.object({
520
530
  username: UsernameArg,
521
531
  bid: z.number().int().positive().optional().describe('Exact Untappd beer id to look for'),
@@ -545,6 +555,7 @@ export function registerCacheTools(server, client, cacheProvider) {
545
555
  '"any_not_had" (at least one of them still needs it) — with qualifying_bids and a per-user freshness block. ' +
546
556
  'Run untappd_sync_user_beers first (for every user); the freshness block flags if coverage is incomplete.',
547
557
  annotations: toolAnnotations({ title: 'Batch-check many beers against the cache', readOnly: true, idempotent: true, openWorld: false }),
558
+ _meta: foodMeta('lookup'),
548
559
  inputSchema: z.object({
549
560
  username: UsernameArg,
550
561
  usernames: UsernamesArg,
@@ -606,6 +617,7 @@ export function registerCacheTools(server, client, cacheProvider) {
606
617
  'untappd_sync_user_beers first; if coverage is incomplete the freshness caveat flags that a "not had" may be ' +
607
618
  'a false negative.',
608
619
  annotations: toolAnnotations({ title: 'From a list of beers, return the ones a user has NOT had', readOnly: true, idempotent: true, openWorld: false }),
620
+ _meta: foodMeta('lookup'),
609
621
  inputSchema: z.object({
610
622
  username: UsernameArg,
611
623
  bids: z.array(z.number().int().positive()).min(1).max(500).describe('Candidate beer ids to filter (1–500)'),
@@ -635,6 +647,7 @@ export function registerCacheTools(server, client, cacheProvider) {
635
647
  'for non-self accounts is only the recent window; for full coverage of which beers a user has had, use ' +
636
648
  'untappd_cache_has_had / not_had instead. Run untappd_sync_checkins first.',
637
649
  annotations: toolAnnotations({ title: 'Query cached check-ins with filters', readOnly: true, idempotent: true, openWorld: false }),
650
+ _meta: foodMeta('lookup'),
638
651
  inputSchema: z.object({
639
652
  username: UsernameArg,
640
653
  brewery_id: z.number().int().positive().optional().describe('Exact brewery id'),
@@ -673,6 +686,7 @@ export function registerCacheTools(server, client, cacheProvider) {
673
686
  'untappd_sync_wishlist and Untappd likes untappd_sync_brewery_likes — ' +
674
687
  'and says so per beer (score, boosted_by, wishlisted, brewery_favorite/favorite).',
675
688
  annotations: toolAnnotations({ title: 'Top-rated beers a user has NOT had, from a candidate list', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
689
+ _meta: foodMeta('other', { aliases: ['what should i order', 'beers i have not had'] }),
676
690
  inputSchema: z.object({
677
691
  username: UsernameArg,
678
692
  usernames: UsernamesArg,
@@ -855,6 +869,7 @@ export function registerCacheTools(server, client, cacheProvider) {
855
869
  'cannot be re-fetched). Shared beer, venue and brewery facts are kept. The preview shows the username and exactly how many rows will be removed. Omit username ' +
856
870
  `for your own account. ${CONFIRM_FLOW}`,
857
871
  annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
872
+ _meta: foodMeta('change'),
858
873
  inputSchema: z.object({
859
874
  username: UsernameArg,
860
875
  confirmToken: confirmTokenParam,
@@ -4,6 +4,7 @@ import { pipeline } from 'node:stream/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { delimiter, extname, join } from 'node:path';
6
6
  import { z } from 'zod';
7
+ import { foodMeta } from './hints.js';
7
8
  import { confirmTokenParam, McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
8
9
  import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
9
10
  const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
@@ -192,6 +193,7 @@ export function registerCheckinTools(server, client) {
192
193
  `already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
193
194
  // Destructive: a toast lands in the check-in owner's notifications, and toggling it back off cannot un-notify them.
194
195
  annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
196
+ _meta: foodMeta('send', { aliases: ['toast', 'cheers'] }),
195
197
  inputSchema: z.object({
196
198
  checkin_id: CheckinIdSchema,
197
199
  confirmToken: confirmTokenParam,
@@ -227,6 +229,7 @@ export function registerCheckinTools(server, client) {
227
229
  CONFIRM_FLOW,
228
230
  // Destructive: the comment reaches the check-in owner (and other readers); untappd_delete_comment removes it but cannot un-notify anyone.
229
231
  annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
232
+ _meta: foodMeta('send', { aliases: ['comment'] }),
230
233
  inputSchema: z.object({
231
234
  checkin_id: CheckinIdSchema,
232
235
  comment: z.string().min(1).max(2000).describe('Comment text to post'),
@@ -261,6 +264,7 @@ export function registerCheckinTools(server, client) {
261
264
  title: 'Delete a comment from an Untappd check-in',
262
265
  description: `Delete one of YOUR comments by its comment id (the id from a check-in's comments list). ${CONFIRM_FLOW}`,
263
266
  annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
267
+ _meta: foodMeta('change'),
264
268
  inputSchema: z.object({
265
269
  comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
266
270
  confirmToken: confirmTokenParam,
@@ -287,6 +291,7 @@ export function registerCheckinTools(server, client) {
287
291
  title: 'Delete an Untappd check-in',
288
292
  description: `Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. ${CONFIRM_FLOW}`,
289
293
  annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
294
+ _meta: foodMeta('change'),
290
295
  inputSchema: z.object({
291
296
  checkin_id: CheckinIdSchema,
292
297
  confirmToken: confirmTokenParam,
@@ -322,6 +327,7 @@ export function registerCheckinTools(server, client) {
322
327
  `that will be posted. ${CONFIRM_FLOW}`,
323
328
  // Destructive: it publishes to your public feed, where friends see it; untappd_delete_checkin cannot unpublish what they saw.
324
329
  annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: true }),
330
+ _meta: foodMeta('change', { aliases: ['check in', 'log a beer', 'rate a beer', 'drinking'] }),
325
331
  inputSchema: z.object({
326
332
  bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
327
333
  rating: RatingSchema.optional().describe('Rating 0–5 in 0.25 increments (omit for no rating)'),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
4
  import { compactCheckins, UNTAPPD_VIEWS } from '../compact.js';
4
5
  export function registerDiscoverTools(server, client) {
@@ -7,6 +8,7 @@ export function registerDiscoverTools(server, client) {
7
8
  description: 'Get the beers trending on Untappd right now, split into macro (big/widely-available) and micro ' +
8
9
  '(craft/independent) lists. Read-only.',
9
10
  annotations: toolAnnotations({ title: 'Get trending beers', readOnly: true, idempotent: false, openWorld: true }),
11
+ _meta: foodMeta('lookup', { aliases: ['popular beers', 'trending beers'] }),
10
12
  inputSchema: z.object({}),
11
13
  }, async () => {
12
14
  const data = await client.get('/beer/trending');
@@ -17,6 +19,7 @@ export function registerDiscoverTools(server, client) {
17
19
  description: 'Get your Untappd notifications — toasts, comments, friend requests, and badges earned on YOUR account, ' +
18
20
  'plus news items. Read-only.',
19
21
  annotations: toolAnnotations({ title: 'Get your Untappd notifications', readOnly: true, idempotent: false, openWorld: true }),
22
+ _meta: foodMeta('whats-new', { aliases: ['toasts', 'badges', 'friend requests', 'beer notifications'] }),
20
23
  inputSchema: z.object({
21
24
  limit: z.number().int().min(1).max(50).optional().describe('Max notifications (1–50, default 25)'),
22
25
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
@@ -30,6 +33,7 @@ export function registerDiscoverTools(server, client) {
30
33
  description: 'Get recent check-ins near a location (lat/lng) — what people are drinking nearby right now. Optionally ' +
31
34
  'widen the search radius. Read-only.',
32
35
  annotations: toolAnnotations({ title: 'Get nearby Untappd check-ins', readOnly: true, idempotent: false, openWorld: true }),
36
+ _meta: foodMeta('lookup', { aliases: ['drinking nearby'] }),
33
37
  inputSchema: z.object({
34
38
  lat: z.number().min(-90).max(90).describe('Latitude of the location'),
35
39
  lng: z.number().min(-180).max(180).describe('Longitude of the location'),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
4
  import { FAVORITE_KINDS } from '../cache/store.js';
4
5
  import { DEFAULT_FAVORITE_THRESHOLDS, FAVORITE_SOURCES, listFavorites } from '../cache/favorites.js';
@@ -30,6 +31,7 @@ export function registerFavoriteTools(server, client, cacheProvider) {
30
31
  'has_had, has_had_many, has_visited, brewery_beers, venue_menu, top_not_had and trip_check results; a favorite brewery also boosts its ' +
31
32
  'beers in untappd_top_not_had. Omit username for your own account.',
32
33
  annotations: toolAnnotations({ title: 'Mark a beer, brewery or venue as a favorite (local)', readOnly: false, idempotent: true, openWorld: false, destructive: false }),
34
+ _meta: foodMeta('change'),
33
35
  inputSchema: z.object({
34
36
  username: UsernameArg,
35
37
  kind: KindArg,
@@ -49,6 +51,7 @@ export function registerFavoriteTools(server, client, cacheProvider) {
49
51
  'when there was none. A DERIVED favorite (from the user\'s history) cannot be removed — it is computed from ' +
50
52
  'the thresholds; change them in untappd_favorites_list instead. Omit username for your own account.',
51
53
  annotations: toolAnnotations({ title: 'Remove a manual favorite (local)', readOnly: false, idempotent: true, openWorld: false, destructive: false }),
54
+ _meta: foodMeta('change'),
52
55
  inputSchema: z.object({ username: UsernameArg, kind: KindArg, id: IdArg }),
53
56
  }, async ({ username, kind, id }) => {
54
57
  const user = resolveUser(username, client.loginName);
@@ -70,6 +73,7 @@ export function registerFavoriteTools(server, client, cacheProvider) {
70
73
  '(untappd_sync_user_venues, untappd_sync_user_beers); "untappd" ones as the last untappd_sync_brewery_likes ' +
71
74
  '(freshness.brewery_likes). Filter by kind and source. Omit username for your own account.',
72
75
  annotations: toolAnnotations({ title: "List a user's favorite beers, breweries and venues", readOnly: true, idempotent: true, openWorld: false }),
76
+ _meta: foodMeta('lookup'),
73
77
  inputSchema: z.object({
74
78
  username: UsernameArg,
75
79
  kind: z.enum(FAVORITE_KINDS).optional().describe('Only this kind (default all three)'),
@@ -1,19 +1,34 @@
1
1
  import { z } from 'zod';
2
- import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
2
+ import { minifiedResult, resolveView, toolAnnotations, viewResult } from '@chrischall/mcp-utils';
3
3
  import { compactCheckins, UNTAPPD_VIEWS } from '../compact.js';
4
+ import { briefCheckins, foodMeta } from './hints.js';
5
+ /**
6
+ * The feed's `view`: the fleet's compact/full rungs plus `brief`, the one-line-per-item
7
+ * answer a context-group host reads aloud (the feed is this server's canonical whats-new).
8
+ */
9
+ const FEED_VIEW = z
10
+ .enum(['compact', 'full', 'brief'])
11
+ .optional()
12
+ .describe('Response shape: "compact" (default) projects each check-in to {id, user, beer, brewery, venue, rating, comment, ' +
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>, inside the untrusted-data ' +
15
+ 'envelope as data: string[], with "-- " note lines ("-- none", "-- more").');
4
16
  export function registerFeedTools(server, client) {
5
17
  server.registerTool('untappd_activity_feed', {
6
18
  title: 'Get Untappd friend activity feed',
7
19
  description: 'Get your Untappd friend activity feed — the recent check-ins from people you follow, newest first. Page ' +
8
20
  'backwards with max_id (the pagination.max_id from a prior call). Read-only.',
9
21
  annotations: toolAnnotations({ title: 'Get Untappd friend activity feed', readOnly: true, idempotent: false, openWorld: true }),
22
+ _meta: foodMeta('whats-new', { canonical: true, aliases: ['beer', 'check-ins', 'friends drinking', 'what are my friends drinking', 'craft beer'] }),
10
23
  inputSchema: z.object({
11
24
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
12
25
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
13
- view: viewParam(UNTAPPD_VIEWS, { note: 'compact projects each check-in to {id, user, beer, brewery, venue, rating, comment, toast/comment counts}; "full" returns Untappd\'s whole ~5 KB record.' }),
26
+ view: FEED_VIEW,
14
27
  }),
15
28
  }, async ({ limit, max_id, view }) => {
16
29
  const data = await client.get('/checkin/recent', { limit, max_id });
30
+ if (view === 'brief')
31
+ return { content: [{ type: 'text', text: briefCheckins(data) }] };
17
32
  const v = resolveView(view, UNTAPPD_VIEWS);
18
33
  return viewResult(v, v === 'compact' ? compactCheckins(data) : data);
19
34
  });
@@ -22,6 +37,7 @@ export function registerFeedTools(server, client) {
22
37
  description: 'Get full detail for a single check-in by its id: the beer, rating, comment, photos, venue, badges earned, ' +
23
38
  'toasts, and comments. Get a check-in id from a feed or user-checkins result. Read-only.',
24
39
  annotations: toolAnnotations({ title: 'Get Untappd check-in detail', readOnly: true, idempotent: true, openWorld: true }),
40
+ _meta: foodMeta('lookup'),
25
41
  inputSchema: z.object({
26
42
  checkin_id: z.number().int().positive().describe('Untappd check-in id'),
27
43
  }),
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { confirmTokenParam, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
4
+ import { foodMeta } from './hints.js';
4
5
  // The endpoint PATHS below are confirmed from the Untappd app's own JS bundle
5
6
  // (`friend/request`, `friend/accept`, `friend/reject`, `friend/remove`). The
6
7
  // HTTP method (POST) mirrors the other captured action-writes (toast/comment),
@@ -20,6 +21,7 @@ const ACTIONS = [
20
21
  title: 'Send an Untappd friend request',
21
22
  verb: 'send a friend request to',
22
23
  detail: 'Sends a friend request to another user, who will be notified.',
24
+ intent: 'send',
23
25
  },
24
26
  {
25
27
  tool: 'untappd_accept_friend',
@@ -27,6 +29,7 @@ const ACTIONS = [
27
29
  title: 'Accept an Untappd friend request',
28
30
  verb: 'accept the incoming friend request from',
29
31
  detail: 'Accepts an incoming friend request (see untappd_pending_friends for pending uids).',
32
+ intent: 'change',
30
33
  },
31
34
  {
32
35
  tool: 'untappd_reject_friend',
@@ -34,6 +37,7 @@ const ACTIONS = [
34
37
  title: 'Reject an Untappd friend request',
35
38
  verb: 'reject the incoming friend request from',
36
39
  detail: 'Rejects/ignores an incoming friend request (see untappd_pending_friends for pending uids).',
40
+ intent: 'change',
37
41
  },
38
42
  {
39
43
  tool: 'untappd_remove_friend',
@@ -41,6 +45,7 @@ const ACTIONS = [
41
45
  title: 'Remove an Untappd friend',
42
46
  verb: 'remove your friendship with',
43
47
  detail: 'Removes an existing friend, or cancels a friend request you sent.',
48
+ intent: 'change',
44
49
  },
45
50
  ];
46
51
  export function registerFriendActionTools(server, client) {
@@ -55,6 +60,7 @@ export function registerFriendActionTools(server, client) {
55
60
  // un-sent, and a rejection cannot be un-rejected, so none of them has an
56
61
  // inverse in this tool set.
57
62
  annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true, destructive: true }),
63
+ _meta: foodMeta(action.intent, action.intent === 'send' ? { aliases: ['friend request', 'add a friend'] } : {}),
58
64
  inputSchema: z.object({
59
65
  target_uid: TargetUidSchema,
60
66
  confirmToken: confirmTokenParam,
@@ -0,0 +1,70 @@
1
+ import { UNTRUSTED_CONTENT_RULE, untrustedEnvelope } from '@chrischall/mcp-utils';
2
+ /** `_meta` for an Untappd tool: every tool here is in the `food` domain. */
3
+ export function foodMeta(intent, extra = {}) {
4
+ return { mint: { domain: 'food', intent, ...extra } };
5
+ }
6
+ const BRIEF_MAX = 120;
7
+ function str(v) {
8
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
9
+ }
10
+ function dict(v) {
11
+ return v && typeof v === 'object' ? v : {};
12
+ }
13
+ /** Untappd's RFC 2822 `created_at` as an ISO instant (to the second), or undefined when unparseable. */
14
+ function isoInstant(v) {
15
+ const s = str(v);
16
+ if (!s)
17
+ return undefined;
18
+ const t = Date.parse(s);
19
+ return Number.isNaN(t) ? undefined : new Date(t).toISOString().replace(/\.\d{3}Z$/, 'Z');
20
+ }
21
+ /**
22
+ * One check-in as one line, `<user>: <beer> (<brewery>) at <venue> — <ISO date | no date> — <rated N | not rated>`,
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".
27
+ */
28
+ export function briefCheckinLine(item) {
29
+ const c = dict(item);
30
+ const who = str(dict(c.user).user_name) ?? 'someone';
31
+ const beer = str(dict(c.beer).beer_name) ?? 'a beer';
32
+ const brewery = str(dict(c.brewery).brewery_name);
33
+ const venue = str(dict(c.venue).venue_name);
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;
37
+ const rating = typeof c.rating_score === 'number' && c.rating_score > 0 ? `rated ${c.rating_score}` : 'not rated';
38
+ const tail = ` — ${isoInstant(c.created_at) ?? 'no date'} — ${rating}`;
39
+ const room = BRIEF_MAX - tail.length;
40
+ const shown = title.length > room ? `${title.slice(0, Math.max(room - 1, 0))}…` : title;
41
+ return `${shown}${tail}`;
42
+ }
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
+ */
68
+ export function briefCheckins(resp) {
69
+ return JSON.stringify(untrustedEnvelope(briefCheckinLines(resp), { note: `${BRIEF_WHO_WROTE} ${UNTRUSTED_CONTENT_RULE}` }));
70
+ }
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
4
  import { menuPick, venuePick } from '../menu/pick.js';
4
5
  import { freshness, quotaFields, resolveUser, UsernameArg } from './cache.js';
@@ -47,6 +48,7 @@ export function registerMenuTools(server, client, cacheProvider, deps = {}) {
47
48
  'a false negative and another_run_needed is true. api_budget is also capped to the quota Untappd reports, ' +
48
49
  'when it reports one. Omit username for your own account.',
49
50
  annotations: toolAnnotations({ title: "Pick beers off a tap list that a user hasn't had", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
51
+ _meta: foodMeta('other', { aliases: ['what should i order', 'tap list', 'beer menu'] }),
50
52
  inputSchema: z.object({
51
53
  username: UsernameArg,
52
54
  items: z
@@ -84,6 +86,7 @@ export function registerMenuTools(server, client, cacheProvider, deps = {}) {
84
86
  '(menu_complete is false only if the budget ran out mid-menu). A venue with no verified menu comes back ' +
85
87
  'unresolved — use untappd_menu_pick with the tap list instead. Omit username for your own account.',
86
88
  annotations: toolAnnotations({ title: "Pick beers off a venue's Untappd menu that a user hasn't had", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
89
+ _meta: foodMeta('other', { aliases: ['what should i order', 'on tap'] }),
87
90
  inputSchema: z.object({
88
91
  venue: z
89
92
  .union([z.string().min(1), z.number().int().positive()])
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
4
  /**
4
5
  * Parse an untappd.com URL into its entity type and id. Path shapes (confirmed
@@ -49,6 +50,7 @@ export function registerResolveTools(server, client) {
49
50
  description: 'Parse an untappd.com URL (a beer /b/, brewery /w/, venue /v/, user /user/, or check-in link) into its ' +
50
51
  'entity type and id, and name the tool to call next. Pure local parsing — no network. Read-only.',
51
52
  annotations: toolAnnotations({ title: 'Resolve an Untappd URL', readOnly: true, idempotent: true, openWorld: false }),
53
+ _meta: foodMeta('lookup'),
52
54
  inputSchema: z.object({
53
55
  url: z.string().min(1).describe('An untappd.com URL to resolve'),
54
56
  }),
@@ -66,6 +68,7 @@ export function registerResolveTools(server, client) {
66
68
  description: 'Resolve an untappd.com URL AND fetch the entity detail in one call — the convenience combination of ' +
67
69
  'untappd_resolve + the matching info tool. Returns { resolved, detail }. Read-only.',
68
70
  annotations: toolAnnotations({ title: 'Open an Untappd URL', readOnly: true, idempotent: true, openWorld: true }),
71
+ _meta: foodMeta('lookup'),
69
72
  inputSchema: z.object({
70
73
  url: z.string().min(1).describe('An untappd.com URL to resolve and fetch'),
71
74
  }),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { RateLimitError, createHelpfulError, messageOf, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
3
4
  import { DEFAULT_VISIT_RADIUS_M, haversineM } from '../cache/store.js';
4
5
  import { BREWERY_BEERS_TTL_MS, getBreweryBeers } from '../cache/brewery-beers.js';
@@ -64,6 +65,7 @@ export function registerTripTools(server, client, cacheProvider) {
64
65
  idempotent: true,
65
66
  openWorld: true,
66
67
  }),
68
+ _meta: foodMeta('lookup', { aliases: ['brewery crawl', 'bar crawl', 'beer trip'] }),
67
69
  inputSchema: z.object({
68
70
  username: UsernameArg,
69
71
  usernames: z
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { createHelpfulError, minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
4
  import { compactCheckins, compactWishlist, compactUserBeers, UNTAPPD_VIEWS, upstreamCompact } from '../compact.js';
4
5
  const UsernameArg = z
@@ -43,6 +44,7 @@ export function registerUserTools(server, client) {
43
44
  description: "Get an Untappd user's profile: bio, location, total check-ins, distinct beers, badges, and stats. " +
44
45
  'Omit username for your own account. Read-only.',
45
46
  annotations: toolAnnotations({ title: 'Get Untappd user profile', readOnly: true, idempotent: true, openWorld: true }),
47
+ _meta: foodMeta('lookup'),
46
48
  inputSchema: z.object({
47
49
  username: UsernameArg,
48
50
  view: viewParam(UNTAPPD_VIEWS, { note: 'compact also asks Untappd for its own slim record, dropping the embedded activity/list blocks; \'full\' returns everything.' }),
@@ -59,6 +61,7 @@ export function registerUserTools(server, client) {
59
61
  'any other user Untappd returns just the ~50 most recent check-ins and ignores max_id — use untappd_user_beers ' +
60
62
  '(has had) or untappd_user_venues (has been) for their full history. Omit username for your own. Read-only.',
61
63
  annotations: toolAnnotations({ title: 'Get Untappd user check-ins', readOnly: true, idempotent: true, openWorld: true }),
64
+ _meta: foodMeta('lookup'),
62
65
  inputSchema: z.object({
63
66
  username: UsernameArg,
64
67
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
@@ -74,6 +77,7 @@ export function registerUserTools(server, client) {
74
77
  title: 'Get Untappd user wishlist',
75
78
  description: "Get the beers on a user's wishlist. Supports sorting and paging. Omit username for your own account. Read-only.",
76
79
  annotations: toolAnnotations({ title: 'Get Untappd user wishlist', readOnly: true, idempotent: true, openWorld: true }),
80
+ _meta: foodMeta('lookup', { aliases: ['wishlist', 'beers to try'] }),
77
81
  inputSchema: z.object({
78
82
  username: UsernameArg,
79
83
  limit: z.number().int().min(1).max(50).optional().describe('Max beers (1–50, default 25)'),
@@ -94,6 +98,7 @@ export function registerUserTools(server, client) {
94
98
  description: "Get the distinct (unique) beers a user has ever checked in, with their rating and check-in count per beer. " +
95
99
  'Supports sorting and paging. Omit username for your own account. Read-only.',
96
100
  annotations: toolAnnotations({ title: 'Get Untappd distinct beers', readOnly: true, idempotent: true, openWorld: true }),
101
+ _meta: foodMeta('lookup'),
97
102
  inputSchema: z.object({
98
103
  username: UsernameArg,
99
104
  limit: z.number().int().min(1).max(50).optional().describe('Max beers (1–50, default 25)'),
@@ -113,6 +118,7 @@ export function registerUserTools(server, client) {
113
118
  title: 'Get Untappd user badges',
114
119
  description: "Get the badges a user has earned, most recent first. Omit username for your own account. Read-only.",
115
120
  annotations: toolAnnotations({ title: 'Get Untappd user badges', readOnly: true, idempotent: true, openWorld: true }),
121
+ _meta: foodMeta('lookup'),
116
122
  inputSchema: z.object({
117
123
  username: UsernameArg,
118
124
  limit: z.number().int().min(1).max(50).optional().describe('Max badges (1–50, default 25)'),
@@ -126,6 +132,7 @@ export function registerUserTools(server, client) {
126
132
  title: 'Get Untappd user friends',
127
133
  description: "Get a user's friend list. Omit username for your own account. Read-only.",
128
134
  annotations: toolAnnotations({ title: 'Get Untappd user friends', readOnly: true, idempotent: true, openWorld: true }),
135
+ _meta: foodMeta('lookup'),
129
136
  inputSchema: z.object({
130
137
  username: UsernameArg,
131
138
  limit: z.number().int().min(1).max(50).optional().describe('Max friends (1–50, default 25)'),
@@ -142,6 +149,7 @@ export function registerUserTools(server, client) {
142
149
  'other users). Untappd returns at most 25 venues per page whatever limit you ask for — advance by the ' +
143
150
  "returned pagination offset. Omit username for your own account. Read-only.",
144
151
  annotations: toolAnnotations({ title: 'Get venues a user has checked in at', readOnly: true, idempotent: true, openWorld: true }),
152
+ _meta: foodMeta('lookup'),
145
153
  inputSchema: z.object({
146
154
  username: UsernameArg,
147
155
  limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50; Untappd caps a page at 25)'),
@@ -166,6 +174,7 @@ export function registerUserTools(server, client) {
166
174
  description: 'Get the incoming friend requests waiting on YOUR account — the users who have requested to be your ' +
167
175
  'friend. Read-only.',
168
176
  annotations: toolAnnotations({ title: 'Get your pending friend requests', readOnly: true, idempotent: true, openWorld: true }),
177
+ _meta: foodMeta('lookup'),
169
178
  inputSchema: z.object({
170
179
  limit: z.number().int().min(1).max(50).optional().describe('Max requests (1–50, default 25)'),
171
180
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
@@ -1,4 +1,5 @@
1
1
  import { z } from 'zod';
2
+ import { foodMeta } from './hints.js';
2
3
  import { minifiedResult, resolveView, toolAnnotations, viewParam, viewResult } from '@chrischall/mcp-utils';
3
4
  import { compactCheckins, UNTAPPD_VIEWS, upstreamCompact } from '../compact.js';
4
5
  import { walkVenueMenu } from '../menu/venue-menu.js';
@@ -19,6 +20,7 @@ export function registerVenueTools(server, client, cacheProvider) {
19
20
  description: 'Search Untappd for venues (bars, breweries, restaurants) by name. Returns matches with their venue id, ' +
20
21
  'category, and location. Feed a venue id into untappd_venue_info for full detail. Read-only.',
21
22
  annotations: toolAnnotations({ title: 'Search Untappd venues', readOnly: true, idempotent: true, openWorld: true }),
23
+ _meta: foodMeta('lookup', { aliases: ['bar', 'pub', 'taproom', 'brewpub'] }),
22
24
  inputSchema: z.object({
23
25
  query: z.string().min(1).describe('Venue name to search for'),
24
26
  limit: z.number().int().min(1).max(50).optional().describe('Max results (1–50, default 25)'),
@@ -32,6 +34,7 @@ export function registerVenueTools(server, client, cacheProvider) {
32
34
  description: 'Get full detail for a venue by its Untappd venue id: category, address, contact, rating, total check-ins, ' +
33
35
  'and — on view:"full" — top beers and recent activity. Get an id from untappd_search_venue. Read-only.',
34
36
  annotations: toolAnnotations({ title: 'Get Untappd venue detail', readOnly: true, idempotent: true, openWorld: true }),
37
+ _meta: foodMeta('lookup'),
35
38
  inputSchema: z.object({
36
39
  venue_id: z.number().int().positive().describe('Untappd venue id'),
37
40
  view: viewParam(UNTAPPD_VIEWS, { note: 'compact also asks Untappd for its own slim record, dropping the embedded activity/list blocks; "full" returns everything.' }),
@@ -58,6 +61,7 @@ export function registerVenueTools(server, client, cacheProvider) {
58
61
  'with untappd_favorite_add; default your account) — applied on read, so never stale. Get an id from ' +
59
62
  'untappd_search_venue. Read-only.',
60
63
  annotations: toolAnnotations({ title: "Get a venue's verified beer menu (section-paged)", readOnly: true, idempotent: true, openWorld: true }),
64
+ _meta: foodMeta('lookup', { aliases: ['on tap', 'tap list', 'beer menu'] }),
61
65
  inputSchema: z.object({
62
66
  venue_id: z.number().int().positive().describe('Untappd venue id'),
63
67
  menu_id: z.number().int().positive().optional().describe('Restrict to a single menu id (from a prior result). Optional.'),
@@ -137,6 +141,7 @@ export function registerVenueTools(server, client, cacheProvider) {
137
141
  description: 'Resolve a Foursquare venue id to its Untappd venue. Useful to turn a foursquare_id (e.g. from a check-in) ' +
138
142
  'into an Untappd venue you can pass to untappd_venue_info / untappd_venue_activity. Read-only.',
139
143
  annotations: toolAnnotations({ title: 'Look up an Untappd venue by Foursquare id', readOnly: true, idempotent: true, openWorld: true }),
144
+ _meta: foodMeta('lookup'),
140
145
  inputSchema: z.object({
141
146
  foursquare_id: z.string().min(1).describe('Foursquare venue id'),
142
147
  }),
@@ -150,6 +155,7 @@ export function registerVenueTools(server, client, cacheProvider) {
150
155
  'Page backwards with max_id (the pagination.max_id from a prior call). Cached locally for ' + TTL_HOURS + ' hours ' +
151
156
  'per venue + limit + max_id (cache.hit, fetched_at, expires_at); refresh:true re-fetches. Read-only.',
152
157
  annotations: toolAnnotations({ title: 'Get recent check-ins at a venue', readOnly: true, idempotent: false, openWorld: true }),
158
+ _meta: foodMeta('lookup'),
153
159
  inputSchema: z.object({
154
160
  venue_id: z.number().int().positive().describe('Untappd venue id'),
155
161
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),