untappd-mcp 1.0.0 → 1.2.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.
@@ -0,0 +1,91 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
+ /**
4
+ * Parse an untappd.com URL into its entity type and id. Path shapes (confirmed
5
+ * from the app + site):
6
+ * /b/<slug>/<bid> → beer
7
+ * /w/<slug>/<brewery_id> → brewery
8
+ * /v/<slug>/<venue_id> → venue
9
+ * /user/<username> → user
10
+ * /user/<username>/checkin/<id>→ checkin
11
+ * Returns null if the URL isn't a recognised Untappd entity URL.
12
+ */
13
+ export function parseUntappdUrl(input) {
14
+ let url;
15
+ try {
16
+ url = new URL(input.trim());
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ if (!/(^|\.)untappd\.com$/i.test(url.hostname))
22
+ return null;
23
+ const segs = url.pathname.split('/').filter(Boolean);
24
+ // /user/<username>/checkin/<id>
25
+ if (segs[0] === 'user' && segs[2] === 'checkin' && /^\d+$/.test(segs[3] ?? '')) {
26
+ return { type: 'checkin', id: Number(segs[3]), username: segs[1], tool: 'untappd_checkin_info' };
27
+ }
28
+ // /user/<username>
29
+ if (segs[0] === 'user' && segs[1]) {
30
+ return { type: 'user', username: segs[1], tool: 'untappd_user_info' };
31
+ }
32
+ const short = {
33
+ b: { type: 'beer', tool: 'untappd_beer_info' },
34
+ w: { type: 'brewery', tool: 'untappd_brewery_info' },
35
+ v: { type: 'venue', tool: 'untappd_venue_info' },
36
+ };
37
+ // /b|w|v/<slug>/<id> (id is the last numeric segment)
38
+ if (segs[0] && short[segs[0]]) {
39
+ const last = segs[segs.length - 1];
40
+ if (/^\d+$/.test(last)) {
41
+ return { ...short[segs[0]], id: Number(last) };
42
+ }
43
+ }
44
+ return null;
45
+ }
46
+ export function registerResolveTools(server, client) {
47
+ server.registerTool('untappd_resolve', {
48
+ title: 'Resolve an Untappd URL',
49
+ description: 'Parse an untappd.com URL (a beer /b/, brewery /w/, venue /v/, user /user/, or check-in link) into its ' +
50
+ 'entity type and id, and name the tool to call next. Pure local parsing — no network. Read-only.',
51
+ annotations: toolAnnotations({ title: 'Resolve an Untappd URL', readOnly: true, idempotent: true, openWorld: false }),
52
+ inputSchema: {
53
+ url: z.string().min(1).describe('An untappd.com URL to resolve'),
54
+ },
55
+ }, async ({ url }) => {
56
+ const resolved = parseUntappdUrl(url);
57
+ if (!resolved) {
58
+ throw createHelpfulError(`Not a recognised Untappd entity URL: ${url}`, {
59
+ hint: 'Expected an untappd.com link like /b/<slug>/<bid>, /w/<slug>/<id>, /v/<slug>/<id>, or /user/<name>.',
60
+ });
61
+ }
62
+ return textResult(resolved);
63
+ });
64
+ server.registerTool('untappd_open_url', {
65
+ title: 'Open an Untappd URL (resolve + fetch)',
66
+ description: 'Resolve an untappd.com URL AND fetch the entity detail in one call — the convenience combination of ' +
67
+ 'untappd_resolve + the matching info tool. Returns { resolved, detail }. Read-only.',
68
+ annotations: toolAnnotations({ title: 'Open an Untappd URL', readOnly: true, idempotent: true, openWorld: true }),
69
+ inputSchema: {
70
+ url: z.string().min(1).describe('An untappd.com URL to resolve and fetch'),
71
+ },
72
+ }, async ({ url }) => {
73
+ const resolved = parseUntappdUrl(url);
74
+ if (!resolved) {
75
+ throw createHelpfulError(`Not a recognised Untappd entity URL: ${url}`, {
76
+ hint: 'Expected an untappd.com link like /b/<slug>/<bid>, /w/<slug>/<id>, /v/<slug>/<id>, or /user/<name>.',
77
+ });
78
+ }
79
+ const path = resolved.type === 'beer'
80
+ ? `/beer/info/${resolved.id}`
81
+ : resolved.type === 'brewery'
82
+ ? `/brewery/info/${resolved.id}`
83
+ : resolved.type === 'venue'
84
+ ? `/venue/info/${resolved.id}`
85
+ : resolved.type === 'checkin'
86
+ ? `/checkin/view/${resolved.id}`
87
+ : `/user/info/${encodeURIComponent(resolved.username)}`;
88
+ const detail = await client.get(path);
89
+ return textResult({ resolved, detail });
90
+ });
91
+ }
@@ -1,14 +1,14 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
3
+ import { compactCheckins, compactWishlist, compactUserBeers } from '../compact.js';
4
4
  const UsernameArg = z
5
5
  .string()
6
6
  .min(1)
7
7
  .optional()
8
8
  .describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
9
9
  /** Resolve the target username: explicit arg, else the configured login name. */
10
- function resolveUser(username) {
11
- const u = username ?? client.loginName;
10
+ function resolveUser(username, loginName) {
11
+ const u = username ?? loginName;
12
12
  if (!u) {
13
13
  throw createHelpfulError('No username given and no configured account to fall back to.', {
14
14
  hint: 'Pass `username`, or set UNTAPPD_USERNAME so user tools default to your own account.',
@@ -16,7 +16,7 @@ function resolveUser(username) {
16
16
  }
17
17
  return encodeURIComponent(u);
18
18
  }
19
- export function registerUserTools(server) {
19
+ export function registerUserTools(server, client) {
20
20
  server.registerTool('untappd_user_info', {
21
21
  title: 'Get Untappd user profile',
22
22
  description: "Get an Untappd user's profile: bio, location, total check-ins, distinct beers, badges, and stats. " +
@@ -27,7 +27,7 @@ export function registerUserTools(server) {
27
27
  compact: z.boolean().optional().describe('Return a slimmer record without embedded lists (default false)'),
28
28
  },
29
29
  }, async ({ username, compact }) => {
30
- const data = await client.get(`/user/info/${resolveUser(username)}`, { compact: compact ? 'true' : undefined });
30
+ const data = await client.get(`/user/info/${resolveUser(username, client.loginName)}`, { compact: compact ? 'true' : undefined });
31
31
  return textResult(data);
32
32
  });
33
33
  server.registerTool('untappd_user_checkins', {
@@ -39,10 +39,14 @@ export function registerUserTools(server) {
39
39
  username: UsernameArg,
40
40
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
41
41
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
42
+ compact: z
43
+ .boolean()
44
+ .optional()
45
+ .describe('Project each check-in to a slim summary to save context (default false)'),
42
46
  },
43
- }, async ({ username, limit, max_id }) => {
44
- const data = await client.get(`/user/checkins/${resolveUser(username)}`, { limit, max_id });
45
- return textResult(data);
47
+ }, async ({ username, limit, max_id, compact }) => {
48
+ const data = await client.get(`/user/checkins/${resolveUser(username, client.loginName)}`, { limit, max_id });
49
+ return textResult(compact ? compactCheckins(data) : data);
46
50
  });
47
51
  server.registerTool('untappd_user_wishlist', {
48
52
  title: 'Get Untappd user wishlist',
@@ -56,10 +60,14 @@ export function registerUserTools(server) {
56
60
  .enum(['date', 'name', 'brewery', 'style', 'rating', 'abv'])
57
61
  .optional()
58
62
  .describe('Sort order (default date added, newest first)'),
63
+ compact: z
64
+ .boolean()
65
+ .optional()
66
+ .describe('Project each beer to a slim summary (bid, name, brewery, style, abv, added_at) to save context (default false)'),
59
67
  },
60
- }, async ({ username, limit, offset, sort }) => {
61
- const data = await client.get(`/user/wishlist/${resolveUser(username)}`, { limit, offset, sort });
62
- return textResult(data);
68
+ }, async ({ username, limit, offset, sort, compact }) => {
69
+ const data = await client.get(`/user/wishlist/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
70
+ return textResult(compact ? compactWishlist(data) : data);
63
71
  });
64
72
  server.registerTool('untappd_user_beers', {
65
73
  title: 'Get Untappd distinct beers',
@@ -74,10 +82,14 @@ export function registerUserTools(server) {
74
82
  .enum(['date', 'checkin', 'highest_rated', 'lowest_rated', 'name', 'this_month', 'highest_abv'])
75
83
  .optional()
76
84
  .describe('Sort order (default date, most recent first)'),
85
+ compact: z
86
+ .boolean()
87
+ .optional()
88
+ .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
89
  },
78
- }, async ({ username, limit, offset, sort }) => {
79
- const data = await client.get(`/user/beers/${resolveUser(username)}`, { limit, offset, sort });
80
- return textResult(data);
90
+ }, async ({ username, limit, offset, sort, compact }) => {
91
+ const data = await client.get(`/user/beers/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
92
+ return textResult(compact ? compactUserBeers(data) : data);
81
93
  });
82
94
  server.registerTool('untappd_user_badges', {
83
95
  title: 'Get Untappd user badges',
@@ -89,7 +101,7 @@ export function registerUserTools(server) {
89
101
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
90
102
  },
91
103
  }, async ({ username, limit, offset }) => {
92
- const data = await client.get(`/user/badges/${resolveUser(username)}`, { limit, offset });
104
+ const data = await client.get(`/user/badges/${resolveUser(username, client.loginName)}`, { limit, offset });
93
105
  return textResult(data);
94
106
  });
95
107
  server.registerTool('untappd_user_friends', {
@@ -102,7 +114,38 @@ export function registerUserTools(server) {
102
114
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
103
115
  },
104
116
  }, async ({ username, limit, offset }) => {
105
- const data = await client.get(`/user/friends/${resolveUser(username)}`, { limit, offset });
117
+ const data = await client.get(`/user/friends/${resolveUser(username, client.loginName)}`, { limit, offset });
118
+ return textResult(data);
119
+ });
120
+ server.registerTool('untappd_user_venues', {
121
+ title: 'Get venues a user has checked in at',
122
+ description: "Get the venues a user has checked in at, most recent first, with per-venue check-in counts. Supports " +
123
+ 'sorting and paging. Omit username for your own account. Read-only.',
124
+ annotations: toolAnnotations({ title: 'Get venues a user has checked in at', readOnly: true, idempotent: true, openWorld: true }),
125
+ inputSchema: {
126
+ username: UsernameArg,
127
+ limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50, default 25)'),
128
+ offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
129
+ sort: z
130
+ .enum(['date', 'name', 'checkin', 'highest_rated'])
131
+ .optional()
132
+ .describe('Sort order (default most recent)'),
133
+ },
134
+ }, async ({ username, limit, offset, sort }) => {
135
+ const data = await client.get(`/user/venues/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
136
+ return textResult(data);
137
+ });
138
+ server.registerTool('untappd_pending_friends', {
139
+ title: 'Get your pending friend requests',
140
+ description: 'Get the incoming friend requests waiting on YOUR account — the users who have requested to be your ' +
141
+ 'friend. Read-only.',
142
+ annotations: toolAnnotations({ title: 'Get your pending friend requests', readOnly: true, idempotent: true, openWorld: true }),
143
+ inputSchema: {
144
+ limit: z.number().int().min(1).max(50).optional().describe('Max requests (1–50, default 25)'),
145
+ offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
146
+ },
147
+ }, async ({ limit, offset }) => {
148
+ const data = await client.get('/user/pending', { limit, offset });
106
149
  return textResult(data);
107
150
  });
108
151
  }
@@ -1,6 +1,5 @@
1
1
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
2
- import { client } from '../client.js';
3
- export function registerUtilityTools(server) {
2
+ export function registerUtilityTools(server, client) {
4
3
  server.registerTool('untappd_healthcheck', {
5
4
  title: 'Untappd healthcheck',
6
5
  description: 'Verify Untappd connectivity and that credentials are configured and can log in. Performs a lightweight ' +
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
- export function registerVenueTools(server) {
3
+ import { compactCheckins } from '../compact.js';
4
+ export function registerVenueTools(server, client) {
5
5
  server.registerTool('untappd_search_venue', {
6
6
  title: 'Search Untappd venues',
7
7
  description: 'Search Untappd for venues (bars, breweries, restaurants) by name. Returns matches with their venue id, ' +
@@ -28,6 +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_by_foursquare', {
32
+ title: 'Look up an Untappd venue by Foursquare id',
33
+ description: 'Resolve a Foursquare venue id to its Untappd venue. Useful to turn a foursquare_id (e.g. from a check-in) ' +
34
+ 'into an Untappd venue you can pass to untappd_venue_info / untappd_venue_activity. Read-only.',
35
+ annotations: toolAnnotations({ title: 'Look up an Untappd venue by Foursquare id', readOnly: true, idempotent: true, openWorld: true }),
36
+ inputSchema: {
37
+ foursquare_id: z.string().min(1).describe('Foursquare venue id'),
38
+ },
39
+ }, async ({ foursquare_id }) => {
40
+ const data = await client.get(`/venue/foursquare_lookup/${encodeURIComponent(foursquare_id)}`);
41
+ return textResult(data);
42
+ });
31
43
  server.registerTool('untappd_venue_activity', {
32
44
  title: 'Get recent check-ins at a venue',
33
45
  description: 'Get the recent public check-ins at a venue by its id — who was there, what they drank, and their ratings. ' +
@@ -37,9 +49,13 @@ export function registerVenueTools(server) {
37
49
  venue_id: z.number().int().positive().describe('Untappd venue id'),
38
50
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
39
51
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
52
+ compact: z
53
+ .boolean()
54
+ .optional()
55
+ .describe('Project each check-in to a slim summary to save context (default false)'),
40
56
  },
41
- }, async ({ venue_id, limit, max_id }) => {
57
+ }, async ({ venue_id, limit, max_id, compact }) => {
42
58
  const data = await client.get(`/venue/checkins/${venue_id}`, { limit, max_id });
43
- return textResult(data);
59
+ return textResult(compact ? compactCheckins(data) : data);
44
60
  });
45
61
  }
@@ -1,8 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  const BidSchema = z.number().int().positive().describe('Untappd beer id (bid) — from untappd_search_beer');
5
- export function registerWishlistTools(server) {
4
+ export function registerWishlistTools(server, client) {
6
5
  server.registerTool('untappd_wishlist_add', {
7
6
  title: 'Add a beer to your wishlist',
8
7
  description: 'Add a beer to YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and ' +
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.2.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.2.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>",
@@ -37,19 +37,27 @@
37
37
  "dev": "node dist/index.js",
38
38
  "test": "vitest run",
39
39
  "test:watch": "vitest",
40
- "test:coverage": "vitest run --coverage"
40
+ "test:coverage": "vitest run --coverage",
41
+ "worker:dev": "wrangler dev",
42
+ "worker:deploy": "wrangler deploy",
43
+ "worker:test": "vitest run --config vitest.workers.config.ts"
41
44
  },
42
45
  "dependencies": {
43
46
  "@chrischall/mcp-utils": "^0.12.0",
44
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
+ "ai": "^7.0.19",
45
49
  "dotenv": "^17.4.0",
46
50
  "zod": "^4.4.2"
47
51
  },
48
52
  "devDependencies": {
53
+ "@chrischall/mcp-connector": "file:./packages/mcp-connector",
54
+ "@cloudflare/vitest-pool-workers": "^0.18.4",
55
+ "@cloudflare/workers-types": "^5.20260708.1",
49
56
  "@types/node": "^26.1.1",
50
57
  "@vitest/coverage-v8": "^4.1.2",
51
58
  "esbuild": "^0.28.0",
52
59
  "typescript": "^7.0.2",
53
- "vitest": "^4.1.2"
60
+ "vitest": "^4.1.2",
61
+ "wrangler": "^4.110.0"
54
62
  }
55
63
  }
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.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "1.0.0",
14
+ "version": "1.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },