untappd-mcp 1.0.0 → 1.1.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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Untappd — beers, breweries, venues, check-ins, wishlists, and your friend feed",
10
- "version": "1.0.0"
10
+ "version": "1.1.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Untappd",
16
16
  "source": "./",
17
17
  "description": "MCP server for Untappd — search beers/breweries/venues, read profiles/check-ins/wishlists, and post check-ins, toasts, and comments",
18
- "version": "1.0.0",
18
+ "version": "1.1.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
3
  "displayName": "Untappd",
4
- "version": "1.0.0",
4
+ "version": "1.1.0",
5
5
  "description": "MCP server for Untappd — search beers/breweries/venues, read check-ins and wishlists, and post check-ins, toasts, and comments",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -57,13 +57,14 @@ Reads: `untappd_search_beer`, `untappd_beer_info`, `untappd_beer_activity`,
57
57
  `untappd_search_brewery`, `untappd_brewery_info`, `untappd_brewery_beers`,
58
58
  `untappd_search_venue`, `untappd_venue_info`, `untappd_venue_activity`,
59
59
  `untappd_user_info`, `untappd_user_checkins`, `untappd_user_wishlist`,
60
- `untappd_user_beers`, `untappd_user_badges`, `untappd_user_friends`,
61
- `untappd_activity_feed`, `untappd_checkin_info`, `untappd_trending`,
60
+ `untappd_user_beers`, `untappd_user_badges`, `untappd_user_friends`, `untappd_pending_friends`,
61
+ `untappd_activity_feed`, `untappd_checkin_info`, `untappd_resolve`, `untappd_open_url`, `untappd_user_venues`, `untappd_venue_by_foursquare`, `untappd_trending`,
62
62
  `untappd_notifications`, `untappd_local_checkins`, `untappd_healthcheck`.
63
63
 
64
64
  Writes (confirm-gated — return a dry-run preview unless called with
65
65
  `confirm: true`): `untappd_toast`, `untappd_add_comment`, `untappd_delete_comment`, `untappd_checkin`,
66
- `untappd_wishlist_add`, `untappd_wishlist_remove`, `untappd_delete_checkin`.
66
+ `untappd_wishlist_add`, `untappd_wishlist_remove`, `untappd_delete_checkin`,
67
+ `untappd_add_friend`, `untappd_accept_friend`, `untappd_reject_friend`, `untappd_remove_friend`.
67
68
 
68
69
  ## Development
69
70
 
package/dist/bundle.js CHANGED
@@ -2220,20 +2220,20 @@ var require_resolve = __commonJS({
2220
2220
  return false;
2221
2221
  }
2222
2222
  function countKeys(schema) {
2223
- let count = 0;
2223
+ let count2 = 0;
2224
2224
  for (const key in schema) {
2225
2225
  if (key === "$ref")
2226
2226
  return Infinity;
2227
- count++;
2227
+ count2++;
2228
2228
  if (SIMPLE_INLINED.has(key))
2229
2229
  continue;
2230
2230
  if (typeof schema[key] == "object") {
2231
- (0, util_1.eachItem)(schema[key], (sch) => count += countKeys(sch));
2231
+ (0, util_1.eachItem)(schema[key], (sch) => count2 += countKeys(sch));
2232
2232
  }
2233
- if (count === Infinity)
2233
+ if (count2 === Infinity)
2234
2234
  return Infinity;
2235
2235
  }
2236
- return count;
2236
+ return count2;
2237
2237
  }
2238
2238
  function getFullPath(resolver, id = "", normalize) {
2239
2239
  if (normalize !== false)
@@ -5405,8 +5405,8 @@ var require_contains = __commonJS({
5405
5405
  cxt.result(valid, () => cxt.reset());
5406
5406
  function validateItemsWithCount() {
5407
5407
  const schValid = gen.name("_valid");
5408
- const count = gen.let("count", 0);
5409
- validateItems(schValid, () => gen.if(schValid, () => checkLimits(count)));
5408
+ const count2 = gen.let("count", 0);
5409
+ validateItems(schValid, () => gen.if(schValid, () => checkLimits(count2)));
5410
5410
  }
5411
5411
  function validateItems(_valid, block) {
5412
5412
  gen.forRange("i", 0, len, (i) => {
@@ -5419,16 +5419,16 @@ var require_contains = __commonJS({
5419
5419
  block();
5420
5420
  });
5421
5421
  }
5422
- function checkLimits(count) {
5423
- gen.code((0, codegen_1._)`${count}++`);
5422
+ function checkLimits(count2) {
5423
+ gen.code((0, codegen_1._)`${count2}++`);
5424
5424
  if (max === void 0) {
5425
- gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true).break());
5425
+ gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true).break());
5426
5426
  } else {
5427
- gen.if((0, codegen_1._)`${count} > ${max}`, () => gen.assign(valid, false).break());
5427
+ gen.if((0, codegen_1._)`${count2} > ${max}`, () => gen.assign(valid, false).break());
5428
5428
  if (min === 1)
5429
5429
  gen.assign(valid, true);
5430
5430
  else
5431
- gen.if((0, codegen_1._)`${count} >= ${min}`, () => gen.assign(valid, true));
5431
+ gen.if((0, codegen_1._)`${count2} >= ${min}`, () => gen.assign(valid, true));
5432
5432
  }
5433
5433
  }
5434
5434
  }
@@ -15158,8 +15158,8 @@ function az_default() {
15158
15158
  }
15159
15159
 
15160
15160
  // node_modules/zod/v4/locales/be.js
15161
- function getBelarusianPlural(count, one, few, many) {
15162
- const absCount = Math.abs(count);
15161
+ function getBelarusianPlural(count2, one, few, many) {
15162
+ const absCount = Math.abs(count2);
15163
15163
  const lastDigit = absCount % 10;
15164
15164
  const lastTwoDigits = absCount % 100;
15165
15165
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -17237,8 +17237,8 @@ function hu_default() {
17237
17237
  }
17238
17238
 
17239
17239
  // node_modules/zod/v4/locales/hy.js
17240
- function getArmenianPlural(count, one, many) {
17241
- return Math.abs(count) === 1 ? one : many;
17240
+ function getArmenianPlural(count2, one, many) {
17241
+ return Math.abs(count2) === 1 ? one : many;
17242
17242
  }
17243
17243
  function withDefiniteArticle(word) {
17244
17244
  if (!word)
@@ -19366,8 +19366,8 @@ function ro_default() {
19366
19366
  }
19367
19367
 
19368
19368
  // node_modules/zod/v4/locales/ru.js
19369
- function getRussianPlural(count, one, few, many) {
19370
- const absCount = Math.abs(count);
19369
+ function getRussianPlural(count2, one, few, many) {
19370
+ const absCount = Math.abs(count2);
19371
19371
  const lastDigit = absCount % 10;
19372
19372
  const lastTwoDigits = absCount % 100;
19373
19373
  if (lastTwoDigits >= 11 && lastTwoDigits <= 19) {
@@ -31184,7 +31184,7 @@ function toolAnnotations(opts = {}) {
31184
31184
  }
31185
31185
 
31186
31186
  // src/version.ts
31187
- var VERSION = "1.0.0";
31187
+ var VERSION = "1.1.0";
31188
31188
 
31189
31189
  // src/client.ts
31190
31190
  import { dirname, join } from "path";
@@ -31422,8 +31422,84 @@ var UntappdClient = class {
31422
31422
  };
31423
31423
  var client = new UntappdClient();
31424
31424
 
31425
+ // src/compact.ts
31426
+ function asDict(v) {
31427
+ return v && typeof v === "object" ? v : void 0;
31428
+ }
31429
+ function count(v) {
31430
+ const d = asDict(v);
31431
+ if (!d) return void 0;
31432
+ return d.count ?? d.total_count;
31433
+ }
31434
+ function compactCheckin(item) {
31435
+ const c = asDict(item) ?? {};
31436
+ const beer = asDict(c.beer);
31437
+ const brewery = asDict(c.brewery);
31438
+ const venue = asDict(c.venue);
31439
+ const user = asDict(c.user);
31440
+ return {
31441
+ checkin_id: c.checkin_id,
31442
+ created_at: c.created_at,
31443
+ user: user?.user_name,
31444
+ rating: c.rating_score,
31445
+ comment: c.checkin_comment || void 0,
31446
+ beer: beer && { bid: beer.bid, name: beer.beer_name, style: beer.beer_style, abv: beer.beer_abv },
31447
+ brewery: brewery?.brewery_name,
31448
+ venue: venue?.venue_name || void 0,
31449
+ toasts: count(c.toasts),
31450
+ comments: count(c.comments),
31451
+ has_photo: (count(c.media) ?? 0) > 0
31452
+ };
31453
+ }
31454
+ function beerCore(item) {
31455
+ const beer = asDict(item.beer) ?? {};
31456
+ const brewery = asDict(item.brewery);
31457
+ return {
31458
+ bid: beer.bid,
31459
+ name: beer.beer_name,
31460
+ style: beer.beer_style,
31461
+ abv: beer.beer_abv,
31462
+ ibu: beer.beer_ibu,
31463
+ brewery: brewery?.brewery_name
31464
+ };
31465
+ }
31466
+ function compactBeerResult(item) {
31467
+ const i = asDict(item) ?? {};
31468
+ return { ...beerCore(i), checkin_count: i.checkin_count, have_had: i.have_had };
31469
+ }
31470
+ function compactWishlistBeer(item) {
31471
+ const i = asDict(item) ?? {};
31472
+ return { ...beerCore(i), added_at: i.created_at };
31473
+ }
31474
+ function compactUserBeer(item) {
31475
+ const i = asDict(item) ?? {};
31476
+ return {
31477
+ ...beerCore(i),
31478
+ your_count: i.count,
31479
+ your_rating: i.user_auth_rating_score || void 0,
31480
+ global_rating: i.rating_score || void 0,
31481
+ last_had: i.recent_created_at
31482
+ };
31483
+ }
31484
+ function projectItems(resp, container, mapFn) {
31485
+ const r = asDict(resp);
31486
+ const box = asDict(r?.[container]);
31487
+ const items = box?.items;
31488
+ if (!r || !box || !Array.isArray(items)) {
31489
+ process.stderr.write(`[untappd-mcp] compact: expected ${container}.items array; returning raw response
31490
+ `);
31491
+ return resp;
31492
+ }
31493
+ return { ...r, [container]: { ...box, items: items.map(mapFn) } };
31494
+ }
31495
+ var compactCheckins = (resp) => projectItems(resp, "checkins", compactCheckin);
31496
+ var compactBeerSearch = (resp) => projectItems(resp, "beers", compactBeerResult);
31497
+ var compactWishlist = (resp) => projectItems(resp, "beers", compactWishlistBeer);
31498
+ var compactUserBeers = (resp) => projectItems(resp, "beers", compactUserBeer);
31499
+
31425
31500
  // src/tools/beer.ts
31426
31501
  var BidSchema = external_exports.number().int().positive().describe("Untappd beer id (bid)");
31502
+ var CompactCheckins = external_exports.boolean().optional().describe("Project each check-in to a slim summary (id, user, beer, rating, comment, venue, toast/comment counts) to save context (default false)");
31427
31503
  function registerBeerTools(server) {
31428
31504
  server.registerTool(
31429
31505
  "untappd_search_beer",
@@ -31435,12 +31511,13 @@ function registerBeerTools(server) {
31435
31511
  query: external_exports.string().min(1).describe("Beer name to search for"),
31436
31512
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max results (1\u201350, default 25)"),
31437
31513
  offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)"),
31438
- sort: external_exports.enum(["checkin", "name", "count"]).optional().describe("Sort order: checkin (relevance, default), name, or count")
31514
+ sort: external_exports.enum(["checkin", "name", "count"]).optional().describe("Sort order: checkin (relevance, default), name, or count"),
31515
+ compact: external_exports.boolean().optional().describe("Project each result to a slim summary (bid, name, brewery, style, abv, ibu, counts) to save context (default false)")
31439
31516
  }
31440
31517
  },
31441
- async ({ query, limit, offset, sort }) => {
31518
+ async ({ query, limit, offset, sort, compact }) => {
31442
31519
  const data = await client.get("/search/beer", { q: query, limit, offset, sort });
31443
- return textResult(data);
31520
+ return textResult(compact ? compactBeerSearch(data) : data);
31444
31521
  }
31445
31522
  );
31446
31523
  server.registerTool(
@@ -31468,12 +31545,13 @@ function registerBeerTools(server) {
31468
31545
  inputSchema: {
31469
31546
  bid: BidSchema,
31470
31547
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max check-ins (1\u201350, default 25)"),
31471
- max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)")
31548
+ max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)"),
31549
+ compact: CompactCheckins
31472
31550
  }
31473
31551
  },
31474
- async ({ bid, limit, max_id }) => {
31552
+ async ({ bid, limit, max_id, compact }) => {
31475
31553
  const data = await client.get(`/beer/checkins/${bid}`, { limit, max_id });
31476
- return textResult(data);
31554
+ return textResult(compact ? compactCheckins(data) : data);
31477
31555
  }
31478
31556
  );
31479
31557
  }
@@ -31567,6 +31645,21 @@ function registerVenueTools(server) {
31567
31645
  return textResult(data);
31568
31646
  }
31569
31647
  );
31648
+ server.registerTool(
31649
+ "untappd_venue_by_foursquare",
31650
+ {
31651
+ title: "Look up an Untappd venue by Foursquare id",
31652
+ description: "Resolve a Foursquare venue id to its Untappd venue. Useful to turn a foursquare_id (e.g. from a check-in) into an Untappd venue you can pass to untappd_venue_info / untappd_venue_activity. Read-only.",
31653
+ annotations: toolAnnotations({ title: "Look up an Untappd venue by Foursquare id", readOnly: true, idempotent: true, openWorld: true }),
31654
+ inputSchema: {
31655
+ foursquare_id: external_exports.string().min(1).describe("Foursquare venue id")
31656
+ }
31657
+ },
31658
+ async ({ foursquare_id }) => {
31659
+ const data = await client.get(`/venue/foursquare_lookup/${encodeURIComponent(foursquare_id)}`);
31660
+ return textResult(data);
31661
+ }
31662
+ );
31570
31663
  server.registerTool(
31571
31664
  "untappd_venue_activity",
31572
31665
  {
@@ -31576,12 +31669,13 @@ function registerVenueTools(server) {
31576
31669
  inputSchema: {
31577
31670
  venue_id: external_exports.number().int().positive().describe("Untappd venue id"),
31578
31671
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max check-ins (1\u201350, default 25)"),
31579
- max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)")
31672
+ max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)"),
31673
+ compact: external_exports.boolean().optional().describe("Project each check-in to a slim summary to save context (default false)")
31580
31674
  }
31581
31675
  },
31582
- async ({ venue_id, limit, max_id }) => {
31676
+ async ({ venue_id, limit, max_id, compact }) => {
31583
31677
  const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
31584
- return textResult(data);
31678
+ return textResult(compact ? compactCheckins(data) : data);
31585
31679
  }
31586
31680
  );
31587
31681
  }
@@ -31623,12 +31717,13 @@ function registerUserTools(server) {
31623
31717
  inputSchema: {
31624
31718
  username: UsernameArg,
31625
31719
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max check-ins (1\u201350, default 25)"),
31626
- max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)")
31720
+ max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)"),
31721
+ compact: external_exports.boolean().optional().describe("Project each check-in to a slim summary to save context (default false)")
31627
31722
  }
31628
31723
  },
31629
- async ({ username, limit, max_id }) => {
31724
+ async ({ username, limit, max_id, compact }) => {
31630
31725
  const data = await client.get(`/user/checkins/${resolveUser(username)}`, { limit, max_id });
31631
- return textResult(data);
31726
+ return textResult(compact ? compactCheckins(data) : data);
31632
31727
  }
31633
31728
  );
31634
31729
  server.registerTool(
@@ -31641,12 +31736,13 @@ function registerUserTools(server) {
31641
31736
  username: UsernameArg,
31642
31737
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max beers (1\u201350, default 25)"),
31643
31738
  offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)"),
31644
- sort: external_exports.enum(["date", "name", "brewery", "style", "rating", "abv"]).optional().describe("Sort order (default date added, newest first)")
31739
+ sort: external_exports.enum(["date", "name", "brewery", "style", "rating", "abv"]).optional().describe("Sort order (default date added, newest first)"),
31740
+ compact: external_exports.boolean().optional().describe("Project each beer to a slim summary (bid, name, brewery, style, abv, added_at) to save context (default false)")
31645
31741
  }
31646
31742
  },
31647
- async ({ username, limit, offset, sort }) => {
31743
+ async ({ username, limit, offset, sort, compact }) => {
31648
31744
  const data = await client.get(`/user/wishlist/${resolveUser(username)}`, { limit, offset, sort });
31649
- return textResult(data);
31745
+ return textResult(compact ? compactWishlist(data) : data);
31650
31746
  }
31651
31747
  );
31652
31748
  server.registerTool(
@@ -31659,12 +31755,13 @@ function registerUserTools(server) {
31659
31755
  username: UsernameArg,
31660
31756
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max beers (1\u201350, default 25)"),
31661
31757
  offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)"),
31662
- sort: external_exports.enum(["date", "checkin", "highest_rated", "lowest_rated", "name", "this_month", "highest_abv"]).optional().describe("Sort order (default date, most recent first)")
31758
+ sort: external_exports.enum(["date", "checkin", "highest_rated", "lowest_rated", "name", "this_month", "highest_abv"]).optional().describe("Sort order (default date, most recent first)"),
31759
+ compact: external_exports.boolean().optional().describe("Project each beer to a slim summary (bid, name, brewery, style, abv, ibu, your_count, your_rating, global_rating, last_had) to save context (default false)")
31663
31760
  }
31664
31761
  },
31665
- async ({ username, limit, offset, sort }) => {
31762
+ async ({ username, limit, offset, sort, compact }) => {
31666
31763
  const data = await client.get(`/user/beers/${resolveUser(username)}`, { limit, offset, sort });
31667
- return textResult(data);
31764
+ return textResult(compact ? compactUserBeers(data) : data);
31668
31765
  }
31669
31766
  );
31670
31767
  server.registerTool(
@@ -31701,6 +31798,40 @@ function registerUserTools(server) {
31701
31798
  return textResult(data);
31702
31799
  }
31703
31800
  );
31801
+ server.registerTool(
31802
+ "untappd_user_venues",
31803
+ {
31804
+ title: "Get venues a user has checked in at",
31805
+ description: "Get the venues a user has checked in at, most recent first, with per-venue check-in counts. Supports sorting and paging. Omit username for your own account. Read-only.",
31806
+ annotations: toolAnnotations({ title: "Get venues a user has checked in at", readOnly: true, idempotent: true, openWorld: true }),
31807
+ inputSchema: {
31808
+ username: UsernameArg,
31809
+ limit: external_exports.number().int().min(1).max(50).optional().describe("Max venues (1\u201350, default 25)"),
31810
+ offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)"),
31811
+ sort: external_exports.enum(["date", "name", "checkin", "highest_rated"]).optional().describe("Sort order (default most recent)")
31812
+ }
31813
+ },
31814
+ async ({ username, limit, offset, sort }) => {
31815
+ const data = await client.get(`/user/venues/${resolveUser(username)}`, { limit, offset, sort });
31816
+ return textResult(data);
31817
+ }
31818
+ );
31819
+ server.registerTool(
31820
+ "untappd_pending_friends",
31821
+ {
31822
+ title: "Get your pending friend requests",
31823
+ description: "Get the incoming friend requests waiting on YOUR account \u2014 the users who have requested to be your friend. Read-only.",
31824
+ annotations: toolAnnotations({ title: "Get your pending friend requests", readOnly: true, idempotent: true, openWorld: true }),
31825
+ inputSchema: {
31826
+ limit: external_exports.number().int().min(1).max(50).optional().describe("Max requests (1\u201350, default 25)"),
31827
+ offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)")
31828
+ }
31829
+ },
31830
+ async ({ limit, offset }) => {
31831
+ const data = await client.get("/user/pending", { limit, offset });
31832
+ return textResult(data);
31833
+ }
31834
+ );
31704
31835
  }
31705
31836
 
31706
31837
  // src/tools/feed.ts
@@ -31713,12 +31844,13 @@ function registerFeedTools(server) {
31713
31844
  annotations: toolAnnotations({ title: "Get Untappd friend activity feed", readOnly: true, idempotent: false, openWorld: true }),
31714
31845
  inputSchema: {
31715
31846
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max check-ins (1\u201350, default 25)"),
31716
- max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)")
31847
+ max_id: external_exports.number().int().positive().optional().describe("Return check-ins older than this id (for paging)"),
31848
+ compact: external_exports.boolean().optional().describe("Project each check-in to a slim summary to save context (default false)")
31717
31849
  }
31718
31850
  },
31719
- async ({ limit, max_id }) => {
31851
+ async ({ limit, max_id, compact }) => {
31720
31852
  const data = await client.get("/checkin/recent", { limit, max_id });
31721
- return textResult(data);
31853
+ return textResult(compact ? compactCheckins(data) : data);
31722
31854
  }
31723
31855
  );
31724
31856
  server.registerTool(
@@ -31738,6 +31870,80 @@ function registerFeedTools(server) {
31738
31870
  );
31739
31871
  }
31740
31872
 
31873
+ // src/tools/resolve.ts
31874
+ function parseUntappdUrl(input) {
31875
+ let url2;
31876
+ try {
31877
+ url2 = new URL(input.trim());
31878
+ } catch {
31879
+ return null;
31880
+ }
31881
+ if (!/(^|\.)untappd\.com$/i.test(url2.hostname)) return null;
31882
+ const segs = url2.pathname.split("/").filter(Boolean);
31883
+ if (segs[0] === "user" && segs[2] === "checkin" && /^\d+$/.test(segs[3] ?? "")) {
31884
+ return { type: "checkin", id: Number(segs[3]), username: segs[1], tool: "untappd_checkin_info" };
31885
+ }
31886
+ if (segs[0] === "user" && segs[1]) {
31887
+ return { type: "user", username: segs[1], tool: "untappd_user_info" };
31888
+ }
31889
+ const short = {
31890
+ b: { type: "beer", tool: "untappd_beer_info" },
31891
+ w: { type: "brewery", tool: "untappd_brewery_info" },
31892
+ v: { type: "venue", tool: "untappd_venue_info" }
31893
+ };
31894
+ if (segs[0] && short[segs[0]]) {
31895
+ const last = segs[segs.length - 1];
31896
+ if (/^\d+$/.test(last)) {
31897
+ return { ...short[segs[0]], id: Number(last) };
31898
+ }
31899
+ }
31900
+ return null;
31901
+ }
31902
+ function registerResolveTools(server) {
31903
+ server.registerTool(
31904
+ "untappd_resolve",
31905
+ {
31906
+ title: "Resolve an Untappd URL",
31907
+ description: "Parse an untappd.com URL (a beer /b/, brewery /w/, venue /v/, user /user/, or check-in link) into its entity type and id, and name the tool to call next. Pure local parsing \u2014 no network. Read-only.",
31908
+ annotations: toolAnnotations({ title: "Resolve an Untappd URL", readOnly: true, idempotent: true, openWorld: false }),
31909
+ inputSchema: {
31910
+ url: external_exports.string().min(1).describe("An untappd.com URL to resolve")
31911
+ }
31912
+ },
31913
+ async ({ url: url2 }) => {
31914
+ const resolved = parseUntappdUrl(url2);
31915
+ if (!resolved) {
31916
+ throw createHelpfulError(`Not a recognised Untappd entity URL: ${url2}`, {
31917
+ hint: "Expected an untappd.com link like /b/<slug>/<bid>, /w/<slug>/<id>, /v/<slug>/<id>, or /user/<name>."
31918
+ });
31919
+ }
31920
+ return textResult(resolved);
31921
+ }
31922
+ );
31923
+ server.registerTool(
31924
+ "untappd_open_url",
31925
+ {
31926
+ title: "Open an Untappd URL (resolve + fetch)",
31927
+ description: "Resolve an untappd.com URL AND fetch the entity detail in one call \u2014 the convenience combination of untappd_resolve + the matching info tool. Returns { resolved, detail }. Read-only.",
31928
+ annotations: toolAnnotations({ title: "Open an Untappd URL", readOnly: true, idempotent: true, openWorld: true }),
31929
+ inputSchema: {
31930
+ url: external_exports.string().min(1).describe("An untappd.com URL to resolve and fetch")
31931
+ }
31932
+ },
31933
+ async ({ url: url2 }) => {
31934
+ const resolved = parseUntappdUrl(url2);
31935
+ if (!resolved) {
31936
+ throw createHelpfulError(`Not a recognised Untappd entity URL: ${url2}`, {
31937
+ hint: "Expected an untappd.com link like /b/<slug>/<bid>, /w/<slug>/<id>, /v/<slug>/<id>, or /user/<name>."
31938
+ });
31939
+ }
31940
+ const path = resolved.type === "beer" ? `/beer/info/${resolved.id}` : resolved.type === "brewery" ? `/brewery/info/${resolved.id}` : resolved.type === "venue" ? `/venue/info/${resolved.id}` : resolved.type === "checkin" ? `/checkin/view/${resolved.id}` : `/user/info/${encodeURIComponent(resolved.username)}`;
31941
+ const detail = await client.get(path);
31942
+ return textResult({ resolved, detail });
31943
+ }
31944
+ );
31945
+ }
31946
+
31741
31947
  // src/tools/discover.ts
31742
31948
  function registerDiscoverTools(server) {
31743
31949
  server.registerTool(
@@ -31779,12 +31985,13 @@ function registerDiscoverTools(server) {
31779
31985
  lat: external_exports.number().min(-90).max(90).describe("Latitude of the location"),
31780
31986
  lng: external_exports.number().min(-180).max(180).describe("Longitude of the location"),
31781
31987
  limit: external_exports.number().int().min(1).max(50).optional().describe("Max check-ins (1\u201350, default 25)"),
31782
- radius: external_exports.number().int().min(1).max(25).optional().describe("Search radius (default per Untappd)")
31988
+ radius: external_exports.number().int().min(1).max(25).optional().describe("Search radius (default per Untappd)"),
31989
+ compact: external_exports.boolean().optional().describe("Project each check-in to a slim summary to save context (default false)")
31783
31990
  }
31784
31991
  },
31785
- async ({ lat, lng, limit, radius }) => {
31992
+ async ({ lat, lng, limit, radius, compact }) => {
31786
31993
  const data = await client.get("/thepub/local", { lat, lng, limit, radius });
31787
- return textResult(data);
31994
+ return textResult(compact ? compactCheckins(data) : data);
31788
31995
  }
31789
31996
  );
31790
31997
  }
@@ -31842,6 +32049,67 @@ function registerWishlistTools(server) {
31842
32049
  );
31843
32050
  }
31844
32051
 
32052
+ // src/tools/friends.ts
32053
+ var TargetUidSchema = external_exports.number().int().positive().describe("The target user id (uid) \u2014 from a user profile (response.user.uid) or a friends-list item");
32054
+ var ACTIONS = [
32055
+ {
32056
+ tool: "untappd_add_friend",
32057
+ path: "request",
32058
+ title: "Send an Untappd friend request",
32059
+ verb: "send a friend request to",
32060
+ detail: "Sends a friend request to another user, who will be notified."
32061
+ },
32062
+ {
32063
+ tool: "untappd_accept_friend",
32064
+ path: "accept",
32065
+ title: "Accept an Untappd friend request",
32066
+ verb: "accept the incoming friend request from",
32067
+ detail: "Accepts an incoming friend request (see untappd_pending_friends for pending uids)."
32068
+ },
32069
+ {
32070
+ tool: "untappd_reject_friend",
32071
+ path: "reject",
32072
+ title: "Reject an Untappd friend request",
32073
+ verb: "reject the incoming friend request from",
32074
+ detail: "Rejects/ignores an incoming friend request (see untappd_pending_friends for pending uids)."
32075
+ },
32076
+ {
32077
+ tool: "untappd_remove_friend",
32078
+ path: "remove",
32079
+ title: "Remove an Untappd friend",
32080
+ verb: "remove your friendship with",
32081
+ detail: "Removes an existing friend, or cancels a friend request you sent."
32082
+ }
32083
+ ];
32084
+ function registerFriendActionTools(server) {
32085
+ for (const action of ACTIONS) {
32086
+ server.registerTool(
32087
+ action.tool,
32088
+ {
32089
+ title: action.title,
32090
+ description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it performs the action. Note: this endpoint path is taken from the Untappd app but is not otherwise independently verified.`,
32091
+ annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true }),
32092
+ inputSchema: {
32093
+ target_uid: TargetUidSchema,
32094
+ confirm: schemaConfirm
32095
+ }
32096
+ },
32097
+ async ({ target_uid, confirm }) => {
32098
+ if (confirm !== true) {
32099
+ return textResult({
32100
+ dryRun: true,
32101
+ action: action.path,
32102
+ target_uid,
32103
+ note: `Dry run \u2014 re-run with confirm: true to ${action.verb} user ${target_uid}.`
32104
+ });
32105
+ }
32106
+ const data = await client.write("POST", `/friend/${action.path}/${target_uid}`);
32107
+ return textResult({ done: true, action: action.path, target_uid, result: data?.result });
32108
+ }
32109
+ );
32110
+ }
32111
+ }
32112
+
31845
32113
  // src/tools/checkin.ts
31846
32114
  import { existsSync } from "node:fs";
31847
32115
  import { extname } from "node:path";
@@ -32083,8 +32351,10 @@ await runMcp({
32083
32351
  registerVenueTools,
32084
32352
  registerUserTools,
32085
32353
  registerFeedTools,
32354
+ registerResolveTools,
32086
32355
  registerDiscoverTools,
32087
32356
  registerWishlistTools,
32357
+ registerFriendActionTools,
32088
32358
  registerCheckinTools,
32089
32359
  registerUtilityTools
32090
32360
  ]
@@ -0,0 +1,90 @@
1
+ // Opt-in slim projections for the fat reverse-engineered list responses. A
2
+ // single check-in record is ~5 KB and a beer search item ~1.2 KB, so a page of
3
+ // 25 is a lot of agent context. These project each item down to the fields an
4
+ // agent usually needs, keyed off DOCUMENTED fields only, and derive nothing it
5
+ // can't. Every projector degrades safely: if the expected `<container>.items`
6
+ // array isn't present (the undocumented API drifted), it warns to stderr and
7
+ // returns the RAW response rather than an empty/wrong projection.
8
+ function asDict(v) {
9
+ return v && typeof v === 'object' ? v : undefined;
10
+ }
11
+ function count(v) {
12
+ const d = asDict(v);
13
+ if (!d)
14
+ return undefined;
15
+ return (d.count ?? d.total_count);
16
+ }
17
+ /** Slim a single check-in record. */
18
+ export function compactCheckin(item) {
19
+ const c = asDict(item) ?? {};
20
+ const beer = asDict(c.beer);
21
+ const brewery = asDict(c.brewery);
22
+ const venue = asDict(c.venue);
23
+ const user = asDict(c.user);
24
+ return {
25
+ checkin_id: c.checkin_id,
26
+ created_at: c.created_at,
27
+ user: user?.user_name,
28
+ rating: c.rating_score,
29
+ comment: c.checkin_comment || undefined,
30
+ beer: beer && { bid: beer.bid, name: beer.beer_name, style: beer.beer_style, abv: beer.beer_abv },
31
+ brewery: brewery?.brewery_name,
32
+ venue: venue?.venue_name || undefined,
33
+ toasts: count(c.toasts),
34
+ comments: count(c.comments),
35
+ has_photo: (count(c.media) ?? 0) > 0,
36
+ };
37
+ }
38
+ /** The beer fields common to every beer-list item (search, wishlist, distinct). */
39
+ function beerCore(item) {
40
+ const beer = asDict(item.beer) ?? {};
41
+ const brewery = asDict(item.brewery);
42
+ return {
43
+ bid: beer.bid,
44
+ name: beer.beer_name,
45
+ style: beer.beer_style,
46
+ abv: beer.beer_abv,
47
+ ibu: beer.beer_ibu,
48
+ brewery: brewery?.brewery_name,
49
+ };
50
+ }
51
+ /** Slim a single beer-search result item. */
52
+ export function compactBeerResult(item) {
53
+ const i = asDict(item) ?? {};
54
+ return { ...beerCore(i), checkin_count: i.checkin_count, have_had: i.have_had };
55
+ }
56
+ /** Slim a single wishlist item (adds when it was wishlisted). */
57
+ export function compactWishlistBeer(item) {
58
+ const i = asDict(item) ?? {};
59
+ return { ...beerCore(i), added_at: i.created_at };
60
+ }
61
+ /** Slim a single distinct-beers (user/beers) item — your count, ratings, last had. */
62
+ export function compactUserBeer(item) {
63
+ const i = asDict(item) ?? {};
64
+ return {
65
+ ...beerCore(i),
66
+ your_count: i.count,
67
+ your_rating: i.user_auth_rating_score || undefined,
68
+ global_rating: i.rating_score || undefined,
69
+ last_had: i.recent_created_at,
70
+ };
71
+ }
72
+ /**
73
+ * Project `<container>.items` in a response with `mapFn`, preserving the rest of
74
+ * the container (pagination etc.). Drift-safe: returns the raw response
75
+ * unchanged (with a stderr warning) when the array isn't where expected.
76
+ */
77
+ export function projectItems(resp, container, mapFn) {
78
+ const r = asDict(resp);
79
+ const box = asDict(r?.[container]);
80
+ const items = box?.items;
81
+ if (!r || !box || !Array.isArray(items)) {
82
+ process.stderr.write(`[untappd-mcp] compact: expected ${container}.items array; returning raw response\n`);
83
+ return resp;
84
+ }
85
+ return { ...r, [container]: { ...box, items: items.map(mapFn) } };
86
+ }
87
+ export const compactCheckins = (resp) => projectItems(resp, 'checkins', compactCheckin);
88
+ export const compactBeerSearch = (resp) => projectItems(resp, 'beers', compactBeerResult);
89
+ export const compactWishlist = (resp) => projectItems(resp, 'beers', compactWishlistBeer);
90
+ export const compactUserBeers = (resp) => projectItems(resp, 'beers', compactUserBeer);
package/dist/index.js CHANGED
@@ -6,8 +6,10 @@ import { registerBreweryTools } from './tools/brewery.js';
6
6
  import { registerVenueTools } from './tools/venue.js';
7
7
  import { registerUserTools } from './tools/user.js';
8
8
  import { registerFeedTools } from './tools/feed.js';
9
+ import { registerResolveTools } from './tools/resolve.js';
9
10
  import { registerDiscoverTools } from './tools/discover.js';
10
11
  import { registerWishlistTools } from './tools/wishlist.js';
12
+ import { registerFriendActionTools } from './tools/friends.js';
11
13
  import { registerCheckinTools } from './tools/checkin.js';
12
14
  import { registerUtilityTools } from './tools/utilities.js';
13
15
  // The Untappd client is a module-level singleton (imported by each tool module)
@@ -25,8 +27,10 @@ await runMcp({
25
27
  registerVenueTools,
26
28
  registerUserTools,
27
29
  registerFeedTools,
30
+ registerResolveTools,
28
31
  registerDiscoverTools,
29
32
  registerWishlistTools,
33
+ registerFriendActionTools,
30
34
  registerCheckinTools,
31
35
  registerUtilityTools,
32
36
  ],
@@ -1,7 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { compactBeerSearch, compactCheckins } from '../compact.js';
4
5
  const BidSchema = z.number().int().positive().describe('Untappd beer id (bid)');
6
+ const CompactCheckins = z
7
+ .boolean()
8
+ .optional()
9
+ .describe('Project each check-in to a slim summary (id, user, beer, rating, comment, venue, toast/comment counts) to save context (default false)');
5
10
  export function registerBeerTools(server) {
6
11
  server.registerTool('untappd_search_beer', {
7
12
  title: 'Search Untappd beers',
@@ -17,10 +22,14 @@ export function registerBeerTools(server) {
17
22
  .enum(['checkin', 'name', 'count'])
18
23
  .optional()
19
24
  .describe('Sort order: checkin (relevance, default), name, or count'),
25
+ compact: z
26
+ .boolean()
27
+ .optional()
28
+ .describe('Project each result to a slim summary (bid, name, brewery, style, abv, ibu, counts) to save context (default false)'),
20
29
  },
21
- }, async ({ query, limit, offset, sort }) => {
30
+ }, async ({ query, limit, offset, sort, compact }) => {
22
31
  const data = await client.get('/search/beer', { q: query, limit, offset, sort });
23
- return textResult(data);
32
+ return textResult(compact ? compactBeerSearch(data) : data);
24
33
  });
25
34
  server.registerTool('untappd_beer_info', {
26
35
  title: 'Get Untappd beer detail',
@@ -47,9 +56,10 @@ export function registerBeerTools(server) {
47
56
  bid: BidSchema,
48
57
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
49
58
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
59
+ compact: CompactCheckins,
50
60
  },
51
- }, async ({ bid, limit, max_id }) => {
61
+ }, async ({ bid, limit, max_id, compact }) => {
52
62
  const data = await client.get(`/beer/checkins/${bid}`, { limit, max_id });
53
- return textResult(data);
63
+ return textResult(compact ? compactCheckins(data) : data);
54
64
  });
55
65
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { compactCheckins } from '../compact.js';
4
5
  export function registerDiscoverTools(server) {
5
6
  server.registerTool('untappd_trending', {
6
7
  title: 'Get trending beers',
@@ -35,9 +36,13 @@ export function registerDiscoverTools(server) {
35
36
  lng: z.number().min(-180).max(180).describe('Longitude of the location'),
36
37
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
37
38
  radius: z.number().int().min(1).max(25).optional().describe('Search radius (default per Untappd)'),
39
+ compact: z
40
+ .boolean()
41
+ .optional()
42
+ .describe('Project each check-in to a slim summary to save context (default false)'),
38
43
  },
39
- }, async ({ lat, lng, limit, radius }) => {
44
+ }, async ({ lat, lng, limit, radius, compact }) => {
40
45
  const data = await client.get('/thepub/local', { lat, lng, limit, radius });
41
- return textResult(data);
46
+ return textResult(compact ? compactCheckins(data) : data);
42
47
  });
43
48
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { compactCheckins } from '../compact.js';
4
5
  export function registerFeedTools(server) {
5
6
  server.registerTool('untappd_activity_feed', {
6
7
  title: 'Get Untappd friend activity feed',
@@ -10,10 +11,14 @@ export function registerFeedTools(server) {
10
11
  inputSchema: {
11
12
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
12
13
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
14
+ compact: z
15
+ .boolean()
16
+ .optional()
17
+ .describe('Project each check-in to a slim summary to save context (default false)'),
13
18
  },
14
- }, async ({ limit, max_id }) => {
19
+ }, async ({ limit, max_id, compact }) => {
15
20
  const data = await client.get('/checkin/recent', { limit, max_id });
16
- return textResult(data);
21
+ return textResult(compact ? compactCheckins(data) : data);
17
22
  });
18
23
  server.registerTool('untappd_checkin_info', {
19
24
  title: 'Get Untappd check-in detail',
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ // The endpoint PATHS below are confirmed from the Untappd app's own JS bundle
5
+ // (`friend/request`, `friend/accept`, `friend/reject`, `friend/remove`). The
6
+ // HTTP method (POST) mirrors the other captured action-writes (toast/comment),
7
+ // but — unlike the rest of this server's writes — these were NOT live-verified
8
+ // against the API, because doing so would send real friend requests to / alter
9
+ // real relationships with other people. They are confirm-gated so nothing fires
10
+ // without an explicit confirm: true.
11
+ const TargetUidSchema = z
12
+ .number()
13
+ .int()
14
+ .positive()
15
+ .describe('The target user id (uid) — from a user profile (response.user.uid) or a friends-list item');
16
+ const ACTIONS = [
17
+ {
18
+ tool: 'untappd_add_friend',
19
+ path: 'request',
20
+ title: 'Send an Untappd friend request',
21
+ verb: 'send a friend request to',
22
+ detail: 'Sends a friend request to another user, who will be notified.',
23
+ },
24
+ {
25
+ tool: 'untappd_accept_friend',
26
+ path: 'accept',
27
+ title: 'Accept an Untappd friend request',
28
+ verb: 'accept the incoming friend request from',
29
+ detail: 'Accepts an incoming friend request (see untappd_pending_friends for pending uids).',
30
+ },
31
+ {
32
+ tool: 'untappd_reject_friend',
33
+ path: 'reject',
34
+ title: 'Reject an Untappd friend request',
35
+ verb: 'reject the incoming friend request from',
36
+ detail: 'Rejects/ignores an incoming friend request (see untappd_pending_friends for pending uids).',
37
+ },
38
+ {
39
+ tool: 'untappd_remove_friend',
40
+ path: 'remove',
41
+ title: 'Remove an Untappd friend',
42
+ verb: 'remove your friendship with',
43
+ detail: 'Removes an existing friend, or cancels a friend request you sent.',
44
+ },
45
+ ];
46
+ export function registerFriendActionTools(server) {
47
+ for (const action of ACTIONS) {
48
+ server.registerTool(action.tool, {
49
+ title: action.title,
50
+ description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. ` +
51
+ 'Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it ' +
52
+ 'performs the action. Note: this endpoint path is taken from the Untappd app but is not otherwise ' +
53
+ 'independently verified.',
54
+ annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true }),
55
+ inputSchema: {
56
+ target_uid: TargetUidSchema,
57
+ confirm: schemaConfirm,
58
+ },
59
+ }, async ({ target_uid, confirm }) => {
60
+ if (confirm !== true) {
61
+ return textResult({
62
+ dryRun: true,
63
+ action: action.path,
64
+ target_uid,
65
+ note: `Dry run — re-run with confirm: true to ${action.verb} user ${target_uid}.`,
66
+ });
67
+ }
68
+ const data = await client.write('POST', `/friend/${action.path}/${target_uid}`);
69
+ return textResult({ done: true, action: action.path, target_uid, result: data?.result });
70
+ });
71
+ }
72
+ }
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ /**
5
+ * Parse an untappd.com URL into its entity type and id. Path shapes (confirmed
6
+ * from the app + site):
7
+ * /b/<slug>/<bid> → beer
8
+ * /w/<slug>/<brewery_id> → brewery
9
+ * /v/<slug>/<venue_id> → venue
10
+ * /user/<username> → user
11
+ * /user/<username>/checkin/<id>→ checkin
12
+ * Returns null if the URL isn't a recognised Untappd entity URL.
13
+ */
14
+ export function parseUntappdUrl(input) {
15
+ let url;
16
+ try {
17
+ url = new URL(input.trim());
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ if (!/(^|\.)untappd\.com$/i.test(url.hostname))
23
+ return null;
24
+ const segs = url.pathname.split('/').filter(Boolean);
25
+ // /user/<username>/checkin/<id>
26
+ if (segs[0] === 'user' && segs[2] === 'checkin' && /^\d+$/.test(segs[3] ?? '')) {
27
+ return { type: 'checkin', id: Number(segs[3]), username: segs[1], tool: 'untappd_checkin_info' };
28
+ }
29
+ // /user/<username>
30
+ if (segs[0] === 'user' && segs[1]) {
31
+ return { type: 'user', username: segs[1], tool: 'untappd_user_info' };
32
+ }
33
+ const short = {
34
+ b: { type: 'beer', tool: 'untappd_beer_info' },
35
+ w: { type: 'brewery', tool: 'untappd_brewery_info' },
36
+ v: { type: 'venue', tool: 'untappd_venue_info' },
37
+ };
38
+ // /b|w|v/<slug>/<id> (id is the last numeric segment)
39
+ if (segs[0] && short[segs[0]]) {
40
+ const last = segs[segs.length - 1];
41
+ if (/^\d+$/.test(last)) {
42
+ return { ...short[segs[0]], id: Number(last) };
43
+ }
44
+ }
45
+ return null;
46
+ }
47
+ export function registerResolveTools(server) {
48
+ server.registerTool('untappd_resolve', {
49
+ title: 'Resolve an Untappd URL',
50
+ description: 'Parse an untappd.com URL (a beer /b/, brewery /w/, venue /v/, user /user/, or check-in link) into its ' +
51
+ 'entity type and id, and name the tool to call next. Pure local parsing — no network. Read-only.',
52
+ annotations: toolAnnotations({ title: 'Resolve an Untappd URL', readOnly: true, idempotent: true, openWorld: false }),
53
+ inputSchema: {
54
+ url: z.string().min(1).describe('An untappd.com URL to resolve'),
55
+ },
56
+ }, async ({ url }) => {
57
+ const resolved = parseUntappdUrl(url);
58
+ if (!resolved) {
59
+ throw createHelpfulError(`Not a recognised Untappd entity URL: ${url}`, {
60
+ hint: 'Expected an untappd.com link like /b/<slug>/<bid>, /w/<slug>/<id>, /v/<slug>/<id>, or /user/<name>.',
61
+ });
62
+ }
63
+ return textResult(resolved);
64
+ });
65
+ server.registerTool('untappd_open_url', {
66
+ title: 'Open an Untappd URL (resolve + fetch)',
67
+ description: 'Resolve an untappd.com URL AND fetch the entity detail in one call — the convenience combination of ' +
68
+ 'untappd_resolve + the matching info tool. Returns { resolved, detail }. Read-only.',
69
+ annotations: toolAnnotations({ title: 'Open an Untappd URL', readOnly: true, idempotent: true, openWorld: true }),
70
+ inputSchema: {
71
+ url: z.string().min(1).describe('An untappd.com URL to resolve and fetch'),
72
+ },
73
+ }, async ({ url }) => {
74
+ const resolved = parseUntappdUrl(url);
75
+ if (!resolved) {
76
+ throw createHelpfulError(`Not a recognised Untappd entity URL: ${url}`, {
77
+ hint: 'Expected an untappd.com link like /b/<slug>/<bid>, /w/<slug>/<id>, /v/<slug>/<id>, or /user/<name>.',
78
+ });
79
+ }
80
+ const path = resolved.type === 'beer'
81
+ ? `/beer/info/${resolved.id}`
82
+ : resolved.type === 'brewery'
83
+ ? `/brewery/info/${resolved.id}`
84
+ : resolved.type === 'venue'
85
+ ? `/venue/info/${resolved.id}`
86
+ : resolved.type === 'checkin'
87
+ ? `/checkin/view/${resolved.id}`
88
+ : `/user/info/${encodeURIComponent(resolved.username)}`;
89
+ const detail = await client.get(path);
90
+ return textResult({ resolved, detail });
91
+ });
92
+ }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { compactCheckins, compactWishlist, compactUserBeers } from '../compact.js';
4
5
  const UsernameArg = z
5
6
  .string()
6
7
  .min(1)
@@ -39,10 +40,14 @@ export function registerUserTools(server) {
39
40
  username: UsernameArg,
40
41
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
41
42
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
43
+ compact: z
44
+ .boolean()
45
+ .optional()
46
+ .describe('Project each check-in to a slim summary to save context (default false)'),
42
47
  },
43
- }, async ({ username, limit, max_id }) => {
48
+ }, async ({ username, limit, max_id, compact }) => {
44
49
  const data = await client.get(`/user/checkins/${resolveUser(username)}`, { limit, max_id });
45
- return textResult(data);
50
+ return textResult(compact ? compactCheckins(data) : data);
46
51
  });
47
52
  server.registerTool('untappd_user_wishlist', {
48
53
  title: 'Get Untappd user wishlist',
@@ -56,10 +61,14 @@ export function registerUserTools(server) {
56
61
  .enum(['date', 'name', 'brewery', 'style', 'rating', 'abv'])
57
62
  .optional()
58
63
  .describe('Sort order (default date added, newest first)'),
64
+ compact: z
65
+ .boolean()
66
+ .optional()
67
+ .describe('Project each beer to a slim summary (bid, name, brewery, style, abv, added_at) to save context (default false)'),
59
68
  },
60
- }, async ({ username, limit, offset, sort }) => {
69
+ }, async ({ username, limit, offset, sort, compact }) => {
61
70
  const data = await client.get(`/user/wishlist/${resolveUser(username)}`, { limit, offset, sort });
62
- return textResult(data);
71
+ return textResult(compact ? compactWishlist(data) : data);
63
72
  });
64
73
  server.registerTool('untappd_user_beers', {
65
74
  title: 'Get Untappd distinct beers',
@@ -74,10 +83,14 @@ export function registerUserTools(server) {
74
83
  .enum(['date', 'checkin', 'highest_rated', 'lowest_rated', 'name', 'this_month', 'highest_abv'])
75
84
  .optional()
76
85
  .describe('Sort order (default date, most recent first)'),
86
+ compact: z
87
+ .boolean()
88
+ .optional()
89
+ .describe('Project each beer to a slim summary (bid, name, brewery, style, abv, ibu, your_count, your_rating, global_rating, last_had) to save context (default false)'),
77
90
  },
78
- }, async ({ username, limit, offset, sort }) => {
91
+ }, async ({ username, limit, offset, sort, compact }) => {
79
92
  const data = await client.get(`/user/beers/${resolveUser(username)}`, { limit, offset, sort });
80
- return textResult(data);
93
+ return textResult(compact ? compactUserBeers(data) : data);
81
94
  });
82
95
  server.registerTool('untappd_user_badges', {
83
96
  title: 'Get Untappd user badges',
@@ -105,4 +118,35 @@ export function registerUserTools(server) {
105
118
  const data = await client.get(`/user/friends/${resolveUser(username)}`, { limit, offset });
106
119
  return textResult(data);
107
120
  });
121
+ server.registerTool('untappd_user_venues', {
122
+ title: 'Get venues a user has checked in at',
123
+ description: "Get the venues a user has checked in at, most recent first, with per-venue check-in counts. Supports " +
124
+ 'sorting and paging. Omit username for your own account. Read-only.',
125
+ annotations: toolAnnotations({ title: 'Get venues a user has checked in at', readOnly: true, idempotent: true, openWorld: true }),
126
+ inputSchema: {
127
+ username: UsernameArg,
128
+ limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50, default 25)'),
129
+ offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
130
+ sort: z
131
+ .enum(['date', 'name', 'checkin', 'highest_rated'])
132
+ .optional()
133
+ .describe('Sort order (default most recent)'),
134
+ },
135
+ }, async ({ username, limit, offset, sort }) => {
136
+ const data = await client.get(`/user/venues/${resolveUser(username)}`, { limit, offset, sort });
137
+ return textResult(data);
138
+ });
139
+ server.registerTool('untappd_pending_friends', {
140
+ title: 'Get your pending friend requests',
141
+ description: 'Get the incoming friend requests waiting on YOUR account — the users who have requested to be your ' +
142
+ 'friend. Read-only.',
143
+ annotations: toolAnnotations({ title: 'Get your pending friend requests', readOnly: true, idempotent: true, openWorld: true }),
144
+ inputSchema: {
145
+ limit: z.number().int().min(1).max(50).optional().describe('Max requests (1–50, default 25)'),
146
+ offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
147
+ },
148
+ }, async ({ limit, offset }) => {
149
+ const data = await client.get('/user/pending', { limit, offset });
150
+ return textResult(data);
151
+ });
108
152
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
3
  import { client } from '../client.js';
4
+ import { compactCheckins } from '../compact.js';
4
5
  export function registerVenueTools(server) {
5
6
  server.registerTool('untappd_search_venue', {
6
7
  title: 'Search Untappd venues',
@@ -28,6 +29,18 @@ export function registerVenueTools(server) {
28
29
  const data = await client.get(`/venue/info/${venue_id}`, { compact: compact ? 'true' : undefined });
29
30
  return textResult(data);
30
31
  });
32
+ server.registerTool('untappd_venue_by_foursquare', {
33
+ title: 'Look up an Untappd venue by Foursquare id',
34
+ description: 'Resolve a Foursquare venue id to its Untappd venue. Useful to turn a foursquare_id (e.g. from a check-in) ' +
35
+ 'into an Untappd venue you can pass to untappd_venue_info / untappd_venue_activity. Read-only.',
36
+ annotations: toolAnnotations({ title: 'Look up an Untappd venue by Foursquare id', readOnly: true, idempotent: true, openWorld: true }),
37
+ inputSchema: {
38
+ foursquare_id: z.string().min(1).describe('Foursquare venue id'),
39
+ },
40
+ }, async ({ foursquare_id }) => {
41
+ const data = await client.get(`/venue/foursquare_lookup/${encodeURIComponent(foursquare_id)}`);
42
+ return textResult(data);
43
+ });
31
44
  server.registerTool('untappd_venue_activity', {
32
45
  title: 'Get recent check-ins at a venue',
33
46
  description: 'Get the recent public check-ins at a venue by its id — who was there, what they drank, and their ratings. ' +
@@ -37,9 +50,13 @@ export function registerVenueTools(server) {
37
50
  venue_id: z.number().int().positive().describe('Untappd venue id'),
38
51
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
39
52
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
53
+ compact: z
54
+ .boolean()
55
+ .optional()
56
+ .describe('Project each check-in to a slim summary to save context (default false)'),
40
57
  },
41
- }, async ({ venue_id, limit, max_id }) => {
58
+ }, async ({ venue_id, limit, max_id, compact }) => {
42
59
  const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
43
- return textResult(data);
60
+ return textResult(compact ? compactCheckins(data) : data);
44
61
  });
45
62
  }
package/dist/version.js CHANGED
@@ -3,4 +3,4 @@
3
3
  // json's `extra-files`), and `versionSyncTest` guards that it stays equal to
4
4
  // package.json. Import VERSION wherever the version is needed rather than
5
5
  // re-declaring it.
6
- export const VERSION = '1.0.0'; // x-release-please-version
6
+ export const VERSION = '1.1.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "mcpName": "io.github.chrischall/untappd-mcp",
5
5
  "description": "Untappd MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
package/server.json CHANGED
@@ -6,12 +6,12 @@
6
6
  "url": "https://github.com/chrischall/untappd-mcp",
7
7
  "source": "github"
8
8
  },
9
- "version": "1.0.0",
9
+ "version": "1.1.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "1.0.0",
14
+ "version": "1.1.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },