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.
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();
@@ -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
@@ -1,33 +1,37 @@
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';
7
8
  import { registerUserTools } from './tools/user.js';
8
9
  import { registerFeedTools } from './tools/feed.js';
10
+ import { registerResolveTools } from './tools/resolve.js';
9
11
  import { registerDiscoverTools } from './tools/discover.js';
12
+ import { registerFriendActionTools } from './tools/friends.js';
10
13
  import { registerWishlistTools } from './tools/wishlist.js';
11
14
  import { registerCheckinTools } from './tools/checkin.js';
12
15
  import { registerUtilityTools } from './tools/utilities.js';
13
- // The Untappd client is a module-level singleton (imported by each tool module)
14
- // that defers its config error to the first request. That preserves the
15
- // deferred-config-error pattern: the server boots and answers the host's
16
- // install-time tools/list smoke test even when credentials are absent — the
17
- // 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();
18
20
  await runMcp({
19
21
  name: 'untappd-mcp',
20
22
  version: VERSION,
21
23
  banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
22
24
  tools: [
23
- registerBeerTools,
24
- registerBreweryTools,
25
- registerVenueTools,
26
- registerUserTools,
27
- registerFeedTools,
28
- registerDiscoverTools,
29
- registerWishlistTools,
30
- registerCheckinTools,
31
- 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),
32
36
  ],
33
37
  });
@@ -1,8 +1,12 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
3
- import { client } from '../client.js';
3
+ import { compactBeerSearch, compactCheckins } from '../compact.js';
4
4
  const BidSchema = z.number().int().positive().describe('Untappd beer id (bid)');
5
- export function registerBeerTools(server) {
5
+ const CompactCheckins = z
6
+ .boolean()
7
+ .optional()
8
+ .describe('Project each check-in to a slim summary (id, user, beer, rating, comment, venue, toast/comment counts) to save context (default false)');
9
+ export function registerBeerTools(server, client) {
6
10
  server.registerTool('untappd_search_beer', {
7
11
  title: 'Search Untappd beers',
8
12
  description: 'Search Untappd for beers by name (optionally "Brewery Beer"). Returns ranked matches with their ' +
@@ -17,10 +21,14 @@ export function registerBeerTools(server) {
17
21
  .enum(['checkin', 'name', 'count'])
18
22
  .optional()
19
23
  .describe('Sort order: checkin (relevance, default), name, or count'),
24
+ compact: z
25
+ .boolean()
26
+ .optional()
27
+ .describe('Project each result to a slim summary (bid, name, brewery, style, abv, ibu, counts) to save context (default false)'),
20
28
  },
21
- }, async ({ query, limit, offset, sort }) => {
29
+ }, async ({ query, limit, offset, sort, compact }) => {
22
30
  const data = await client.get('/search/beer', { q: query, limit, offset, sort });
23
- return textResult(data);
31
+ return textResult(compact ? compactBeerSearch(data) : data);
24
32
  });
25
33
  server.registerTool('untappd_beer_info', {
26
34
  title: 'Get Untappd beer detail',
@@ -47,9 +55,10 @@ export function registerBeerTools(server) {
47
55
  bid: BidSchema,
48
56
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
49
57
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
58
+ compact: CompactCheckins,
50
59
  },
51
- }, async ({ bid, limit, max_id }) => {
60
+ }, async ({ bid, limit, max_id, compact }) => {
52
61
  const data = await client.get(`/beer/checkins/${bid}`, { limit, max_id });
53
- return textResult(data);
62
+ return textResult(compact ? compactCheckins(data) : data);
54
63
  });
55
64
  }
@@ -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,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 registerDiscoverTools(server) {
3
+ import { compactCheckins } from '../compact.js';
4
+ export function registerDiscoverTools(server, client) {
5
5
  server.registerTool('untappd_trending', {
6
6
  title: 'Get trending beers',
7
7
  description: 'Get the beers trending on Untappd right now, split into macro (big/widely-available) and micro ' +
@@ -35,9 +35,13 @@ export function registerDiscoverTools(server) {
35
35
  lng: z.number().min(-180).max(180).describe('Longitude of the location'),
36
36
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
37
37
  radius: z.number().int().min(1).max(25).optional().describe('Search radius (default per Untappd)'),
38
+ compact: z
39
+ .boolean()
40
+ .optional()
41
+ .describe('Project each check-in to a slim summary to save context (default false)'),
38
42
  },
39
- }, async ({ lat, lng, limit, radius }) => {
43
+ }, async ({ lat, lng, limit, radius, compact }) => {
40
44
  const data = await client.get('/thepub/local', { lat, lng, limit, radius });
41
- return textResult(data);
45
+ return textResult(compact ? compactCheckins(data) : data);
42
46
  });
43
47
  }
@@ -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 registerFeedTools(server) {
3
+ import { compactCheckins } from '../compact.js';
4
+ export function registerFeedTools(server, client) {
5
5
  server.registerTool('untappd_activity_feed', {
6
6
  title: 'Get Untappd friend activity feed',
7
7
  description: 'Get your Untappd friend activity feed — the recent check-ins from people you follow, newest first. Page ' +
@@ -10,10 +10,14 @@ export function registerFeedTools(server) {
10
10
  inputSchema: {
11
11
  limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
12
12
  max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
13
+ compact: z
14
+ .boolean()
15
+ .optional()
16
+ .describe('Project each check-in to a slim summary to save context (default false)'),
13
17
  },
14
- }, async ({ limit, max_id }) => {
18
+ }, async ({ limit, max_id, compact }) => {
15
19
  const data = await client.get('/checkin/recent', { limit, max_id });
16
- return textResult(data);
20
+ return textResult(compact ? compactCheckins(data) : data);
17
21
  });
18
22
  server.registerTool('untappd_checkin_info', {
19
23
  title: 'Get Untappd check-in detail',
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
3
+ // The endpoint PATHS below are confirmed from the Untappd app's own JS bundle
4
+ // (`friend/request`, `friend/accept`, `friend/reject`, `friend/remove`). The
5
+ // HTTP method (POST) mirrors the other captured action-writes (toast/comment),
6
+ // but — unlike the rest of this server's writes — these were NOT live-verified
7
+ // against the API, because doing so would send real friend requests to / alter
8
+ // real relationships with other people. They are confirm-gated so nothing fires
9
+ // without an explicit confirm: true.
10
+ const TargetUidSchema = z
11
+ .number()
12
+ .int()
13
+ .positive()
14
+ .describe('The target user id (uid) — from a user profile (response.user.uid) or a friends-list item');
15
+ const ACTIONS = [
16
+ {
17
+ tool: 'untappd_add_friend',
18
+ path: 'request',
19
+ title: 'Send an Untappd friend request',
20
+ verb: 'send a friend request to',
21
+ detail: 'Sends a friend request to another user, who will be notified.',
22
+ },
23
+ {
24
+ tool: 'untappd_accept_friend',
25
+ path: 'accept',
26
+ title: 'Accept an Untappd friend request',
27
+ verb: 'accept the incoming friend request from',
28
+ detail: 'Accepts an incoming friend request (see untappd_pending_friends for pending uids).',
29
+ },
30
+ {
31
+ tool: 'untappd_reject_friend',
32
+ path: 'reject',
33
+ title: 'Reject an Untappd friend request',
34
+ verb: 'reject the incoming friend request from',
35
+ detail: 'Rejects/ignores an incoming friend request (see untappd_pending_friends for pending uids).',
36
+ },
37
+ {
38
+ tool: 'untappd_remove_friend',
39
+ path: 'remove',
40
+ title: 'Remove an Untappd friend',
41
+ verb: 'remove your friendship with',
42
+ detail: 'Removes an existing friend, or cancels a friend request you sent.',
43
+ },
44
+ ];
45
+ export function registerFriendActionTools(server, client) {
46
+ for (const action of ACTIONS) {
47
+ server.registerTool(action.tool, {
48
+ title: action.title,
49
+ description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. ` +
50
+ 'Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it ' +
51
+ 'performs the action. Note: this endpoint path is taken from the Untappd app but is not otherwise ' +
52
+ 'independently verified.',
53
+ annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true }),
54
+ inputSchema: {
55
+ target_uid: TargetUidSchema,
56
+ confirm: schemaConfirm,
57
+ },
58
+ }, async ({ target_uid, confirm }) => {
59
+ if (confirm !== true) {
60
+ return textResult({
61
+ dryRun: true,
62
+ action: action.path,
63
+ target_uid,
64
+ note: `Dry run — re-run with confirm: true to ${action.verb} user ${target_uid}.`,
65
+ });
66
+ }
67
+ const data = await client.write('POST', `/friend/${action.path}/${target_uid}`);
68
+ return textResult({ done: true, action: action.path, target_uid, result: data?.result });
69
+ });
70
+ }
71
+ }