untappd-mcp 1.1.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.
package/dist/client.js CHANGED
@@ -1,11 +1,15 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { loadDotenvSafely, readEnvVar, buildQueryString, formatApiError, createHelpfulError, McpToolError, RateLimitError, UnreachableError, } from '@chrischall/mcp-utils';
4
- // Load .env for local dev; silently skip if dotenv is unavailable (e.g. the
5
- // mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and never
6
- // lets .env override a host-provided value.
7
- const __dirname = dirname(fileURLToPath(import.meta.url));
8
- await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
4
+ // Load .env for local (Node) dev; silently skip if dotenv is unavailable (e.g.
5
+ // the mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and
6
+ // never lets .env override a host-provided value. Guard on `import.meta.url`:
7
+ // in a non-Node runtime (Cloudflare Workers) it is undefined and there is no
8
+ // local .env — secrets arrive via the platform's env bindings instead.
9
+ if (import.meta.url) {
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+ await loadDotenvSafely({ path: join(__dirname, '..', '.env'), override: false });
12
+ }
9
13
  const BASE_URL = 'https://api.untappd.com/v4';
10
14
  const SERVICE = 'Untappd';
11
15
  const REQUEST_TIMEOUT_MS = 30_000;
@@ -25,15 +29,87 @@ const DEFAULTS = {
25
29
  // works. Override with UNTAPPD_DEVICE_ID to pin your own.
26
30
  deviceUdid: 'A1B2C3D4-0000-4000-8000-0000000000AA',
27
31
  };
28
- function readCredentials() {
29
- const username = readEnvVar('UNTAPPD_USERNAME');
30
- const password = readEnvVar('UNTAPPD_PASSWORD');
31
- const clientId = readEnvVar('UNTAPPD_CLIENT_ID');
32
- const clientSecret = readEnvVar('UNTAPPD_CLIENT_SECRET');
33
- if (username && password && clientId && clientSecret) {
34
- return { username, password, clientId, clientSecret };
32
+ // One HTTP attempt with a hard timeout, shared by the instance `send()` method
33
+ // and the standalone `xauthLogin()` helper below. Network/timeout failures
34
+ // become an UnreachableError; HTTP status handling is left to the caller.
35
+ async function sendRequest(fetchImpl, method, url, init) {
36
+ const controller = new AbortController();
37
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
38
+ try {
39
+ return await fetchImpl(url, {
40
+ method,
41
+ headers: init.headers,
42
+ ...(init.body !== undefined ? { body: init.body } : {}),
43
+ signal: controller.signal,
44
+ });
45
+ }
46
+ catch {
47
+ throw new UnreachableError(SERVICE);
48
+ }
49
+ finally {
50
+ clearTimeout(timer);
51
+ }
52
+ }
53
+ function xauthHeaders(userAgent) {
54
+ return {
55
+ 'User-Agent': userAgent,
56
+ Accept: 'application/json',
57
+ 'x-untappd-app': 'ios',
58
+ 'x-untappd-app-version': DEFAULTS.appVersion,
59
+ };
60
+ }
61
+ /**
62
+ * Performs the one-shot Untappd xauth login POST (username/password →
63
+ * access token). Standalone so it can be reused both by `UntappdClient`'s
64
+ * own on-demand login and by the Cloudflare-connector login flow
65
+ * (`src/untappd-auth.ts`), which authenticates a user before a client even
66
+ * exists — without duplicating the request/parsing logic.
67
+ */
68
+ export async function xauthLogin(creds, opts = {}) {
69
+ const fetchImpl = opts.fetchImpl ?? fetch;
70
+ const utv = opts.utv ?? readEnvVar('UNTAPPD_UTV') ?? DEFAULTS.utv;
71
+ const deviceUdid = opts.deviceUdid ?? readEnvVar('UNTAPPD_DEVICE_ID') ?? DEFAULTS.deviceUdid;
72
+ const userAgent = opts.userAgent ?? readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
73
+ const qs = buildQueryString({ client_id: creds.clientId, client_secret: creds.clientSecret, utv });
74
+ const form = new URLSearchParams({
75
+ user_name: creds.username,
76
+ user_password: creds.password,
77
+ device_udid: deviceUdid,
78
+ device_name: DEFAULTS.deviceName,
79
+ device_version: DEFAULTS.deviceVersion,
80
+ device_platform: DEFAULTS.devicePlatform,
81
+ app_version: DEFAULTS.appVersion,
82
+ multi_account: 'true',
83
+ });
84
+ const res = await sendRequest(fetchImpl, 'POST', `${BASE_URL}/xauth${qs}`, {
85
+ headers: { ...xauthHeaders(userAgent), 'Content-Type': 'application/x-www-form-urlencoded' },
86
+ body: form.toString(),
87
+ });
88
+ const text = await res.text();
89
+ if (!res.ok) {
90
+ if (res.status === 401 || res.status === 400) {
91
+ throw createHelpfulError(`Untappd login failed (${res.status}).`, {
92
+ hint: 'Check UNTAPPD_USERNAME / UNTAPPD_PASSWORD, and that UNTAPPD_CLIENT_ID / UNTAPPD_CLIENT_SECRET are the mobile app credentials.',
93
+ });
94
+ }
95
+ throw new McpToolError(formatApiError(res.status, 'POST', '/xauth', text, { service: SERVICE }));
96
+ }
97
+ let data;
98
+ try {
99
+ data = JSON.parse(text);
100
+ }
101
+ catch {
102
+ throw new McpToolError('Untappd login returned a non-JSON response.');
35
103
  }
36
- return null;
104
+ const token = data?.response?.access_token;
105
+ if (!token) {
106
+ throw createHelpfulError('Untappd login did not return an access token.', {
107
+ hint: data?.response?.two_factor_enabled
108
+ ? 'This account has two-factor authentication enabled, which xauth login cannot satisfy.'
109
+ : 'The credentials may be incorrect.',
110
+ });
111
+ }
112
+ return token;
37
113
  }
38
114
  function missingCredsError() {
39
115
  const missing = ['UNTAPPD_USERNAME', 'UNTAPPD_PASSWORD', 'UNTAPPD_CLIENT_ID', 'UNTAPPD_CLIENT_SECRET'].filter((k) => !readEnvVar(k));
@@ -44,8 +120,11 @@ function missingCredsError() {
44
120
  }
45
121
  export class UntappdClient {
46
122
  fetchImpl;
47
- creds;
48
- configError;
123
+ clientId;
124
+ clientSecret;
125
+ username;
126
+ password;
127
+ _loginName;
49
128
  utv = readEnvVar('UNTAPPD_UTV') ?? DEFAULTS.utv;
50
129
  deviceUdid = readEnvVar('UNTAPPD_DEVICE_ID') ?? DEFAULTS.deviceUdid;
51
130
  userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
@@ -59,28 +138,34 @@ export class UntappdClient {
59
138
  constructor(opts = {}) {
60
139
  this.fetchImpl = opts.fetchImpl ?? fetch;
61
140
  this.token = opts.token ?? null;
62
- const creds = opts.credentials !== undefined ? opts.credentials : readCredentials();
63
- if (creds) {
64
- this.creds = creds;
65
- this.configError = null;
66
- }
67
- else {
68
- this.creds = null;
69
- this.configError = missingCredsError();
70
- }
141
+ this.clientId = opts.clientId ?? readEnvVar('UNTAPPD_CLIENT_ID') ?? null;
142
+ this.clientSecret = opts.clientSecret ?? readEnvVar('UNTAPPD_CLIENT_SECRET') ?? null;
143
+ this.username = opts.username ?? readEnvVar('UNTAPPD_USERNAME') ?? null;
144
+ this.password = opts.password ?? readEnvVar('UNTAPPD_PASSWORD') ?? null;
145
+ this._loginName = opts.loginName ?? this.username;
71
146
  }
72
- /** Whether credentials are configured (used by the healthcheck tool). */
147
+ /** True when enough is configured to make authenticated calls. */
73
148
  get configured() {
74
- return this.configError === null;
149
+ // A pre-seeded token needs only app creds (for writes); otherwise a full login is required.
150
+ if (this.token)
151
+ return Boolean(this.clientId && this.clientSecret);
152
+ return Boolean(this.clientId && this.clientSecret && this.username && this.password);
75
153
  }
76
154
  /** The configured login name — the default `username` for user-scoped tools. */
77
155
  get loginName() {
78
- return this.creds?.username ?? null;
156
+ return this._loginName;
157
+ }
158
+ requireAppCreds() {
159
+ if (!this.clientId || !this.clientSecret) {
160
+ throw missingCredsError();
161
+ }
162
+ return { clientId: this.clientId, clientSecret: this.clientSecret };
79
163
  }
80
- requireCreds() {
81
- if (this.configError)
82
- throw this.configError;
83
- return this.creds;
164
+ requireLogin() {
165
+ const app = this.requireAppCreds();
166
+ if (!this.username || !this.password)
167
+ throw missingCredsError();
168
+ return { ...app, username: this.username, password: this.password };
84
169
  }
85
170
  baseHeaders() {
86
171
  return {
@@ -90,25 +175,10 @@ export class UntappdClient {
90
175
  'x-untappd-app-version': DEFAULTS.appVersion,
91
176
  };
92
177
  }
93
- // One HTTP attempt with a hard timeout. Network/timeout failures become an
94
- // UnreachableError; HTTP status handling is left to the caller.
178
+ // One HTTP attempt with a hard timeout, delegating to the module-level
179
+ // helper shared with `xauthLogin()`.
95
180
  async send(method, url, init) {
96
- const controller = new AbortController();
97
- const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
98
- try {
99
- return await this.fetchImpl(url, {
100
- method,
101
- headers: init.headers,
102
- ...(init.body !== undefined ? { body: init.body } : {}),
103
- signal: controller.signal,
104
- });
105
- }
106
- catch {
107
- throw new UnreachableError(SERVICE);
108
- }
109
- finally {
110
- clearTimeout(timer);
111
- }
181
+ return sendRequest(this.fetchImpl, method, url, init);
112
182
  }
113
183
  /**
114
184
  * Obtain an access token, logging in via xauth (username/password) on demand.
@@ -126,46 +196,13 @@ export class UntappdClient {
126
196
  return this.loginInFlight;
127
197
  }
128
198
  async login() {
129
- const c = this.requireCreds();
130
- const qs = buildQueryString({ client_id: c.clientId, client_secret: c.clientSecret, utv: this.utv });
131
- const form = new URLSearchParams({
132
- user_name: c.username,
133
- user_password: c.password,
134
- device_udid: this.deviceUdid,
135
- device_name: DEFAULTS.deviceName,
136
- device_version: DEFAULTS.deviceVersion,
137
- device_platform: DEFAULTS.devicePlatform,
138
- app_version: DEFAULTS.appVersion,
139
- multi_account: 'true',
140
- });
141
- const res = await this.send('POST', `${BASE_URL}/xauth${qs}`, {
142
- headers: { ...this.baseHeaders(), 'Content-Type': 'application/x-www-form-urlencoded' },
143
- body: form.toString(),
199
+ const c = this.requireLogin();
200
+ const token = await xauthLogin(c, {
201
+ fetchImpl: this.fetchImpl,
202
+ utv: this.utv,
203
+ deviceUdid: this.deviceUdid,
204
+ userAgent: this.userAgent,
144
205
  });
145
- const text = await res.text();
146
- if (!res.ok) {
147
- if (res.status === 401 || res.status === 400) {
148
- throw createHelpfulError(`Untappd login failed (${res.status}).`, {
149
- hint: 'Check UNTAPPD_USERNAME / UNTAPPD_PASSWORD, and that UNTAPPD_CLIENT_ID / UNTAPPD_CLIENT_SECRET are the mobile app credentials.',
150
- });
151
- }
152
- throw new McpToolError(formatApiError(res.status, 'POST', '/xauth', text, { service: SERVICE }));
153
- }
154
- let data;
155
- try {
156
- data = JSON.parse(text);
157
- }
158
- catch {
159
- throw new McpToolError('Untappd login returned a non-JSON response.');
160
- }
161
- const token = data?.response?.access_token;
162
- if (!token) {
163
- throw createHelpfulError('Untappd login did not return an access token.', {
164
- hint: data?.response?.two_factor_enabled
165
- ? 'This account has two-factor authentication enabled, which xauth login cannot satisfy.'
166
- : 'The credentials may be incorrect.',
167
- });
168
- }
169
206
  this.token = token;
170
207
  return token;
171
208
  }
@@ -204,7 +241,7 @@ export class UntappdClient {
204
241
  // requests the app itself makes.
205
242
  let query;
206
243
  if (opts.auth === 'bearer') {
207
- const c = this.requireCreds();
244
+ const c = this.requireAppCreds();
208
245
  headers['Authorization'] = `Bearer ${token}`;
209
246
  query = { ...opts.query, client_id: c.clientId, client_secret: c.clientSecret, utv: this.utv };
210
247
  }
@@ -255,10 +292,3 @@ export class UntappdClient {
255
292
  }
256
293
  }
257
294
  }
258
- /**
259
- * Module-level singleton shared by every tool module. Constructing it here (not
260
- * in `index.ts`) keeps the deferred-config-error pattern: the server boots and
261
- * answers the host's install-time tools/list smoke test even when credentials
262
- * are absent — the error only surfaces on the first tool call.
263
- */
264
- export const client = new UntappdClient();
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { runMcp } from '@chrischall/mcp-utils';
3
3
  import { VERSION } from './version.js';
4
+ import { UntappdClient } from './client.js';
4
5
  import { registerBeerTools } from './tools/beer.js';
5
6
  import { registerBreweryTools } from './tools/brewery.js';
6
7
  import { registerVenueTools } from './tools/venue.js';
@@ -8,30 +9,29 @@ import { registerUserTools } from './tools/user.js';
8
9
  import { registerFeedTools } from './tools/feed.js';
9
10
  import { registerResolveTools } from './tools/resolve.js';
10
11
  import { registerDiscoverTools } from './tools/discover.js';
11
- import { registerWishlistTools } from './tools/wishlist.js';
12
12
  import { registerFriendActionTools } from './tools/friends.js';
13
+ import { registerWishlistTools } from './tools/wishlist.js';
13
14
  import { registerCheckinTools } from './tools/checkin.js';
14
15
  import { registerUtilityTools } from './tools/utilities.js';
15
- // The Untappd client is a module-level singleton (imported by each tool module)
16
- // that defers its config error to the first request. That preserves the
17
- // deferred-config-error pattern: the server boots and answers the host's
18
- // install-time tools/list smoke test even when credentials are absent — the
19
- // configuration error only surfaces on the first tool call.
16
+ // Build the env-based client once and inject it into each registrar. The
17
+ // constructor defers its config error, so the server still boots (and answers
18
+ // the host's install-time tools/list probe) when credentials are absent.
19
+ const client = new UntappdClient();
20
20
  await runMcp({
21
21
  name: 'untappd-mcp',
22
22
  version: VERSION,
23
23
  banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
24
24
  tools: [
25
- registerBeerTools,
26
- registerBreweryTools,
27
- registerVenueTools,
28
- registerUserTools,
29
- registerFeedTools,
30
- registerResolveTools,
31
- registerDiscoverTools,
32
- registerWishlistTools,
33
- registerFriendActionTools,
34
- registerCheckinTools,
35
- registerUtilityTools,
25
+ (s) => registerBeerTools(s, client),
26
+ (s) => registerBreweryTools(s, client),
27
+ (s) => registerVenueTools(s, client),
28
+ (s) => registerUserTools(s, client),
29
+ (s) => registerFeedTools(s, client),
30
+ (s) => registerResolveTools(s, client),
31
+ (s) => registerDiscoverTools(s, client),
32
+ (s) => registerFriendActionTools(s, client),
33
+ (s) => registerWishlistTools(s, client),
34
+ (s) => registerCheckinTools(s, client),
35
+ (s) => registerUtilityTools(s, client),
36
36
  ],
37
37
  });
@@ -1,13 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { compactBeerSearch, compactCheckins } from '../compact.js';
5
4
  const BidSchema = z.number().int().positive().describe('Untappd beer id (bid)');
6
5
  const CompactCheckins = z
7
6
  .boolean()
8
7
  .optional()
9
8
  .describe('Project each check-in to a slim summary (id, user, beer, rating, comment, venue, toast/comment counts) to save context (default false)');
10
- export function registerBeerTools(server) {
9
+ export function registerBeerTools(server, client) {
11
10
  server.registerTool('untappd_search_beer', {
12
11
  title: 'Search Untappd beers',
13
12
  description: 'Search Untappd for beers by name (optionally "Brewery Beer"). Returns ranked matches with their ' +
@@ -1,7 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
- export function registerBreweryTools(server) {
3
+ export function registerBreweryTools(server, client) {
5
4
  server.registerTool('untappd_search_brewery', {
6
5
  title: 'Search Untappd breweries',
7
6
  description: 'Search Untappd for breweries by name. Returns matches with their brewery id, location, type, and beer ' +
@@ -2,7 +2,6 @@ import { existsSync } from 'node:fs';
2
2
  import { extname } from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { textResult, toolAnnotations, schemaConfirm, fileBlob, McpToolError, createHelpfulError, messageOf } from '@chrischall/mcp-utils';
5
- import { client } from '../client.js';
6
5
  const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
7
6
  // Keyed by the NORMALISED extension that photoExt() returns (jpeg → jpg), so
8
7
  // there is no dead `jpeg` entry.
@@ -23,7 +22,7 @@ function localTimezone() {
23
22
  const gmt_offset = -new Date().getTimezoneOffset() / 60;
24
23
  return { timezone, gmt_offset };
25
24
  }
26
- export function registerCheckinTools(server) {
25
+ export function registerCheckinTools(server, client) {
27
26
  server.registerTool('untappd_toast', {
28
27
  title: 'Toast an Untappd check-in',
29
28
  description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have " +
@@ -1,8 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { compactCheckins } from '../compact.js';
5
- export function registerDiscoverTools(server) {
4
+ export function registerDiscoverTools(server, client) {
6
5
  server.registerTool('untappd_trending', {
7
6
  title: 'Get trending beers',
8
7
  description: 'Get the beers trending on Untappd right now, split into macro (big/widely-available) and micro ' +
@@ -1,8 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { compactCheckins } from '../compact.js';
5
- export function registerFeedTools(server) {
4
+ export function registerFeedTools(server, client) {
6
5
  server.registerTool('untappd_activity_feed', {
7
6
  title: 'Get Untappd friend activity feed',
8
7
  description: 'Get your Untappd friend activity feed — the recent check-ins from people you follow, newest first. Page ' +
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  // The endpoint PATHS below are confirmed from the Untappd app's own JS bundle
5
4
  // (`friend/request`, `friend/accept`, `friend/reject`, `friend/remove`). The
6
5
  // HTTP method (POST) mirrors the other captured action-writes (toast/comment),
@@ -43,7 +42,7 @@ const ACTIONS = [
43
42
  detail: 'Removes an existing friend, or cancels a friend request you sent.',
44
43
  },
45
44
  ];
46
- export function registerFriendActionTools(server) {
45
+ export function registerFriendActionTools(server, client) {
47
46
  for (const action of ACTIONS) {
48
47
  server.registerTool(action.tool, {
49
48
  title: action.title,
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  /**
5
4
  * Parse an untappd.com URL into its entity type and id. Path shapes (confirmed
6
5
  * from the app + site):
@@ -44,7 +43,7 @@ export function parseUntappdUrl(input) {
44
43
  }
45
44
  return null;
46
45
  }
47
- export function registerResolveTools(server) {
46
+ export function registerResolveTools(server, client) {
48
47
  server.registerTool('untappd_resolve', {
49
48
  title: 'Resolve an Untappd URL',
50
49
  description: 'Parse an untappd.com URL (a beer /b/, brewery /w/, venue /v/, user /user/, or check-in link) into its ' +
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { compactCheckins, compactWishlist, compactUserBeers } from '../compact.js';
5
4
  const UsernameArg = z
6
5
  .string()
@@ -8,8 +7,8 @@ const UsernameArg = z
8
7
  .optional()
9
8
  .describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
10
9
  /** Resolve the target username: explicit arg, else the configured login name. */
11
- function resolveUser(username) {
12
- const u = username ?? client.loginName;
10
+ function resolveUser(username, loginName) {
11
+ const u = username ?? loginName;
13
12
  if (!u) {
14
13
  throw createHelpfulError('No username given and no configured account to fall back to.', {
15
14
  hint: 'Pass `username`, or set UNTAPPD_USERNAME so user tools default to your own account.',
@@ -17,7 +16,7 @@ function resolveUser(username) {
17
16
  }
18
17
  return encodeURIComponent(u);
19
18
  }
20
- export function registerUserTools(server) {
19
+ export function registerUserTools(server, client) {
21
20
  server.registerTool('untappd_user_info', {
22
21
  title: 'Get Untappd user profile',
23
22
  description: "Get an Untappd user's profile: bio, location, total check-ins, distinct beers, badges, and stats. " +
@@ -28,7 +27,7 @@ export function registerUserTools(server) {
28
27
  compact: z.boolean().optional().describe('Return a slimmer record without embedded lists (default false)'),
29
28
  },
30
29
  }, async ({ username, compact }) => {
31
- 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 });
32
31
  return textResult(data);
33
32
  });
34
33
  server.registerTool('untappd_user_checkins', {
@@ -46,7 +45,7 @@ export function registerUserTools(server) {
46
45
  .describe('Project each check-in to a slim summary to save context (default false)'),
47
46
  },
48
47
  }, async ({ username, limit, max_id, compact }) => {
49
- const data = await client.get(`/user/checkins/${resolveUser(username)}`, { limit, max_id });
48
+ const data = await client.get(`/user/checkins/${resolveUser(username, client.loginName)}`, { limit, max_id });
50
49
  return textResult(compact ? compactCheckins(data) : data);
51
50
  });
52
51
  server.registerTool('untappd_user_wishlist', {
@@ -67,7 +66,7 @@ export function registerUserTools(server) {
67
66
  .describe('Project each beer to a slim summary (bid, name, brewery, style, abv, added_at) to save context (default false)'),
68
67
  },
69
68
  }, async ({ username, limit, offset, sort, compact }) => {
70
- const data = await client.get(`/user/wishlist/${resolveUser(username)}`, { limit, offset, sort });
69
+ const data = await client.get(`/user/wishlist/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
71
70
  return textResult(compact ? compactWishlist(data) : data);
72
71
  });
73
72
  server.registerTool('untappd_user_beers', {
@@ -89,7 +88,7 @@ export function registerUserTools(server) {
89
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)'),
90
89
  },
91
90
  }, async ({ username, limit, offset, sort, compact }) => {
92
- const data = await client.get(`/user/beers/${resolveUser(username)}`, { limit, offset, sort });
91
+ const data = await client.get(`/user/beers/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
93
92
  return textResult(compact ? compactUserBeers(data) : data);
94
93
  });
95
94
  server.registerTool('untappd_user_badges', {
@@ -102,7 +101,7 @@ export function registerUserTools(server) {
102
101
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
103
102
  },
104
103
  }, async ({ username, limit, offset }) => {
105
- 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 });
106
105
  return textResult(data);
107
106
  });
108
107
  server.registerTool('untappd_user_friends', {
@@ -115,7 +114,7 @@ export function registerUserTools(server) {
115
114
  offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
116
115
  },
117
116
  }, async ({ username, limit, offset }) => {
118
- 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 });
119
118
  return textResult(data);
120
119
  });
121
120
  server.registerTool('untappd_user_venues', {
@@ -133,7 +132,7 @@ export function registerUserTools(server) {
133
132
  .describe('Sort order (default most recent)'),
134
133
  },
135
134
  }, async ({ username, limit, offset, sort }) => {
136
- const data = await client.get(`/user/venues/${resolveUser(username)}`, { limit, offset, sort });
135
+ const data = await client.get(`/user/venues/${resolveUser(username, client.loginName)}`, { limit, offset, sort });
137
136
  return textResult(data);
138
137
  });
139
138
  server.registerTool('untappd_pending_friends', {
@@ -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,8 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
4
3
  import { compactCheckins } from '../compact.js';
5
- export function registerVenueTools(server) {
4
+ export function registerVenueTools(server, client) {
6
5
  server.registerTool('untappd_search_venue', {
7
6
  title: 'Search Untappd venues',
8
7
  description: 'Search Untappd for venues (bars, breweries, restaurants) by name. Returns matches with their venue id, ' +
@@ -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.1.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.1.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.1.0",
9
+ "version": "1.2.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "1.1.0",
14
+ "version": "1.2.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },