untappd-mcp 0.0.0 → 1.0.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": "0.0.0"
10
+ "version": "1.0.0"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,14 +15,20 @@
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": "0.0.0",
18
+ "version": "1.0.0",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
22
22
  "homepage": "https://github.com/chrischall/untappd-mcp",
23
23
  "repository": "https://github.com/chrischall/untappd-mcp",
24
24
  "license": "MIT",
25
- "keywords": ["untappd", "beer", "breweries", "checkins", "mcp"],
25
+ "keywords": [
26
+ "untappd",
27
+ "beer",
28
+ "breweries",
29
+ "checkins",
30
+ "mcp"
31
+ ],
26
32
  "category": "lifestyle"
27
33
  }
28
34
  ]
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
3
  "displayName": "Untappd",
4
- "version": "0.0.0",
4
+ "version": "1.0.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",
@@ -10,7 +10,13 @@
10
10
  "homepage": "https://github.com/chrischall/untappd-mcp",
11
11
  "repository": "https://github.com/chrischall/untappd-mcp",
12
12
  "license": "MIT",
13
- "keywords": ["untappd", "beer", "breweries", "checkins", "mcp"],
13
+ "keywords": [
14
+ "untappd",
15
+ "beer",
16
+ "breweries",
17
+ "checkins",
18
+ "mcp"
19
+ ],
14
20
  "skills": "./SKILL.md",
15
21
  "mcp": "./.mcp.json"
16
22
  }
package/README.md CHANGED
@@ -53,14 +53,17 @@ Keep these values private; do not commit them.
53
53
 
54
54
  ## Tools
55
55
 
56
- Reads: `untappd_search_beer`, `untappd_beer_info`, `untappd_search_brewery`,
57
- `untappd_brewery_info`, `untappd_search_venue`, `untappd_venue_info`,
56
+ Reads: `untappd_search_beer`, `untappd_beer_info`, `untappd_beer_activity`,
57
+ `untappd_search_brewery`, `untappd_brewery_info`, `untappd_brewery_beers`,
58
+ `untappd_search_venue`, `untappd_venue_info`, `untappd_venue_activity`,
58
59
  `untappd_user_info`, `untappd_user_checkins`, `untappd_user_wishlist`,
59
60
  `untappd_user_beers`, `untappd_user_badges`, `untappd_user_friends`,
60
- `untappd_activity_feed`, `untappd_checkin_info`, `untappd_healthcheck`.
61
+ `untappd_activity_feed`, `untappd_checkin_info`, `untappd_trending`,
62
+ `untappd_notifications`, `untappd_local_checkins`, `untappd_healthcheck`.
61
63
 
62
64
  Writes (confirm-gated — return a dry-run preview unless called with
63
- `confirm: true`): `untappd_toast`, `untappd_add_comment`, `untappd_checkin`.
65
+ `confirm: true`): `untappd_toast`, `untappd_add_comment`, `untappd_delete_comment`, `untappd_checkin`,
66
+ `untappd_wishlist_add`, `untappd_wishlist_remove`, `untappd_delete_checkin`.
64
67
 
65
68
  ## Development
66
69
 
package/dist/bundle.js CHANGED
@@ -31073,6 +31073,11 @@ function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
31073
31073
  return redacted;
31074
31074
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
31075
31075
  }
31076
+ function messageOf(err) {
31077
+ if (err instanceof Error)
31078
+ return err.message;
31079
+ return String(err);
31080
+ }
31076
31081
 
31077
31082
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
31078
31083
  function textResult(data) {
@@ -31111,6 +31116,21 @@ async function loadDotenvSafely(opts = {}) {
31111
31116
  }
31112
31117
  }
31113
31118
 
31119
+ // node_modules/@chrischall/mcp-utils/dist/fs/index.js
31120
+ import { openAsBlob } from "node:fs";
31121
+ async function fileBlob(path, opts = {}) {
31122
+ let blob;
31123
+ try {
31124
+ blob = await openAsBlob(path, opts.type !== void 0 ? { type: opts.type } : void 0);
31125
+ } catch {
31126
+ throw new Error(`Cannot read file for upload: ${path}`);
31127
+ }
31128
+ if (opts.maxBytes !== void 0 && blob.size > opts.maxBytes) {
31129
+ throw new Error(`${opts.label ?? "File"} is ${blob.size} bytes, over the ${opts.maxBytes}-byte limit: ${path}`);
31130
+ }
31131
+ return blob;
31132
+ }
31133
+
31114
31134
  // node_modules/@chrischall/mcp-utils/dist/http/index.js
31115
31135
  function buildQueryString(params) {
31116
31136
  const parts = [];
@@ -31164,7 +31184,7 @@ function toolAnnotations(opts = {}) {
31164
31184
  }
31165
31185
 
31166
31186
  // src/version.ts
31167
- var VERSION = "0.0.0";
31187
+ var VERSION = "1.0.0";
31168
31188
 
31169
31189
  // src/client.ts
31170
31190
  import { dirname, join } from "path";
@@ -31387,6 +31407,18 @@ var UntappdClient = class {
31387
31407
  async write(method, path, opts = {}) {
31388
31408
  return this.request(method, path, { ...opts, auth: "bearer" });
31389
31409
  }
31410
+ /**
31411
+ * Raw binary PUT to an arbitrary URL — used for the presigned-S3 photo upload,
31412
+ * whose URL already carries its own auth query params, so NO Untappd token is
31413
+ * attached. `body` should be a file-backed Blob (streamed, not heap-buffered).
31414
+ */
31415
+ async putBinary(url2, body, contentType) {
31416
+ const res = await this.send("PUT", url2, { headers: { "Content-Type": contentType }, body });
31417
+ if (!res.ok) {
31418
+ const text = await res.text().catch(() => "");
31419
+ throw new McpToolError(formatApiError(res.status, "PUT", url2.split("?")[0], text, { service: `${SERVICE} photo upload` }));
31420
+ }
31421
+ }
31390
31422
  };
31391
31423
  var client = new UntappdClient();
31392
31424
 
@@ -31427,6 +31459,23 @@ function registerBeerTools(server) {
31427
31459
  return textResult(data);
31428
31460
  }
31429
31461
  );
31462
+ server.registerTool(
31463
+ "untappd_beer_activity",
31464
+ {
31465
+ title: "Get recent check-ins for a beer",
31466
+ description: "Get the recent public check-ins for a beer by its bid \u2014 who drank it, their rating, comment, and venue. Page backwards with max_id (the pagination.max_id from a prior call). Read-only.",
31467
+ annotations: toolAnnotations({ title: "Get recent check-ins for a beer", readOnly: true, idempotent: false, openWorld: true }),
31468
+ inputSchema: {
31469
+ bid: BidSchema,
31470
+ 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)")
31472
+ }
31473
+ },
31474
+ async ({ bid, limit, max_id }) => {
31475
+ const data = await client.get(`/beer/checkins/${bid}`, { limit, max_id });
31476
+ return textResult(data);
31477
+ }
31478
+ );
31430
31479
  }
31431
31480
 
31432
31481
  // src/tools/brewery.ts
@@ -31464,6 +31513,24 @@ function registerBreweryTools(server) {
31464
31513
  return textResult(data);
31465
31514
  }
31466
31515
  );
31516
+ server.registerTool(
31517
+ "untappd_brewery_beers",
31518
+ {
31519
+ title: "Get a brewery's beer list",
31520
+ description: "Get the beers a brewery makes, by brewery id, with per-beer rating and check-in counts. Supports sorting and paging. Get an id from untappd_search_brewery. Read-only.",
31521
+ annotations: toolAnnotations({ title: "Get a brewery's beer list", readOnly: true, idempotent: true, openWorld: true }),
31522
+ inputSchema: {
31523
+ brewery_id: external_exports.number().int().positive().describe("Untappd brewery id"),
31524
+ limit: external_exports.number().int().min(1).max(50).optional().describe("Max beers (1\u201350, default 25)"),
31525
+ offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)"),
31526
+ sort: external_exports.enum(["name", "style", "abv", "rating", "count"]).optional().describe("Sort order (default by popularity)")
31527
+ }
31528
+ },
31529
+ async ({ brewery_id, limit, offset, sort }) => {
31530
+ const data = await client.get(`/brewery/beer_list/${brewery_id}`, { limit, offset, sort });
31531
+ return textResult(data);
31532
+ }
31533
+ );
31467
31534
  }
31468
31535
 
31469
31536
  // src/tools/venue.ts
@@ -31500,6 +31567,23 @@ function registerVenueTools(server) {
31500
31567
  return textResult(data);
31501
31568
  }
31502
31569
  );
31570
+ server.registerTool(
31571
+ "untappd_venue_activity",
31572
+ {
31573
+ title: "Get recent check-ins at a venue",
31574
+ description: "Get the recent public check-ins at a venue by its id \u2014 who was there, what they drank, and their ratings. Page backwards with max_id (the pagination.max_id from a prior call). Read-only.",
31575
+ annotations: toolAnnotations({ title: "Get recent check-ins at a venue", readOnly: true, idempotent: false, openWorld: true }),
31576
+ inputSchema: {
31577
+ venue_id: external_exports.number().int().positive().describe("Untappd venue id"),
31578
+ 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)")
31580
+ }
31581
+ },
31582
+ async ({ venue_id, limit, max_id }) => {
31583
+ const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
31584
+ return textResult(data);
31585
+ }
31586
+ );
31503
31587
  }
31504
31588
 
31505
31589
  // src/tools/user.ts
@@ -31654,8 +31738,119 @@ function registerFeedTools(server) {
31654
31738
  );
31655
31739
  }
31656
31740
 
31741
+ // src/tools/discover.ts
31742
+ function registerDiscoverTools(server) {
31743
+ server.registerTool(
31744
+ "untappd_trending",
31745
+ {
31746
+ title: "Get trending beers",
31747
+ description: "Get the beers trending on Untappd right now, split into macro (big/widely-available) and micro (craft/independent) lists. Read-only.",
31748
+ annotations: toolAnnotations({ title: "Get trending beers", readOnly: true, idempotent: false, openWorld: true }),
31749
+ inputSchema: {}
31750
+ },
31751
+ async () => {
31752
+ const data = await client.get("/beer/trending");
31753
+ return textResult(data);
31754
+ }
31755
+ );
31756
+ server.registerTool(
31757
+ "untappd_notifications",
31758
+ {
31759
+ title: "Get your Untappd notifications",
31760
+ description: "Get your Untappd notifications \u2014 toasts, comments, friend requests, and badges earned on YOUR account, plus news items. Read-only.",
31761
+ annotations: toolAnnotations({ title: "Get your Untappd notifications", readOnly: true, idempotent: false, openWorld: true }),
31762
+ inputSchema: {
31763
+ limit: external_exports.number().int().min(1).max(50).optional().describe("Max notifications (1\u201350, default 25)"),
31764
+ offset: external_exports.number().int().min(0).optional().describe("Result offset for paging (default 0)")
31765
+ }
31766
+ },
31767
+ async ({ limit, offset }) => {
31768
+ const data = await client.get("/notifications", { limit, offset });
31769
+ return textResult(data);
31770
+ }
31771
+ );
31772
+ server.registerTool(
31773
+ "untappd_local_checkins",
31774
+ {
31775
+ title: "Get nearby Untappd check-ins",
31776
+ description: "Get recent check-ins near a location (lat/lng) \u2014 what people are drinking nearby right now. Optionally widen the search radius. Read-only.",
31777
+ annotations: toolAnnotations({ title: "Get nearby Untappd check-ins", readOnly: true, idempotent: false, openWorld: true }),
31778
+ inputSchema: {
31779
+ lat: external_exports.number().min(-90).max(90).describe("Latitude of the location"),
31780
+ lng: external_exports.number().min(-180).max(180).describe("Longitude of the location"),
31781
+ 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)")
31783
+ }
31784
+ },
31785
+ async ({ lat, lng, limit, radius }) => {
31786
+ const data = await client.get("/thepub/local", { lat, lng, limit, radius });
31787
+ return textResult(data);
31788
+ }
31789
+ );
31790
+ }
31791
+
31792
+ // src/tools/wishlist.ts
31793
+ var BidSchema2 = external_exports.number().int().positive().describe("Untappd beer id (bid) \u2014 from untappd_search_beer");
31794
+ function registerWishlistTools(server) {
31795
+ server.registerTool(
31796
+ "untappd_wishlist_add",
31797
+ {
31798
+ title: "Add a beer to your wishlist",
31799
+ description: "Add a beer to YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it adds. Writes to your account.",
31800
+ annotations: toolAnnotations({ title: "Add a beer to your wishlist", readOnly: false, idempotent: true, openWorld: true }),
31801
+ inputSchema: {
31802
+ bid: BidSchema2,
31803
+ confirm: schemaConfirm
31804
+ }
31805
+ },
31806
+ async ({ bid, confirm }) => {
31807
+ if (confirm !== true) {
31808
+ return textResult({
31809
+ dryRun: true,
31810
+ action: "wishlist_add",
31811
+ bid,
31812
+ note: "Dry run \u2014 re-run with confirm: true to add this beer to your wishlist."
31813
+ });
31814
+ }
31815
+ const data = await client.write("GET", "/user/wishlist/add", { query: { bid } });
31816
+ return textResult({ added: true, bid, result: data?.result });
31817
+ }
31818
+ );
31819
+ server.registerTool(
31820
+ "untappd_wishlist_remove",
31821
+ {
31822
+ title: "Remove a beer from your wishlist",
31823
+ description: "Remove a beer from YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it removes. Writes to your account.",
31824
+ annotations: toolAnnotations({ title: "Remove a beer from your wishlist", readOnly: false, idempotent: true, openWorld: true }),
31825
+ inputSchema: {
31826
+ bid: BidSchema2,
31827
+ confirm: schemaConfirm
31828
+ }
31829
+ },
31830
+ async ({ bid, confirm }) => {
31831
+ if (confirm !== true) {
31832
+ return textResult({
31833
+ dryRun: true,
31834
+ action: "wishlist_remove",
31835
+ bid,
31836
+ note: "Dry run \u2014 re-run with confirm: true to remove this beer from your wishlist."
31837
+ });
31838
+ }
31839
+ const data = await client.write("GET", "/user/wishlist/delete", { query: { bid } });
31840
+ return textResult({ removed: true, bid, result: data?.result });
31841
+ }
31842
+ );
31843
+ }
31844
+
31657
31845
  // src/tools/checkin.ts
31846
+ import { existsSync } from "node:fs";
31847
+ import { extname } from "node:path";
31658
31848
  var CheckinIdSchema = external_exports.number().int().positive().describe("Untappd check-in id");
31849
+ var PHOTO_CONTENT_TYPES = { jpg: "image/jpeg", png: "image/png" };
31850
+ function photoExt(path) {
31851
+ const ext = extname(path).slice(1).toLowerCase();
31852
+ return ext === "jpeg" ? "jpg" : ext;
31853
+ }
31659
31854
  var RatingSchema = external_exports.number().min(0).max(5).refine((r) => Math.round(r * 4) === r * 4, { message: "rating must be a multiple of 0.25" });
31660
31855
  function localTimezone() {
31661
31856
  const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
@@ -31713,25 +31908,83 @@ function registerCheckinTools(server) {
31713
31908
  return textResult({ posted: true, checkin_id, response: data });
31714
31909
  }
31715
31910
  );
31911
+ server.registerTool(
31912
+ "untappd_delete_comment",
31913
+ {
31914
+ title: "Delete a comment from an Untappd check-in",
31915
+ description: "Delete one of YOUR comments by its comment id (the id from a check-in's comments list). Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.",
31916
+ annotations: toolAnnotations({ title: "Delete a comment from an Untappd check-in", readOnly: false, idempotent: true, openWorld: true }),
31917
+ inputSchema: {
31918
+ comment_id: external_exports.number().int().positive().describe("Untappd comment id (from a check-in's comments.items)"),
31919
+ confirm: schemaConfirm
31920
+ }
31921
+ },
31922
+ async ({ comment_id, confirm }) => {
31923
+ if (confirm !== true) {
31924
+ return textResult({
31925
+ dryRun: true,
31926
+ action: "delete_comment",
31927
+ comment_id,
31928
+ note: "Dry run \u2014 re-run with confirm: true to delete this comment from your Untappd account."
31929
+ });
31930
+ }
31931
+ const data = await client.write("POST", `/checkin/deletecomment/${comment_id}`);
31932
+ return textResult({ deleted: true, comment_id, result: data?.result });
31933
+ }
31934
+ );
31935
+ server.registerTool(
31936
+ "untappd_delete_checkin",
31937
+ {
31938
+ title: "Delete an Untappd check-in",
31939
+ description: "Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.",
31940
+ annotations: toolAnnotations({ title: "Delete an Untappd check-in", readOnly: false, idempotent: true, openWorld: true }),
31941
+ inputSchema: {
31942
+ checkin_id: CheckinIdSchema,
31943
+ confirm: schemaConfirm
31944
+ }
31945
+ },
31946
+ async ({ checkin_id, confirm }) => {
31947
+ if (confirm !== true) {
31948
+ return textResult({
31949
+ dryRun: true,
31950
+ action: "delete_checkin",
31951
+ checkin_id,
31952
+ note: "Dry run \u2014 re-run with confirm: true to PERMANENTLY delete this check-in. This cannot be undone."
31953
+ });
31954
+ }
31955
+ const data = await client.write("POST", `/checkin/delete/${checkin_id}`);
31956
+ return textResult({ deleted: true, checkin_id, result: data?.result });
31957
+ }
31958
+ );
31716
31959
  server.registerTool(
31717
31960
  "untappd_checkin",
31718
31961
  {
31719
31962
  title: "Check in a beer on Untappd",
31720
- description: "Post a NEW beer check-in to YOUR Untappd account \u2014 this publishes to your public feed. Provide the beer id (bid) from untappd_search_beer; optionally a rating (0\u20135 in 0.25 steps), a shout (comment), and a venue via foursquare_id (from a venue result). Without confirm: true it returns a dry-run preview of the exact fields and makes NO network call; with confirm: true it posts. Photo attachment is not supported.",
31963
+ description: "Post a NEW beer check-in to YOUR Untappd account \u2014 this publishes to your public feed. Provide the beer id (bid) from untappd_search_beer; optionally a rating (0\u20135 in 0.25 steps), a shout (comment), a venue via foursquare_id, and a local photo via photo_path (JPEG/PNG). Without confirm: true it returns a dry-run preview of the exact fields and makes NO network call; with confirm: true it posts.",
31721
31964
  annotations: toolAnnotations({ title: "Check in a beer on Untappd", readOnly: false, idempotent: false, openWorld: true }),
31722
31965
  inputSchema: {
31723
31966
  bid: external_exports.number().int().positive().describe("Untappd beer id to check in (from untappd_search_beer)"),
31724
31967
  rating: RatingSchema.optional().describe("Rating 0\u20135 in 0.25 increments (omit for no rating)"),
31725
31968
  shout: external_exports.string().max(2e3).optional().describe("Optional shout / comment text for the check-in"),
31726
31969
  foursquare_id: external_exports.string().optional().describe("Optional Foursquare venue id to tag the check-in location"),
31970
+ photo_path: external_exports.string().optional().describe("Optional path to a local JPEG/PNG photo to attach to the check-in"),
31727
31971
  geolat: external_exports.number().optional().describe("Optional latitude of the check-in"),
31728
31972
  geolng: external_exports.number().optional().describe("Optional longitude of the check-in"),
31729
31973
  container_id: external_exports.number().int().optional().describe("Optional serving container id (e.g. 1 = draft, 2 = bottle, 3 = can)"),
31730
31974
  confirm: schemaConfirm
31731
31975
  }
31732
31976
  },
31733
- async ({ bid, rating, shout, foursquare_id, geolat, geolng, container_id, confirm }) => {
31977
+ async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, confirm }) => {
31734
31978
  const { timezone, gmt_offset } = localTimezone();
31979
+ let ext;
31980
+ if (photo_path !== void 0) {
31981
+ ext = photoExt(photo_path);
31982
+ if (!(ext in PHOTO_CONTENT_TYPES)) {
31983
+ throw createHelpfulError(`Unsupported photo type "${ext || "(none)"}".`, {
31984
+ hint: "Attach a .jpg, .jpeg, or .png file."
31985
+ });
31986
+ }
31987
+ }
31735
31988
  const form = {
31736
31989
  bid,
31737
31990
  rating: rating !== void 0 ? rating.toFixed(2) : void 0,
@@ -31742,7 +31995,8 @@ function registerCheckinTools(server) {
31742
31995
  container_id,
31743
31996
  timezone,
31744
31997
  gmt_offset,
31745
- is_photo: "false",
31998
+ is_photo: photo_path !== void 0 ? "true" : "false",
31999
+ photo_file_ext: photo_path !== void 0 ? ext : void 0,
31746
32000
  platform: "ios"
31747
32001
  };
31748
32002
  if (confirm !== true) {
@@ -31750,11 +32004,40 @@ function registerCheckinTools(server) {
31750
32004
  dryRun: true,
31751
32005
  action: "checkin",
31752
32006
  form,
32007
+ photo: photo_path !== void 0 ? { path: photo_path, note: "will be uploaded after the check-in is created" } : void 0,
31753
32008
  note: "Dry run \u2014 re-run with confirm: true to POST this check-in to your public Untappd feed."
31754
32009
  });
31755
32010
  }
32011
+ let blob;
32012
+ if (photo_path !== void 0) {
32013
+ if (!existsSync(photo_path)) throw new McpToolError(`Photo file not found: ${photo_path}`);
32014
+ blob = await fileBlob(photo_path);
32015
+ }
31756
32016
  const data = await client.write("POST", "/checkin/add", { form });
31757
- return textResult({ checked_in: true, checkin_id: data?.checkin_id, response: data });
32017
+ let photo_attached = false;
32018
+ let photo_error;
32019
+ if (photo_path !== void 0) {
32020
+ if (data?.photo_upload?.url && data.checkin_id) {
32021
+ try {
32022
+ await client.putBinary(data.photo_upload.url, blob, PHOTO_CONTENT_TYPES[ext]);
32023
+ await client.write("POST", "/photo/uploadComplete", {
32024
+ form: { checkin_id: data.checkin_id, destination_url: data.photo_upload.destination_url }
32025
+ });
32026
+ photo_attached = true;
32027
+ } catch (e) {
32028
+ photo_error = `Check-in ${data.checkin_id} was created, but attaching the photo failed: ${messageOf(e)}. Retry attaching, or remove it with untappd_delete_checkin.`;
32029
+ }
32030
+ } else {
32031
+ photo_error = `Check-in ${data?.checkin_id} was created, but Untappd returned no photo upload URL, so no photo was attached.`;
32032
+ }
32033
+ }
32034
+ return textResult({
32035
+ checked_in: true,
32036
+ checkin_id: data?.checkin_id,
32037
+ photo_attached,
32038
+ ...photo_error ? { photo_error } : {},
32039
+ response: data
32040
+ });
31758
32041
  }
31759
32042
  );
31760
32043
  }
@@ -31800,6 +32083,8 @@ await runMcp({
31800
32083
  registerVenueTools,
31801
32084
  registerUserTools,
31802
32085
  registerFeedTools,
32086
+ registerDiscoverTools,
32087
+ registerWishlistTools,
31803
32088
  registerCheckinTools,
31804
32089
  registerUtilityTools
31805
32090
  ]
package/dist/client.js CHANGED
@@ -242,6 +242,18 @@ export class UntappdClient {
242
242
  async write(method, path, opts = {}) {
243
243
  return this.request(method, path, { ...opts, auth: 'bearer' });
244
244
  }
245
+ /**
246
+ * Raw binary PUT to an arbitrary URL — used for the presigned-S3 photo upload,
247
+ * whose URL already carries its own auth query params, so NO Untappd token is
248
+ * attached. `body` should be a file-backed Blob (streamed, not heap-buffered).
249
+ */
250
+ async putBinary(url, body, contentType) {
251
+ const res = await this.send('PUT', url, { headers: { 'Content-Type': contentType }, body });
252
+ if (!res.ok) {
253
+ const text = await res.text().catch(() => '');
254
+ throw new McpToolError(formatApiError(res.status, 'PUT', url.split('?')[0], text, { service: `${SERVICE} photo upload` }));
255
+ }
256
+ }
245
257
  }
246
258
  /**
247
259
  * Module-level singleton shared by every tool module. Constructing it here (not
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ 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 { registerDiscoverTools } from './tools/discover.js';
10
+ import { registerWishlistTools } from './tools/wishlist.js';
9
11
  import { registerCheckinTools } from './tools/checkin.js';
10
12
  import { registerUtilityTools } from './tools/utilities.js';
11
13
  // The Untappd client is a module-level singleton (imported by each tool module)
@@ -23,6 +25,8 @@ await runMcp({
23
25
  registerVenueTools,
24
26
  registerUserTools,
25
27
  registerFeedTools,
28
+ registerDiscoverTools,
29
+ registerWishlistTools,
26
30
  registerCheckinTools,
27
31
  registerUtilityTools,
28
32
  ],
@@ -38,4 +38,18 @@ export function registerBeerTools(server) {
38
38
  const data = await client.get(`/beer/info/${bid}`, { compact: compact ? 'true' : undefined });
39
39
  return textResult(data);
40
40
  });
41
+ server.registerTool('untappd_beer_activity', {
42
+ title: 'Get recent check-ins for a beer',
43
+ description: 'Get the recent public check-ins for a beer by its bid — who drank it, their rating, comment, and venue. ' +
44
+ 'Page backwards with max_id (the pagination.max_id from a prior call). Read-only.',
45
+ annotations: toolAnnotations({ title: 'Get recent check-ins for a beer', readOnly: true, idempotent: false, openWorld: true }),
46
+ inputSchema: {
47
+ bid: BidSchema,
48
+ limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
49
+ max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
50
+ },
51
+ }, async ({ bid, limit, max_id }) => {
52
+ const data = await client.get(`/beer/checkins/${bid}`, { limit, max_id });
53
+ return textResult(data);
54
+ });
41
55
  }
@@ -29,4 +29,22 @@ export function registerBreweryTools(server) {
29
29
  const data = await client.get(`/brewery/info/${brewery_id}`, { compact: compact ? 'true' : undefined });
30
30
  return textResult(data);
31
31
  });
32
+ server.registerTool('untappd_brewery_beers', {
33
+ title: "Get a brewery's beer list",
34
+ description: 'Get the beers a brewery makes, by brewery id, with per-beer rating and check-in counts. Supports sorting ' +
35
+ 'and paging. Get an id from untappd_search_brewery. Read-only.',
36
+ annotations: toolAnnotations({ title: "Get a brewery's beer list", readOnly: true, idempotent: true, openWorld: true }),
37
+ inputSchema: {
38
+ brewery_id: z.number().int().positive().describe('Untappd brewery id'),
39
+ limit: z.number().int().min(1).max(50).optional().describe('Max beers (1–50, default 25)'),
40
+ offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
41
+ sort: z
42
+ .enum(['name', 'style', 'abv', 'rating', 'count'])
43
+ .optional()
44
+ .describe('Sort order (default by popularity)'),
45
+ },
46
+ }, async ({ brewery_id, limit, offset, sort }) => {
47
+ const data = await client.get(`/brewery/beer_list/${brewery_id}`, { limit, offset, sort });
48
+ return textResult(data);
49
+ });
32
50
  }
@@ -1,7 +1,16 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { extname } from 'node:path';
1
3
  import { z } from 'zod';
2
- import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
4
+ import { textResult, toolAnnotations, schemaConfirm, fileBlob, McpToolError, createHelpfulError, messageOf } from '@chrischall/mcp-utils';
3
5
  import { client } from '../client.js';
4
6
  const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
7
+ // Keyed by the NORMALISED extension that photoExt() returns (jpeg → jpg), so
8
+ // there is no dead `jpeg` entry.
9
+ const PHOTO_CONTENT_TYPES = { jpg: 'image/jpeg', png: 'image/png' };
10
+ function photoExt(path) {
11
+ const ext = extname(path).slice(1).toLowerCase();
12
+ return ext === 'jpeg' ? 'jpg' : ext;
13
+ }
5
14
  // Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
6
15
  const RatingSchema = z
7
16
  .number()
@@ -60,18 +69,61 @@ export function registerCheckinTools(server) {
60
69
  const data = await client.write('POST', `/checkin/addcomment/${checkin_id}`, { form: { comment } });
61
70
  return textResult({ posted: true, checkin_id, response: data });
62
71
  });
72
+ server.registerTool('untappd_delete_comment', {
73
+ title: 'Delete a comment from an Untappd check-in',
74
+ description: 'Delete one of YOUR comments by its comment id (the id from a check-in\'s comments list). Without ' +
75
+ 'confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.',
76
+ annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true }),
77
+ inputSchema: {
78
+ comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
79
+ confirm: schemaConfirm,
80
+ },
81
+ }, async ({ comment_id, confirm }) => {
82
+ if (confirm !== true) {
83
+ return textResult({
84
+ dryRun: true,
85
+ action: 'delete_comment',
86
+ comment_id,
87
+ note: 'Dry run — re-run with confirm: true to delete this comment from your Untappd account.',
88
+ });
89
+ }
90
+ const data = await client.write('POST', `/checkin/deletecomment/${comment_id}`);
91
+ return textResult({ deleted: true, comment_id, result: data?.result });
92
+ });
93
+ server.registerTool('untappd_delete_checkin', {
94
+ title: 'Delete an Untappd check-in',
95
+ description: 'Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. Without ' +
96
+ 'confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.',
97
+ annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true }),
98
+ inputSchema: {
99
+ checkin_id: CheckinIdSchema,
100
+ confirm: schemaConfirm,
101
+ },
102
+ }, async ({ checkin_id, confirm }) => {
103
+ if (confirm !== true) {
104
+ return textResult({
105
+ dryRun: true,
106
+ action: 'delete_checkin',
107
+ checkin_id,
108
+ note: 'Dry run — re-run with confirm: true to PERMANENTLY delete this check-in. This cannot be undone.',
109
+ });
110
+ }
111
+ const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
112
+ return textResult({ deleted: true, checkin_id, result: data?.result });
113
+ });
63
114
  server.registerTool('untappd_checkin', {
64
115
  title: 'Check in a beer on Untappd',
65
116
  description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
66
- '(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), and a venue via ' +
67
- 'foursquare_id (from a venue result). Without confirm: true it returns a dry-run preview of the exact fields ' +
68
- 'and makes NO network call; with confirm: true it posts. Photo attachment is not supported.',
117
+ '(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), a venue via ' +
118
+ 'foursquare_id, and a local photo via photo_path (JPEG/PNG). Without confirm: true it returns a dry-run ' +
119
+ 'preview of the exact fields and makes NO network call; with confirm: true it posts.',
69
120
  annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true }),
70
121
  inputSchema: {
71
122
  bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
72
123
  rating: RatingSchema.optional().describe('Rating 0–5 in 0.25 increments (omit for no rating)'),
73
124
  shout: z.string().max(2000).optional().describe('Optional shout / comment text for the check-in'),
74
125
  foursquare_id: z.string().optional().describe('Optional Foursquare venue id to tag the check-in location'),
126
+ photo_path: z.string().optional().describe('Optional path to a local JPEG/PNG photo to attach to the check-in'),
75
127
  geolat: z.number().optional().describe('Optional latitude of the check-in'),
76
128
  geolng: z.number().optional().describe('Optional longitude of the check-in'),
77
129
  container_id: z
@@ -81,8 +133,17 @@ export function registerCheckinTools(server) {
81
133
  .describe('Optional serving container id (e.g. 1 = draft, 2 = bottle, 3 = can)'),
82
134
  confirm: schemaConfirm,
83
135
  },
84
- }, async ({ bid, rating, shout, foursquare_id, geolat, geolng, container_id, confirm }) => {
136
+ }, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, confirm }) => {
85
137
  const { timezone, gmt_offset } = localTimezone();
138
+ let ext;
139
+ if (photo_path !== undefined) {
140
+ ext = photoExt(photo_path);
141
+ if (!(ext in PHOTO_CONTENT_TYPES)) {
142
+ throw createHelpfulError(`Unsupported photo type "${ext || '(none)'}".`, {
143
+ hint: 'Attach a .jpg, .jpeg, or .png file.',
144
+ });
145
+ }
146
+ }
86
147
  const form = {
87
148
  bid,
88
149
  rating: rating !== undefined ? rating.toFixed(2) : undefined,
@@ -93,7 +154,8 @@ export function registerCheckinTools(server) {
93
154
  container_id,
94
155
  timezone,
95
156
  gmt_offset,
96
- is_photo: 'false',
157
+ is_photo: photo_path !== undefined ? 'true' : 'false',
158
+ photo_file_ext: photo_path !== undefined ? ext : undefined,
97
159
  platform: 'ios',
98
160
  };
99
161
  if (confirm !== true) {
@@ -101,10 +163,48 @@ export function registerCheckinTools(server) {
101
163
  dryRun: true,
102
164
  action: 'checkin',
103
165
  form,
166
+ photo: photo_path !== undefined ? { path: photo_path, note: 'will be uploaded after the check-in is created' } : undefined,
104
167
  note: 'Dry run — re-run with confirm: true to POST this check-in to your public Untappd feed.',
105
168
  });
106
169
  }
170
+ // Open the photo BEFORE creating the check-in, so a missing/unreadable
171
+ // file fails fast without leaving an orphaned photo-less check-in behind.
172
+ let blob;
173
+ if (photo_path !== undefined) {
174
+ if (!existsSync(photo_path))
175
+ throw new McpToolError(`Photo file not found: ${photo_path}`);
176
+ blob = await fileBlob(photo_path); // file-backed, streamed — not heap-buffered
177
+ }
107
178
  const data = await client.write('POST', '/checkin/add', { form });
108
- return textResult({ checked_in: true, checkin_id: data?.checkin_id, response: data });
179
+ // Photo is a follow-up S3 upload keyed to the returned checkin_id, then an
180
+ // uploadComplete call. The check-in already exists at this point, so a
181
+ // photo failure is surfaced explicitly (photo_error) rather than thrown —
182
+ // never silently dropped.
183
+ let photo_attached = false;
184
+ let photo_error;
185
+ if (photo_path !== undefined) {
186
+ if (data?.photo_upload?.url && data.checkin_id) {
187
+ try {
188
+ await client.putBinary(data.photo_upload.url, blob, PHOTO_CONTENT_TYPES[ext]);
189
+ await client.write('POST', '/photo/uploadComplete', {
190
+ form: { checkin_id: data.checkin_id, destination_url: data.photo_upload.destination_url },
191
+ });
192
+ photo_attached = true;
193
+ }
194
+ catch (e) {
195
+ photo_error = `Check-in ${data.checkin_id} was created, but attaching the photo failed: ${messageOf(e)}. Retry attaching, or remove it with untappd_delete_checkin.`;
196
+ }
197
+ }
198
+ else {
199
+ photo_error = `Check-in ${data?.checkin_id} was created, but Untappd returned no photo upload URL, so no photo was attached.`;
200
+ }
201
+ }
202
+ return textResult({
203
+ checked_in: true,
204
+ checkin_id: data?.checkin_id,
205
+ photo_attached,
206
+ ...(photo_error ? { photo_error } : {}),
207
+ response: data,
208
+ });
109
209
  });
110
210
  }
@@ -0,0 +1,43 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ export function registerDiscoverTools(server) {
5
+ server.registerTool('untappd_trending', {
6
+ title: 'Get trending beers',
7
+ description: 'Get the beers trending on Untappd right now, split into macro (big/widely-available) and micro ' +
8
+ '(craft/independent) lists. Read-only.',
9
+ annotations: toolAnnotations({ title: 'Get trending beers', readOnly: true, idempotent: false, openWorld: true }),
10
+ inputSchema: {},
11
+ }, async () => {
12
+ const data = await client.get('/beer/trending');
13
+ return textResult(data);
14
+ });
15
+ server.registerTool('untappd_notifications', {
16
+ title: 'Get your Untappd notifications',
17
+ description: 'Get your Untappd notifications — toasts, comments, friend requests, and badges earned on YOUR account, ' +
18
+ 'plus news items. Read-only.',
19
+ annotations: toolAnnotations({ title: 'Get your Untappd notifications', readOnly: true, idempotent: false, openWorld: true }),
20
+ inputSchema: {
21
+ limit: z.number().int().min(1).max(50).optional().describe('Max notifications (1–50, default 25)'),
22
+ offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
23
+ },
24
+ }, async ({ limit, offset }) => {
25
+ const data = await client.get('/notifications', { limit, offset });
26
+ return textResult(data);
27
+ });
28
+ server.registerTool('untappd_local_checkins', {
29
+ title: 'Get nearby Untappd check-ins',
30
+ description: 'Get recent check-ins near a location (lat/lng) — what people are drinking nearby right now. Optionally ' +
31
+ 'widen the search radius. Read-only.',
32
+ annotations: toolAnnotations({ title: 'Get nearby Untappd check-ins', readOnly: true, idempotent: false, openWorld: true }),
33
+ inputSchema: {
34
+ lat: z.number().min(-90).max(90).describe('Latitude of the location'),
35
+ lng: z.number().min(-180).max(180).describe('Longitude of the location'),
36
+ limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
37
+ radius: z.number().int().min(1).max(25).optional().describe('Search radius (default per Untappd)'),
38
+ },
39
+ }, async ({ lat, lng, limit, radius }) => {
40
+ const data = await client.get('/thepub/local', { lat, lng, limit, radius });
41
+ return textResult(data);
42
+ });
43
+ }
@@ -28,4 +28,18 @@ export function registerVenueTools(server) {
28
28
  const data = await client.get(`/venue/info/${venue_id}`, { compact: compact ? 'true' : undefined });
29
29
  return textResult(data);
30
30
  });
31
+ server.registerTool('untappd_venue_activity', {
32
+ title: 'Get recent check-ins at a venue',
33
+ description: 'Get the recent public check-ins at a venue by its id — who was there, what they drank, and their ratings. ' +
34
+ 'Page backwards with max_id (the pagination.max_id from a prior call). Read-only.',
35
+ annotations: toolAnnotations({ title: 'Get recent check-ins at a venue', readOnly: true, idempotent: false, openWorld: true }),
36
+ inputSchema: {
37
+ venue_id: z.number().int().positive().describe('Untappd venue id'),
38
+ limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
39
+ max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
40
+ },
41
+ }, async ({ venue_id, limit, max_id }) => {
42
+ const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
43
+ return textResult(data);
44
+ });
31
45
  }
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
+ import { client } from '../client.js';
4
+ const BidSchema = z.number().int().positive().describe('Untappd beer id (bid) — from untappd_search_beer');
5
+ export function registerWishlistTools(server) {
6
+ server.registerTool('untappd_wishlist_add', {
7
+ title: 'Add a beer to your wishlist',
8
+ description: 'Add a beer to YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and ' +
9
+ 'makes NO network call; with confirm: true it adds. Writes to your account.',
10
+ annotations: toolAnnotations({ title: 'Add a beer to your wishlist', readOnly: false, idempotent: true, openWorld: true }),
11
+ inputSchema: {
12
+ bid: BidSchema,
13
+ confirm: schemaConfirm,
14
+ },
15
+ }, async ({ bid, confirm }) => {
16
+ if (confirm !== true) {
17
+ return textResult({
18
+ dryRun: true,
19
+ action: 'wishlist_add',
20
+ bid,
21
+ note: 'Dry run — re-run with confirm: true to add this beer to your wishlist.',
22
+ });
23
+ }
24
+ const data = await client.write('GET', '/user/wishlist/add', { query: { bid } });
25
+ return textResult({ added: true, bid, result: data?.result });
26
+ });
27
+ server.registerTool('untappd_wishlist_remove', {
28
+ title: 'Remove a beer from your wishlist',
29
+ description: 'Remove a beer from YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview ' +
30
+ 'and makes NO network call; with confirm: true it removes. Writes to your account.',
31
+ annotations: toolAnnotations({ title: 'Remove a beer from your wishlist', readOnly: false, idempotent: true, openWorld: true }),
32
+ inputSchema: {
33
+ bid: BidSchema,
34
+ confirm: schemaConfirm,
35
+ },
36
+ }, async ({ bid, confirm }) => {
37
+ if (confirm !== true) {
38
+ return textResult({
39
+ dryRun: true,
40
+ action: 'wishlist_remove',
41
+ bid,
42
+ note: 'Dry run — re-run with confirm: true to remove this beer from your wishlist.',
43
+ });
44
+ }
45
+ const data = await client.write('GET', '/user/wishlist/delete', { query: { bid } });
46
+ return textResult({ removed: true, bid, result: data?.result });
47
+ });
48
+ }
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 = '0.0.0'; // x-release-please-version
6
+ export const VERSION = '1.0.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "0.0.0",
3
+ "version": "1.0.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>",
@@ -46,10 +46,10 @@
46
46
  "zod": "^4.4.2"
47
47
  },
48
48
  "devDependencies": {
49
- "@types/node": "^25.5.2",
49
+ "@types/node": "^26.1.1",
50
50
  "@vitest/coverage-v8": "^4.1.2",
51
51
  "esbuild": "^0.28.0",
52
- "typescript": "^6.0.2",
52
+ "typescript": "^7.0.2",
53
53
  "vitest": "^4.1.2"
54
54
  }
55
55
  }
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": "0.0.0",
9
+ "version": "1.0.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "0.0.0",
14
+ "version": "1.0.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },