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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +15 -0
- package/dist/bundle.js +216 -192
- package/dist/client.js +126 -96
- package/dist/index.js +17 -17
- package/dist/tools/beer.js +1 -2
- package/dist/tools/brewery.js +1 -2
- package/dist/tools/checkin.js +1 -2
- package/dist/tools/discover.js +1 -2
- package/dist/tools/feed.js +1 -2
- package/dist/tools/friends.js +1 -2
- package/dist/tools/resolve.js +1 -2
- package/dist/tools/user.js +10 -11
- package/dist/tools/utilities.js +1 -2
- package/dist/tools/venue.js +1 -2
- 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/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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
});
|
package/dist/tools/beer.js
CHANGED
|
@@ -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 ' +
|
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,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 ' +
|
package/dist/tools/feed.js
CHANGED
|
@@ -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 ' +
|
package/dist/tools/friends.js
CHANGED
|
@@ -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,
|
package/dist/tools/resolve.js
CHANGED
|
@@ -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 ' +
|
package/dist/tools/user.js
CHANGED
|
@@ -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 ??
|
|
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', {
|
package/dist/tools/utilities.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
|
-
|
|
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 ' +
|
package/dist/tools/venue.js
CHANGED
|
@@ -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, ' +
|
package/dist/tools/wishlist.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
9
|
+
"version": "1.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|