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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bundle.js +276 -151
- package/dist/cache/db.js +62 -14
- package/dist/tools/beer.js +4 -0
- package/dist/tools/brewery.js +4 -0
- package/dist/tools/cache.js +15 -0
- package/dist/tools/checkin.js +6 -0
- package/dist/tools/discover.js +4 -0
- package/dist/tools/favorites.js +4 -0
- package/dist/tools/feed.js +17 -2
- package/dist/tools/friends.js +6 -0
- package/dist/tools/hints.js +42 -0
- package/dist/tools/menu.js +3 -0
- package/dist/tools/resolve.js +3 -0
- package/dist/tools/trip.js +2 -0
- package/dist/tools/user.js +9 -0
- package/dist/tools/venue.js +6 -0
- package/dist/tools/wishlist.js +3 -0
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
- package/skills/untappd-mcp/SKILL.md +28 -0
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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`.
|
package/dist/tools/beer.js
CHANGED
|
@@ -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)'),
|
package/dist/tools/brewery.js
CHANGED
|
@@ -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)'),
|
package/dist/tools/cache.js
CHANGED
|
@@ -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,
|
package/dist/tools/checkin.js
CHANGED
|
@@ -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)'),
|
package/dist/tools/discover.js
CHANGED
|
@@ -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'),
|
package/dist/tools/favorites.js
CHANGED
|
@@ -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)'),
|
package/dist/tools/feed.js
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { minifiedResult, resolveView, toolAnnotations,
|
|
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:
|
|
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
|
}),
|
package/dist/tools/friends.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/tools/menu.js
CHANGED
|
@@ -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()])
|
package/dist/tools/resolve.js
CHANGED
|
@@ -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
|
}),
|
package/dist/tools/trip.js
CHANGED
|
@@ -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
|