untappd-mcp 2.5.1 → 2.6.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/dist/cache/db.js CHANGED
@@ -47,7 +47,9 @@ class NodeSqlDriver {
47
47
  // IMMEDIATE takes the write lock up front. A DEFERRED transaction that
48
48
  // reads and then writes can hit SQLITE_BUSY on the lock upgrade, which
49
49
  // busy_timeout does NOT retry — the failure mode of two hosts open at once.
50
- this.db.exec('BEGIN IMMEDIATE');
50
+ // BEGIN itself can still get a handler-less SQLITE_BUSY (e.g. while another
51
+ // process recovers the WAL), so it is retried too.
52
+ retryBusy(() => this.db.exec('BEGIN IMMEDIATE'));
51
53
  try {
52
54
  fn();
53
55
  this.db.exec('COMMIT');
@@ -78,23 +80,36 @@ export class CheckinCache extends LocalCacheStore {
78
80
  if (path !== ':memory:')
79
81
  preparePrivateFile(path);
80
82
  const db = new DatabaseSync(path);
81
- // Set before the schema/migration transaction so even that waits out a
82
- // second process instead of failing. WAL lets readers and one writer run
83
- // concurrently (two hosts open at once); NORMAL sync is the WAL-safe default.
84
- db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
85
- if (path !== ':memory:') {
86
- db.exec('PRAGMA journal_mode = WAL');
87
- db.exec('PRAGMA synchronous = NORMAL');
83
+ try {
84
+ // Set before the schema/migration transaction so even that waits out a
85
+ // second process instead of failing. WAL lets readers and one writer run
86
+ // concurrently (two hosts open at once); NORMAL sync is the WAL-safe default.
87
+ db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
88
+ if (path !== ':memory:') {
89
+ // Switching a fresh file to WAL needs an exclusive lock, and when another
90
+ // process is opening it too SQLite answers SQLITE_BUSY WITHOUT calling
91
+ // the busy handler (#256) — so busy_timeout never applies and this has
92
+ // to retry itself. Once the file is WAL (the switch is persistent) a
93
+ // later open skips the switch entirely.
94
+ retryBusy(() => {
95
+ if (journalModeOf(db) !== 'wal')
96
+ db.exec('PRAGMA journal_mode = WAL');
97
+ });
98
+ db.exec('PRAGMA synchronous = NORMAL');
99
+ }
100
+ const cache = new CheckinCache(db, path);
101
+ if (path !== ':memory:')
102
+ tightenSidecars(path);
103
+ return cache;
104
+ }
105
+ catch (e) {
106
+ db.close();
107
+ throw e;
88
108
  }
89
- const cache = new CheckinCache(db, path);
90
- if (path !== ':memory:')
91
- tightenSidecars(path);
92
- return cache;
93
109
  }
94
110
  /** The SQLite journal mode actually in effect (`wal` for a file, `memory` for :memory:). */
95
111
  journalMode() {
96
- const row = this.db.prepare('PRAGMA journal_mode').get();
97
- return String(row?.journal_mode ?? 'unknown');
112
+ return journalModeOf(this.db);
98
113
  }
99
114
  /** On-disk footprint: the db file plus its -wal/-shm side files. */
100
115
  fileInfo() {
@@ -152,6 +167,39 @@ function bestEffortChmod(path, mode) {
152
167
  }
153
168
  // How long a write waits for another process's lock before failing.
154
169
  const BUSY_TIMEOUT_MS = 5000;
170
+ function journalModeOf(db) {
171
+ const row = db.prepare('PRAGMA journal_mode').get();
172
+ return String(row?.journal_mode ?? 'unknown');
173
+ }
174
+ /** SQLITE_BUSY / SQLITE_LOCKED, including their extended codes (e.g. BUSY_RECOVERY). */
175
+ function isBusy(e) {
176
+ const code = e?.errcode;
177
+ return typeof code === 'number' && ((code & 0xff) === 5 || (code & 0xff) === 6);
178
+ }
179
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
180
+ /**
181
+ * Run `fn`, retrying a SQLITE_BUSY that SQLite raised without consulting the
182
+ * busy handler, until {@link BUSY_TIMEOUT_MS} after the FIRST attempt. A BUSY
183
+ * that already waited out busy_timeout lands past that deadline and is thrown
184
+ * as-is, so the total wait never exceeds busy_timeout's. The driver is
185
+ * synchronous, so the backoff blocks (Atomics.wait) like busy_timeout does.
186
+ */
187
+ export function retryBusy(fn, timeoutMs = BUSY_TIMEOUT_MS) {
188
+ const deadline = Date.now() + timeoutMs;
189
+ for (let attempt = 0;; attempt++) {
190
+ try {
191
+ return fn();
192
+ }
193
+ catch (e) {
194
+ const left = deadline - Date.now();
195
+ if (!isBusy(e) || left <= 0)
196
+ throw e;
197
+ // 5, 10, 20 … ms, capped at 100, plus jitter so colliding opens spread out.
198
+ const wait = Math.min(100, 5 * 2 ** Math.min(attempt, 5)) + Math.random() * 5;
199
+ Atomics.wait(sleepCell, 0, 0, Math.min(wait, left));
200
+ }
201
+ }
202
+ }
155
203
  /**
156
204
  * The on-disk cache path, always ABSOLUTE: `$UNTAPPD_CACHE_DB` (a file), else
157
205
  * `$UNTAPPD_CACHE_DIR/checkins.db`, else `~/.untappd-mcp/checkins.db`.
@@ -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,33 @@
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>.');
4
15
  export function registerFeedTools(server, client) {
5
16
  server.registerTool('untappd_activity_feed', {
6
17
  title: 'Get Untappd friend activity feed',
7
18
  description: 'Get your Untappd friend activity feed — the recent check-ins from people you follow, newest first. Page ' +
8
19
  'backwards with max_id (the pagination.max_id from a prior call). Read-only.',
9
20
  annotations: toolAnnotations({ title: 'Get Untappd friend activity feed', readOnly: true, idempotent: false, openWorld: true }),
21
+ _meta: foodMeta('whats-new', { canonical: true, aliases: ['beer', 'check-ins', 'friends drinking', 'what are my friends drinking', 'craft beer'] }),
10
22
  inputSchema: z.object({
11
23
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
12
24
  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.' }),
25
+ view: FEED_VIEW,
14
26
  }),
15
27
  }, async ({ limit, max_id, view }) => {
16
28
  const data = await client.get('/checkin/recent', { limit, max_id });
29
+ if (view === 'brief')
30
+ return { content: [{ type: 'text', text: briefCheckins(data) }] };
17
31
  const v = resolveView(view, UNTAPPD_VIEWS);
18
32
  return viewResult(v, v === 'compact' ? compactCheckins(data) : data);
19
33
  });
@@ -22,6 +36,7 @@ export function registerFeedTools(server, client) {
22
36
  description: 'Get full detail for a single check-in by its id: the beer, rating, comment, photos, venue, badges earned, ' +
23
37
  'toasts, and comments. Get a check-in id from a feed or user-checkins result. Read-only.',
24
38
  annotations: toolAnnotations({ title: 'Get Untappd check-in detail', readOnly: true, idempotent: true, openWorld: true }),
39
+ _meta: foodMeta('lookup'),
25
40
  inputSchema: z.object({
26
41
  checkin_id: z.number().int().positive().describe('Untappd check-in id'),
27
42
  }),
@@ -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,42 @@
1
+ /** `_meta` for an Untappd tool: every tool here is in the `food` domain. */
2
+ export function foodMeta(intent, extra = {}) {
3
+ return { mint: { domain: 'food', intent, ...extra } };
4
+ }
5
+ const BRIEF_MAX = 120;
6
+ function str(v) {
7
+ return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
8
+ }
9
+ function dict(v) {
10
+ return v && typeof v === 'object' ? v : {};
11
+ }
12
+ /** Untappd's RFC 2822 `created_at` as an ISO instant (to the second), or undefined when unparseable. */
13
+ function isoInstant(v) {
14
+ const s = str(v);
15
+ if (!s)
16
+ return undefined;
17
+ const t = Date.parse(s);
18
+ return Number.isNaN(t) ? undefined : new Date(t).toISOString().replace(/\.\d{3}Z$/, 'Z');
19
+ }
20
+ /**
21
+ * 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".
24
+ */
25
+ export function briefCheckinLine(item) {
26
+ const c = dict(item);
27
+ const who = str(dict(c.user).user_name) ?? 'someone';
28
+ const beer = str(dict(c.beer).beer_name) ?? 'a beer';
29
+ const brewery = str(dict(c.brewery).brewery_name);
30
+ const venue = str(dict(c.venue).venue_name);
31
+ const title = `${who}: ${beer}${brewery ? ` (${brewery})` : ''}${venue ? ` at ${venue}` : ''}`;
32
+ const rating = typeof c.rating_score === 'number' && c.rating_score > 0 ? `rated ${c.rating_score}` : 'not rated';
33
+ const tail = ` — ${isoInstant(c.created_at) ?? 'no date'} — ${rating}`;
34
+ const room = BRIEF_MAX - tail.length;
35
+ const shown = title.length > room ? `${title.slice(0, Math.max(room - 1, 0))}…` : title;
36
+ return `${shown}${tail}`;
37
+ }
38
+ /** A `{ checkins: { items } }` response as brief lines, one per check-in (none for an empty or unexpected page). */
39
+ export function briefCheckins(resp) {
40
+ const items = dict(dict(resp).checkins).items;
41
+ return Array.isArray(items) ? items.map(briefCheckinLine).join('\n') : '';
42
+ }
@@ -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