untappd-mcp 0.0.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 +29 -0
- package/.claude-plugin/plugin.json +16 -0
- package/.mcp.json +14 -0
- package/README.md +75 -0
- package/SKILL.md +47 -0
- package/dist/bundle.js +31806 -0
- package/dist/client.js +252 -0
- package/dist/index.js +29 -0
- package/dist/tools/beer.js +41 -0
- package/dist/tools/brewery.js +32 -0
- package/dist/tools/checkin.js +110 -0
- package/dist/tools/feed.js +30 -0
- package/dist/tools/user.js +108 -0
- package/dist/tools/utilities.js +27 -0
- package/dist/tools/venue.js +31 -0
- package/dist/version.js +6 -0
- package/package.json +55 -0
- package/server.json +50 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { dirname, join } from 'path';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
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 });
|
|
9
|
+
const BASE_URL = 'https://api.untappd.com/v4';
|
|
10
|
+
const SERVICE = 'Untappd';
|
|
11
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
12
|
+
// Non-secret client constants that mimic the Untappd iPad app (v4.7.13). The
|
|
13
|
+
// API accepts requests carrying these; NONE are user secrets (the secrets are
|
|
14
|
+
// UNTAPPD_CLIENT_ID / UNTAPPD_CLIENT_SECRET / UNTAPPD_USERNAME / _PASSWORD,
|
|
15
|
+
// which come from the environment). All overridable via env for forward-compat
|
|
16
|
+
// when the app version bumps.
|
|
17
|
+
const DEFAULTS = {
|
|
18
|
+
utv: '4.0.0',
|
|
19
|
+
appVersion: '4.7.13',
|
|
20
|
+
deviceName: 'iPad',
|
|
21
|
+
deviceVersion: '26.5',
|
|
22
|
+
devicePlatform: 'iPadOS',
|
|
23
|
+
userAgent: 'Untappd/4.7.13 (ios; iPadOS 26.5)',
|
|
24
|
+
// Untappd keys the returned access token to this device id; any stable UUID
|
|
25
|
+
// works. Override with UNTAPPD_DEVICE_ID to pin your own.
|
|
26
|
+
deviceUdid: 'A1B2C3D4-0000-4000-8000-0000000000AA',
|
|
27
|
+
};
|
|
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 };
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
function missingCredsError() {
|
|
39
|
+
const missing = ['UNTAPPD_USERNAME', 'UNTAPPD_PASSWORD', 'UNTAPPD_CLIENT_ID', 'UNTAPPD_CLIENT_SECRET'].filter((k) => !readEnvVar(k));
|
|
40
|
+
return createHelpfulError(`Untappd credentials are not configured — missing ${missing.join(', ') || 'credentials'}.`, {
|
|
41
|
+
hint: 'Set UNTAPPD_USERNAME and UNTAPPD_PASSWORD (your Untappd login), plus UNTAPPD_CLIENT_ID and ' +
|
|
42
|
+
'UNTAPPD_CLIENT_SECRET (the Untappd mobile app client credentials). See the README for how to obtain them.',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export class UntappdClient {
|
|
46
|
+
fetchImpl;
|
|
47
|
+
creds;
|
|
48
|
+
configError;
|
|
49
|
+
utv = readEnvVar('UNTAPPD_UTV') ?? DEFAULTS.utv;
|
|
50
|
+
deviceUdid = readEnvVar('UNTAPPD_DEVICE_ID') ?? DEFAULTS.deviceUdid;
|
|
51
|
+
userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
|
|
52
|
+
token;
|
|
53
|
+
loginInFlight = null;
|
|
54
|
+
/**
|
|
55
|
+
* Defer the config error so the server can still start (and respond to the
|
|
56
|
+
* host's install-time tools/list smoke test) when credentials aren't set yet.
|
|
57
|
+
* Tool calls re-raise the error at request time.
|
|
58
|
+
*/
|
|
59
|
+
constructor(opts = {}) {
|
|
60
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
61
|
+
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
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Whether credentials are configured (used by the healthcheck tool). */
|
|
73
|
+
get configured() {
|
|
74
|
+
return this.configError === null;
|
|
75
|
+
}
|
|
76
|
+
/** The configured login name — the default `username` for user-scoped tools. */
|
|
77
|
+
get loginName() {
|
|
78
|
+
return this.creds?.username ?? null;
|
|
79
|
+
}
|
|
80
|
+
requireCreds() {
|
|
81
|
+
if (this.configError)
|
|
82
|
+
throw this.configError;
|
|
83
|
+
return this.creds;
|
|
84
|
+
}
|
|
85
|
+
baseHeaders() {
|
|
86
|
+
return {
|
|
87
|
+
'User-Agent': this.userAgent,
|
|
88
|
+
Accept: 'application/json',
|
|
89
|
+
'x-untappd-app': 'ios',
|
|
90
|
+
'x-untappd-app-version': DEFAULTS.appVersion,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// One HTTP attempt with a hard timeout. Network/timeout failures become an
|
|
94
|
+
// UnreachableError; HTTP status handling is left to the caller.
|
|
95
|
+
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
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Obtain an access token, logging in via xauth (username/password) on demand.
|
|
115
|
+
* A single shared in-flight promise coalesces concurrent tool calls so they
|
|
116
|
+
* never trigger duplicate logins.
|
|
117
|
+
*/
|
|
118
|
+
async ensureToken() {
|
|
119
|
+
if (this.token)
|
|
120
|
+
return this.token;
|
|
121
|
+
if (!this.loginInFlight) {
|
|
122
|
+
this.loginInFlight = this.login().finally(() => {
|
|
123
|
+
this.loginInFlight = null;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return this.loginInFlight;
|
|
127
|
+
}
|
|
128
|
+
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(),
|
|
144
|
+
});
|
|
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
|
+
this.token = token;
|
|
170
|
+
return token;
|
|
171
|
+
}
|
|
172
|
+
async parseJson(res, method, path) {
|
|
173
|
+
if (res.status === 429) {
|
|
174
|
+
const ra = Number(res.headers.get('retry-after'));
|
|
175
|
+
throw new RateLimitError(SERVICE, ra > 0 ? ra : undefined);
|
|
176
|
+
}
|
|
177
|
+
const text = await res.text();
|
|
178
|
+
let data;
|
|
179
|
+
if (text.length) {
|
|
180
|
+
try {
|
|
181
|
+
data = JSON.parse(text);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
/* leave undefined; handled below */
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
const meta = data?.meta;
|
|
188
|
+
if (!res.ok) {
|
|
189
|
+
const detail = meta?.error_detail || meta?.error_type;
|
|
190
|
+
throw new McpToolError(formatApiError(res.status, method, path, detail ?? text, { service: SERVICE }));
|
|
191
|
+
}
|
|
192
|
+
if (typeof meta?.code === 'number' && meta.code >= 400) {
|
|
193
|
+
const detail = meta.error_detail || meta.error_type || `code ${meta.code}`;
|
|
194
|
+
throw new McpToolError(`Untappd ${method} ${path} failed (${meta.code}): ${detail}`);
|
|
195
|
+
}
|
|
196
|
+
return (data?.response ?? data);
|
|
197
|
+
}
|
|
198
|
+
async request(method, path, opts, isRetry = false) {
|
|
199
|
+
const token = await this.ensureToken();
|
|
200
|
+
const headers = this.baseHeaders();
|
|
201
|
+
// The Untappd app carries the token as a query param on reads and as an
|
|
202
|
+
// `Authorization: Bearer` header on writes (with the client credentials in
|
|
203
|
+
// the query). We mirror that exactly — both shapes are the real captured
|
|
204
|
+
// requests the app itself makes.
|
|
205
|
+
let query;
|
|
206
|
+
if (opts.auth === 'bearer') {
|
|
207
|
+
const c = this.requireCreds();
|
|
208
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
209
|
+
query = { ...opts.query, client_id: c.clientId, client_secret: c.clientSecret, utv: this.utv };
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
query = { ...opts.query, access_token: token, utv: this.utv };
|
|
213
|
+
}
|
|
214
|
+
const qs = buildQueryString(query);
|
|
215
|
+
let body;
|
|
216
|
+
if (opts.form) {
|
|
217
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
218
|
+
const f = new URLSearchParams();
|
|
219
|
+
for (const [k, v] of Object.entries(opts.form)) {
|
|
220
|
+
if (v !== undefined && v !== null)
|
|
221
|
+
f.append(k, String(v));
|
|
222
|
+
}
|
|
223
|
+
body = f.toString();
|
|
224
|
+
}
|
|
225
|
+
const res = await this.send(method, `${BASE_URL}${path}${qs}`, { headers, body });
|
|
226
|
+
// A 401 means the cached token went stale — drop it and log in once more.
|
|
227
|
+
if (res.status === 401 && !isRetry) {
|
|
228
|
+
this.token = null;
|
|
229
|
+
return this.request(method, path, opts, true);
|
|
230
|
+
}
|
|
231
|
+
return this.parseJson(res, method, path);
|
|
232
|
+
}
|
|
233
|
+
/** Authenticated read (token in the query, as the app does for GETs). */
|
|
234
|
+
async get(path, query = {}) {
|
|
235
|
+
return this.request('GET', path, { query, auth: 'query' });
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Authenticated write. Attaches the `Authorization: Bearer` token and client
|
|
239
|
+
* credentials centrally (the app's write shape); `form` is sent as
|
|
240
|
+
* application/x-www-form-urlencoded. Every mutating tool routes through here.
|
|
241
|
+
*/
|
|
242
|
+
async write(method, path, opts = {}) {
|
|
243
|
+
return this.request(method, path, { ...opts, auth: 'bearer' });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Module-level singleton shared by every tool module. Constructing it here (not
|
|
248
|
+
* in `index.ts`) keeps the deferred-config-error pattern: the server boots and
|
|
249
|
+
* answers the host's install-time tools/list smoke test even when credentials
|
|
250
|
+
* are absent — the error only surfaces on the first tool call.
|
|
251
|
+
*/
|
|
252
|
+
export const client = new UntappdClient();
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runMcp } from '@chrischall/mcp-utils';
|
|
3
|
+
import { VERSION } from './version.js';
|
|
4
|
+
import { registerBeerTools } from './tools/beer.js';
|
|
5
|
+
import { registerBreweryTools } from './tools/brewery.js';
|
|
6
|
+
import { registerVenueTools } from './tools/venue.js';
|
|
7
|
+
import { registerUserTools } from './tools/user.js';
|
|
8
|
+
import { registerFeedTools } from './tools/feed.js';
|
|
9
|
+
import { registerCheckinTools } from './tools/checkin.js';
|
|
10
|
+
import { registerUtilityTools } from './tools/utilities.js';
|
|
11
|
+
// The Untappd client is a module-level singleton (imported by each tool module)
|
|
12
|
+
// that defers its config error to the first request. That preserves the
|
|
13
|
+
// deferred-config-error pattern: the server boots and answers the host's
|
|
14
|
+
// install-time tools/list smoke test even when credentials are absent — the
|
|
15
|
+
// configuration error only surfaces on the first tool call.
|
|
16
|
+
await runMcp({
|
|
17
|
+
name: 'untappd-mcp',
|
|
18
|
+
version: VERSION,
|
|
19
|
+
banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
|
|
20
|
+
tools: [
|
|
21
|
+
registerBeerTools,
|
|
22
|
+
registerBreweryTools,
|
|
23
|
+
registerVenueTools,
|
|
24
|
+
registerUserTools,
|
|
25
|
+
registerFeedTools,
|
|
26
|
+
registerCheckinTools,
|
|
27
|
+
registerUtilityTools,
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
const BidSchema = z.number().int().positive().describe('Untappd beer id (bid)');
|
|
5
|
+
export function registerBeerTools(server) {
|
|
6
|
+
server.registerTool('untappd_search_beer', {
|
|
7
|
+
title: 'Search Untappd beers',
|
|
8
|
+
description: 'Search Untappd for beers by name (optionally "Brewery Beer"). Returns ranked matches with their ' +
|
|
9
|
+
'beer id (bid), brewery, style, ABV, IBU, and global rating. Feed a bid into untappd_beer_info for full ' +
|
|
10
|
+
'detail. Read-only.',
|
|
11
|
+
annotations: toolAnnotations({ title: 'Search Untappd beers', readOnly: true, idempotent: true, openWorld: true }),
|
|
12
|
+
inputSchema: {
|
|
13
|
+
query: z.string().min(1).describe('Beer name to search for'),
|
|
14
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max results (1–50, default 25)'),
|
|
15
|
+
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
16
|
+
sort: z
|
|
17
|
+
.enum(['checkin', 'name', 'count'])
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Sort order: checkin (relevance, default), name, or count'),
|
|
20
|
+
},
|
|
21
|
+
}, async ({ query, limit, offset, sort }) => {
|
|
22
|
+
const data = await client.get('/search/beer', { q: query, limit, offset, sort });
|
|
23
|
+
return textResult(data);
|
|
24
|
+
});
|
|
25
|
+
server.registerTool('untappd_beer_info', {
|
|
26
|
+
title: 'Get Untappd beer detail',
|
|
27
|
+
description: 'Get full detail for a beer by its Untappd beer id (bid): description, style, ABV, IBU, brewery, rating, ' +
|
|
28
|
+
'total check-in count, and (unless compact) recent activity. Get a bid from untappd_search_beer. Read-only.',
|
|
29
|
+
annotations: toolAnnotations({ title: 'Get Untappd beer detail', readOnly: true, idempotent: true, openWorld: true }),
|
|
30
|
+
inputSchema: {
|
|
31
|
+
bid: BidSchema,
|
|
32
|
+
compact: z
|
|
33
|
+
.boolean()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Return a slimmer record without the embedded recent-activity lists (default false)'),
|
|
36
|
+
},
|
|
37
|
+
}, async ({ bid, compact }) => {
|
|
38
|
+
const data = await client.get(`/beer/info/${bid}`, { compact: compact ? 'true' : undefined });
|
|
39
|
+
return textResult(data);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
export function registerBreweryTools(server) {
|
|
5
|
+
server.registerTool('untappd_search_brewery', {
|
|
6
|
+
title: 'Search Untappd breweries',
|
|
7
|
+
description: 'Search Untappd for breweries by name. Returns matches with their brewery id, location, type, and beer ' +
|
|
8
|
+
'count. Feed a brewery id into untappd_brewery_info for full detail. Read-only.',
|
|
9
|
+
annotations: toolAnnotations({ title: 'Search Untappd breweries', readOnly: true, idempotent: true, openWorld: true }),
|
|
10
|
+
inputSchema: {
|
|
11
|
+
query: z.string().min(1).describe('Brewery name to search for'),
|
|
12
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max results (1–50, default 25)'),
|
|
13
|
+
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
14
|
+
},
|
|
15
|
+
}, async ({ query, limit, offset }) => {
|
|
16
|
+
const data = await client.get('/search/brewery', { q: query, limit, offset });
|
|
17
|
+
return textResult(data);
|
|
18
|
+
});
|
|
19
|
+
server.registerTool('untappd_brewery_info', {
|
|
20
|
+
title: 'Get Untappd brewery detail',
|
|
21
|
+
description: 'Get full detail for a brewery by its Untappd brewery id: description, location, type, rating, total ' +
|
|
22
|
+
'check-ins, and popular beers. Get an id from untappd_search_brewery. Read-only.',
|
|
23
|
+
annotations: toolAnnotations({ title: 'Get Untappd brewery detail', readOnly: true, idempotent: true, openWorld: true }),
|
|
24
|
+
inputSchema: {
|
|
25
|
+
brewery_id: z.number().int().positive().describe('Untappd brewery id'),
|
|
26
|
+
compact: z.boolean().optional().describe('Return a slimmer record without embedded activity (default false)'),
|
|
27
|
+
},
|
|
28
|
+
}, async ({ brewery_id, compact }) => {
|
|
29
|
+
const data = await client.get(`/brewery/info/${brewery_id}`, { compact: compact ? 'true' : undefined });
|
|
30
|
+
return textResult(data);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations, schemaConfirm } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
|
|
5
|
+
// Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
|
|
6
|
+
const RatingSchema = z
|
|
7
|
+
.number()
|
|
8
|
+
.min(0)
|
|
9
|
+
.max(5)
|
|
10
|
+
.refine((r) => Math.round(r * 4) === r * 4, { message: 'rating must be a multiple of 0.25' });
|
|
11
|
+
function localTimezone() {
|
|
12
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
|
|
13
|
+
// getTimezoneOffset is minutes behind UTC (positive = behind), so negate for GMT offset in hours.
|
|
14
|
+
const gmt_offset = -new Date().getTimezoneOffset() / 60;
|
|
15
|
+
return { timezone, gmt_offset };
|
|
16
|
+
}
|
|
17
|
+
export function registerCheckinTools(server) {
|
|
18
|
+
server.registerTool('untappd_toast', {
|
|
19
|
+
title: 'Toast an Untappd check-in',
|
|
20
|
+
description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have " +
|
|
21
|
+
'already toasted removes the toast. Without confirm: true it returns a dry-run preview and makes NO network ' +
|
|
22
|
+
'call; with confirm: true it posts. Writes to your Untappd account and is visible to others.',
|
|
23
|
+
annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true }),
|
|
24
|
+
inputSchema: {
|
|
25
|
+
checkin_id: CheckinIdSchema,
|
|
26
|
+
confirm: schemaConfirm,
|
|
27
|
+
},
|
|
28
|
+
}, async ({ checkin_id, confirm }) => {
|
|
29
|
+
if (confirm !== true) {
|
|
30
|
+
return textResult({
|
|
31
|
+
dryRun: true,
|
|
32
|
+
action: 'toast',
|
|
33
|
+
checkin_id,
|
|
34
|
+
note: 'Dry run — re-run with confirm: true to toggle your toast on this check-in.',
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const data = await client.write('POST', `/checkin/toast/${checkin_id}`);
|
|
38
|
+
return textResult({ toggled: true, checkin_id, result: data?.result, like_type: data?.like_type });
|
|
39
|
+
});
|
|
40
|
+
server.registerTool('untappd_add_comment', {
|
|
41
|
+
title: 'Comment on an Untappd check-in',
|
|
42
|
+
description: 'Post a comment on a check-in from YOUR account. Without confirm: true it returns a dry-run preview and ' +
|
|
43
|
+
'makes NO network call; with confirm: true it posts. Writes to your Untappd account and is visible to others.',
|
|
44
|
+
annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true }),
|
|
45
|
+
inputSchema: {
|
|
46
|
+
checkin_id: CheckinIdSchema,
|
|
47
|
+
comment: z.string().min(1).max(2000).describe('Comment text to post'),
|
|
48
|
+
confirm: schemaConfirm,
|
|
49
|
+
},
|
|
50
|
+
}, async ({ checkin_id, comment, confirm }) => {
|
|
51
|
+
if (confirm !== true) {
|
|
52
|
+
return textResult({
|
|
53
|
+
dryRun: true,
|
|
54
|
+
action: 'add_comment',
|
|
55
|
+
checkin_id,
|
|
56
|
+
comment,
|
|
57
|
+
note: 'Dry run — re-run with confirm: true to post this comment to your Untappd account.',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const data = await client.write('POST', `/checkin/addcomment/${checkin_id}`, { form: { comment } });
|
|
61
|
+
return textResult({ posted: true, checkin_id, response: data });
|
|
62
|
+
});
|
|
63
|
+
server.registerTool('untappd_checkin', {
|
|
64
|
+
title: 'Check in a beer on Untappd',
|
|
65
|
+
description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
|
|
66
|
+
'(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), and a venue via ' +
|
|
67
|
+
'foursquare_id (from a venue result). Without confirm: true it returns a dry-run preview of the exact fields ' +
|
|
68
|
+
'and makes NO network call; with confirm: true it posts. Photo attachment is not supported.',
|
|
69
|
+
annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true }),
|
|
70
|
+
inputSchema: {
|
|
71
|
+
bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
|
|
72
|
+
rating: RatingSchema.optional().describe('Rating 0–5 in 0.25 increments (omit for no rating)'),
|
|
73
|
+
shout: z.string().max(2000).optional().describe('Optional shout / comment text for the check-in'),
|
|
74
|
+
foursquare_id: z.string().optional().describe('Optional Foursquare venue id to tag the check-in location'),
|
|
75
|
+
geolat: z.number().optional().describe('Optional latitude of the check-in'),
|
|
76
|
+
geolng: z.number().optional().describe('Optional longitude of the check-in'),
|
|
77
|
+
container_id: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe('Optional serving container id (e.g. 1 = draft, 2 = bottle, 3 = can)'),
|
|
82
|
+
confirm: schemaConfirm,
|
|
83
|
+
},
|
|
84
|
+
}, async ({ bid, rating, shout, foursquare_id, geolat, geolng, container_id, confirm }) => {
|
|
85
|
+
const { timezone, gmt_offset } = localTimezone();
|
|
86
|
+
const form = {
|
|
87
|
+
bid,
|
|
88
|
+
rating: rating !== undefined ? rating.toFixed(2) : undefined,
|
|
89
|
+
shout: shout || undefined,
|
|
90
|
+
foursquare_id,
|
|
91
|
+
geolat,
|
|
92
|
+
geolng,
|
|
93
|
+
container_id,
|
|
94
|
+
timezone,
|
|
95
|
+
gmt_offset,
|
|
96
|
+
is_photo: 'false',
|
|
97
|
+
platform: 'ios',
|
|
98
|
+
};
|
|
99
|
+
if (confirm !== true) {
|
|
100
|
+
return textResult({
|
|
101
|
+
dryRun: true,
|
|
102
|
+
action: 'checkin',
|
|
103
|
+
form,
|
|
104
|
+
note: 'Dry run — re-run with confirm: true to POST this check-in to your public Untappd feed.',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const data = await client.write('POST', '/checkin/add', { form });
|
|
108
|
+
return textResult({ checked_in: true, checkin_id: data?.checkin_id, response: data });
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
export function registerFeedTools(server) {
|
|
5
|
+
server.registerTool('untappd_activity_feed', {
|
|
6
|
+
title: 'Get Untappd friend activity feed',
|
|
7
|
+
description: 'Get your Untappd friend activity feed — the recent check-ins from people you follow, newest first. Page ' +
|
|
8
|
+
'backwards with max_id (the pagination.max_id from a prior call). Read-only.',
|
|
9
|
+
annotations: toolAnnotations({ title: 'Get Untappd friend activity feed', readOnly: true, idempotent: false, openWorld: true }),
|
|
10
|
+
inputSchema: {
|
|
11
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
|
|
12
|
+
max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
|
|
13
|
+
},
|
|
14
|
+
}, async ({ limit, max_id }) => {
|
|
15
|
+
const data = await client.get('/checkin/recent', { limit, max_id });
|
|
16
|
+
return textResult(data);
|
|
17
|
+
});
|
|
18
|
+
server.registerTool('untappd_checkin_info', {
|
|
19
|
+
title: 'Get Untappd check-in detail',
|
|
20
|
+
description: 'Get full detail for a single check-in by its id: the beer, rating, comment, photos, venue, badges earned, ' +
|
|
21
|
+
'toasts, and comments. Get a check-in id from a feed or user-checkins result. Read-only.',
|
|
22
|
+
annotations: toolAnnotations({ title: 'Get Untappd check-in detail', readOnly: true, idempotent: true, openWorld: true }),
|
|
23
|
+
inputSchema: {
|
|
24
|
+
checkin_id: z.number().int().positive().describe('Untappd check-in id'),
|
|
25
|
+
},
|
|
26
|
+
}, async ({ checkin_id }) => {
|
|
27
|
+
const data = await client.get(`/checkin/view/${checkin_id}`);
|
|
28
|
+
return textResult(data);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { client } from '../client.js';
|
|
4
|
+
const UsernameArg = z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.optional()
|
|
8
|
+
.describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
|
|
9
|
+
/** Resolve the target username: explicit arg, else the configured login name. */
|
|
10
|
+
function resolveUser(username) {
|
|
11
|
+
const u = username ?? client.loginName;
|
|
12
|
+
if (!u) {
|
|
13
|
+
throw createHelpfulError('No username given and no configured account to fall back to.', {
|
|
14
|
+
hint: 'Pass `username`, or set UNTAPPD_USERNAME so user tools default to your own account.',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return encodeURIComponent(u);
|
|
18
|
+
}
|
|
19
|
+
export function registerUserTools(server) {
|
|
20
|
+
server.registerTool('untappd_user_info', {
|
|
21
|
+
title: 'Get Untappd user profile',
|
|
22
|
+
description: "Get an Untappd user's profile: bio, location, total check-ins, distinct beers, badges, and stats. " +
|
|
23
|
+
'Omit username for your own account. Read-only.',
|
|
24
|
+
annotations: toolAnnotations({ title: 'Get Untappd user profile', readOnly: true, idempotent: true, openWorld: true }),
|
|
25
|
+
inputSchema: {
|
|
26
|
+
username: UsernameArg,
|
|
27
|
+
compact: z.boolean().optional().describe('Return a slimmer record without embedded lists (default false)'),
|
|
28
|
+
},
|
|
29
|
+
}, async ({ username, compact }) => {
|
|
30
|
+
const data = await client.get(`/user/info/${resolveUser(username)}`, { compact: compact ? 'true' : undefined });
|
|
31
|
+
return textResult(data);
|
|
32
|
+
});
|
|
33
|
+
server.registerTool('untappd_user_checkins', {
|
|
34
|
+
title: 'Get Untappd user check-ins',
|
|
35
|
+
description: "Get a user's recent check-ins (most recent first): the beer, rating, comment, venue, and toasts/comments. " +
|
|
36
|
+
'Page backwards with max_id (the pagination.max_id from a prior call). Omit username for your own. Read-only.',
|
|
37
|
+
annotations: toolAnnotations({ title: 'Get Untappd user check-ins', readOnly: true, idempotent: true, openWorld: true }),
|
|
38
|
+
inputSchema: {
|
|
39
|
+
username: UsernameArg,
|
|
40
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max check-ins (1–50, default 25)'),
|
|
41
|
+
max_id: z.number().int().positive().optional().describe('Return check-ins older than this id (for paging)'),
|
|
42
|
+
},
|
|
43
|
+
}, async ({ username, limit, max_id }) => {
|
|
44
|
+
const data = await client.get(`/user/checkins/${resolveUser(username)}`, { limit, max_id });
|
|
45
|
+
return textResult(data);
|
|
46
|
+
});
|
|
47
|
+
server.registerTool('untappd_user_wishlist', {
|
|
48
|
+
title: 'Get Untappd user wishlist',
|
|
49
|
+
description: "Get the beers on a user's wishlist. Supports sorting and paging. Omit username for your own account. Read-only.",
|
|
50
|
+
annotations: toolAnnotations({ title: 'Get Untappd user wishlist', readOnly: true, idempotent: true, openWorld: true }),
|
|
51
|
+
inputSchema: {
|
|
52
|
+
username: UsernameArg,
|
|
53
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max beers (1–50, default 25)'),
|
|
54
|
+
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
55
|
+
sort: z
|
|
56
|
+
.enum(['date', 'name', 'brewery', 'style', 'rating', 'abv'])
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('Sort order (default date added, newest first)'),
|
|
59
|
+
},
|
|
60
|
+
}, async ({ username, limit, offset, sort }) => {
|
|
61
|
+
const data = await client.get(`/user/wishlist/${resolveUser(username)}`, { limit, offset, sort });
|
|
62
|
+
return textResult(data);
|
|
63
|
+
});
|
|
64
|
+
server.registerTool('untappd_user_beers', {
|
|
65
|
+
title: 'Get Untappd distinct beers',
|
|
66
|
+
description: "Get the distinct (unique) beers a user has ever checked in, with their rating and check-in count per beer. " +
|
|
67
|
+
'Supports sorting and paging. Omit username for your own account. Read-only.',
|
|
68
|
+
annotations: toolAnnotations({ title: 'Get Untappd distinct beers', readOnly: true, idempotent: true, openWorld: true }),
|
|
69
|
+
inputSchema: {
|
|
70
|
+
username: UsernameArg,
|
|
71
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max beers (1–50, default 25)'),
|
|
72
|
+
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
73
|
+
sort: z
|
|
74
|
+
.enum(['date', 'checkin', 'highest_rated', 'lowest_rated', 'name', 'this_month', 'highest_abv'])
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('Sort order (default date, most recent first)'),
|
|
77
|
+
},
|
|
78
|
+
}, async ({ username, limit, offset, sort }) => {
|
|
79
|
+
const data = await client.get(`/user/beers/${resolveUser(username)}`, { limit, offset, sort });
|
|
80
|
+
return textResult(data);
|
|
81
|
+
});
|
|
82
|
+
server.registerTool('untappd_user_badges', {
|
|
83
|
+
title: 'Get Untappd user badges',
|
|
84
|
+
description: "Get the badges a user has earned, most recent first. Omit username for your own account. Read-only.",
|
|
85
|
+
annotations: toolAnnotations({ title: 'Get Untappd user badges', readOnly: true, idempotent: true, openWorld: true }),
|
|
86
|
+
inputSchema: {
|
|
87
|
+
username: UsernameArg,
|
|
88
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max badges (1–50, default 25)'),
|
|
89
|
+
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
90
|
+
},
|
|
91
|
+
}, async ({ username, limit, offset }) => {
|
|
92
|
+
const data = await client.get(`/user/badges/${resolveUser(username)}`, { limit, offset });
|
|
93
|
+
return textResult(data);
|
|
94
|
+
});
|
|
95
|
+
server.registerTool('untappd_user_friends', {
|
|
96
|
+
title: 'Get Untappd user friends',
|
|
97
|
+
description: "Get a user's friend list. Omit username for your own account. Read-only.",
|
|
98
|
+
annotations: toolAnnotations({ title: 'Get Untappd user friends', readOnly: true, idempotent: true, openWorld: true }),
|
|
99
|
+
inputSchema: {
|
|
100
|
+
username: UsernameArg,
|
|
101
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max friends (1–50, default 25)'),
|
|
102
|
+
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
103
|
+
},
|
|
104
|
+
}, async ({ username, limit, offset }) => {
|
|
105
|
+
const data = await client.get(`/user/friends/${resolveUser(username)}`, { limit, offset });
|
|
106
|
+
return textResult(data);
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
|
+
import { client } from '../client.js';
|
|
3
|
+
export function registerUtilityTools(server) {
|
|
4
|
+
server.registerTool('untappd_healthcheck', {
|
|
5
|
+
title: 'Untappd healthcheck',
|
|
6
|
+
description: 'Verify Untappd connectivity and that credentials are configured and can log in. Performs a lightweight ' +
|
|
7
|
+
'authenticated request (your recent feed) and reports whether it succeeded. Read-only.',
|
|
8
|
+
annotations: toolAnnotations({ title: 'Untappd healthcheck', readOnly: true, idempotent: true, openWorld: true }),
|
|
9
|
+
inputSchema: {},
|
|
10
|
+
}, async () => {
|
|
11
|
+
if (!client.configured) {
|
|
12
|
+
return textResult({
|
|
13
|
+
ok: false,
|
|
14
|
+
configured: false,
|
|
15
|
+
note: 'Untappd credentials are not set. Configure UNTAPPD_USERNAME, UNTAPPD_PASSWORD, UNTAPPD_CLIENT_ID, and UNTAPPD_CLIENT_SECRET.',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
const feed = await client.get('/checkin/recent', { limit: 1 });
|
|
19
|
+
return textResult({
|
|
20
|
+
ok: true,
|
|
21
|
+
configured: true,
|
|
22
|
+
account: client.loginName,
|
|
23
|
+
feed_reachable: feed?.checkins !== undefined,
|
|
24
|
+
note: 'Logged in to Untappd and fetched the friend feed successfully.',
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
}
|