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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -3
- package/dist/bundle.js +469 -175
- package/dist/client.js +126 -96
- package/dist/compact.js +90 -0
- package/dist/index.js +18 -14
- package/dist/tools/beer.js +15 -6
- package/dist/tools/brewery.js +1 -2
- package/dist/tools/checkin.js +1 -2
- package/dist/tools/discover.js +8 -4
- package/dist/tools/feed.js +8 -4
- package/dist/tools/friends.js +71 -0
- package/dist/tools/resolve.js +91 -0
- package/dist/tools/user.js +59 -16
- package/dist/tools/utilities.js +1 -2
- package/dist/tools/venue.js +20 -4
- package/dist/tools/wishlist.js +1 -2
- package/dist/version.js +1 -1
- package/package.json +11 -3
- package/server.json +2 -2
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.
|
|
5
|
-
// mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and
|
|
6
|
-
// lets .env override a host-provided value.
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
/**
|
|
147
|
+
/** True when enough is configured to make authenticated calls. */
|
|
73
148
|
get configured() {
|
|
74
|
-
|
|
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.
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
94
|
-
//
|
|
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
|
-
|
|
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.
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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.
|
|
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/compact.js
ADDED
|
@@ -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
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
});
|
package/dist/tools/beer.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import {
|
|
3
|
+
import { compactBeerSearch, compactCheckins } from '../compact.js';
|
|
4
4
|
const BidSchema = z.number().int().positive().describe('Untappd beer id (bid)');
|
|
5
|
-
|
|
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
|
}
|
package/dist/tools/brewery.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
|
|
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 ' +
|
package/dist/tools/checkin.js
CHANGED
|
@@ -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 " +
|
package/dist/tools/discover.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import {
|
|
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
|
}
|
package/dist/tools/feed.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import {
|
|
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
|
+
}
|