untappd-mcp 2.1.3 → 2.2.1
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 +27 -4
- package/dist/bundle.js +721 -134
- package/dist/cache/db.js +41 -4
- package/dist/cache/store.js +19 -0
- package/dist/tools/cache.js +42 -1
- package/dist/tools/checkin.js +119 -60
- package/dist/tools/confirm.js +20 -0
- package/dist/tools/friends.js +19 -15
- package/dist/tools/wishlist.js +32 -25
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/untappd-mcp/SKILL.md +18 -7
package/dist/cache/db.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
-
import { mkdirSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, mkdirSync, openSync } from 'node:fs';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { readEnvVar } from '@chrischall/mcp-utils';
|
|
@@ -46,16 +46,53 @@ export class CheckinCache extends LocalCacheStore {
|
|
|
46
46
|
super(new CheckinStoreCore(new NodeSqlDriver(db)));
|
|
47
47
|
this.db = db;
|
|
48
48
|
}
|
|
49
|
-
/**
|
|
49
|
+
/**
|
|
50
|
+
* Open (creating parent dirs) a file-backed cache, or `:memory:` for tests.
|
|
51
|
+
* The file holds other people's dated check-in and venue history, so it is
|
|
52
|
+
* kept owner-only: dirs 0700, the db (and any -wal/-shm/-journal) 0600.
|
|
53
|
+
*/
|
|
50
54
|
static open(path) {
|
|
51
55
|
if (path !== ':memory:')
|
|
52
|
-
|
|
53
|
-
|
|
56
|
+
preparePrivateFile(path);
|
|
57
|
+
const cache = new CheckinCache(new DatabaseSync(path));
|
|
58
|
+
if (path !== ':memory:')
|
|
59
|
+
tightenSidecars(path);
|
|
60
|
+
return cache;
|
|
54
61
|
}
|
|
55
62
|
close() {
|
|
56
63
|
this.db.close();
|
|
57
64
|
}
|
|
58
65
|
}
|
|
66
|
+
/**
|
|
67
|
+
* Create the cache's directory (0700 — `mode` applies to every dir mkdir
|
|
68
|
+
* creates) and the db file itself (0600) BEFORE SQLite opens it, so the file is
|
|
69
|
+
* never briefly umask-readable. Pre-existing loose entries from older versions
|
|
70
|
+
* are tightened too; chmod failures (e.g. a shared dir the user doesn't own)
|
|
71
|
+
* are best-effort, never fatal.
|
|
72
|
+
*/
|
|
73
|
+
function preparePrivateFile(path) {
|
|
74
|
+
const dir = dirname(path);
|
|
75
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
76
|
+
bestEffortChmod(dir, 0o700);
|
|
77
|
+
if (!existsSync(path))
|
|
78
|
+
closeSync(openSync(path, 'a', 0o600));
|
|
79
|
+
bestEffortChmod(path, 0o600);
|
|
80
|
+
}
|
|
81
|
+
/** SQLite side files inherit the db's mode, but re-assert in case a loose one lingers. */
|
|
82
|
+
function tightenSidecars(path) {
|
|
83
|
+
for (const suffix of ['-wal', '-shm', '-journal']) {
|
|
84
|
+
if (existsSync(path + suffix))
|
|
85
|
+
bestEffortChmod(path + suffix, 0o600);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
function bestEffortChmod(path, mode) {
|
|
89
|
+
try {
|
|
90
|
+
chmodSync(path, mode);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
/* best-effort — not every filesystem supports POSIX modes */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
59
96
|
/** Default on-disk cache path: `$UNTAPPD_CACHE_DB` or `~/.untappd-mcp/checkins.db`. */
|
|
60
97
|
export function defaultCachePath() {
|
|
61
98
|
return readEnvVar('UNTAPPD_CACHE_DB') ?? join(homedir(), '.untappd-mcp', 'checkins.db');
|
package/dist/cache/store.js
CHANGED
|
@@ -466,6 +466,22 @@ export class CheckinStoreCore {
|
|
|
466
466
|
const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
|
|
467
467
|
return this.db.all(`SELECT * FROM checkins WHERE ${where.join(' AND ')} ORDER BY ${order} LIMIT ?`, [...params, limit]);
|
|
468
468
|
}
|
|
469
|
+
/** Delete one user's cached rows (case-insensitive) in a single transaction. */
|
|
470
|
+
forgetUser(username) {
|
|
471
|
+
const key = username.toLowerCase();
|
|
472
|
+
const count = (table) => Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
|
|
473
|
+
const removed = {
|
|
474
|
+
checkins: count('checkins'),
|
|
475
|
+
distinct_beers: count('distinct_beers'),
|
|
476
|
+
sync_state: count('sync_state'),
|
|
477
|
+
};
|
|
478
|
+
this.db.transaction(() => {
|
|
479
|
+
this.db.run('DELETE FROM checkins WHERE username = ?', [key]);
|
|
480
|
+
this.db.run('DELETE FROM distinct_beers WHERE username = ?', [key]);
|
|
481
|
+
this.db.run('DELETE FROM sync_state WHERE username = ?', [key]);
|
|
482
|
+
});
|
|
483
|
+
return removed;
|
|
484
|
+
}
|
|
469
485
|
getBeerMeta(bids) {
|
|
470
486
|
if (bids.length === 0)
|
|
471
487
|
return [];
|
|
@@ -540,6 +556,9 @@ export class LocalCacheStore {
|
|
|
540
556
|
async upsertBeerMeta(rows) {
|
|
541
557
|
this.core.upsertBeerMeta(rows);
|
|
542
558
|
}
|
|
559
|
+
async forgetUser(username) {
|
|
560
|
+
return this.core.forgetUser(username);
|
|
561
|
+
}
|
|
543
562
|
}
|
|
544
563
|
// Escape LIKE wildcards in user input so a literal % or _ isn't treated as a
|
|
545
564
|
// wildcard. Our LIKE patterns don't set an ESCAPE clause, so we simply strip the
|
package/dist/tools/cache.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { RateLimitError, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
2
|
+
import { RateLimitError, confirmTokenParam, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
3
|
import { beerMetaFrom } from '../cache/store.js';
|
|
4
4
|
import { syncCheckins } from '../cache/sync.js';
|
|
5
5
|
import { syncUserBeers } from '../cache/sync-beers.js';
|
|
6
|
+
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
6
7
|
// Re-fetch cached beer metadata at most this often; a hit newer than this skips
|
|
7
8
|
// the beer/info API call.
|
|
8
9
|
const BEER_META_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
@@ -386,4 +387,44 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
386
387
|
freshness: await freshness(cache, user),
|
|
387
388
|
});
|
|
388
389
|
});
|
|
390
|
+
server.registerTool('untappd_cache_forget', {
|
|
391
|
+
title: "Forget a user's cached check-ins",
|
|
392
|
+
description: "Delete everything the LOCAL cache holds for one user — their cached check-ins (beer, rating, comment, venue, " +
|
|
393
|
+
'date), distinct-beers list and sync state — e.g. after syncing a friend you no longer want a history of. Only ' +
|
|
394
|
+
'the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it. Shared beer ' +
|
|
395
|
+
'metadata is kept. The preview shows the username and exactly how many rows will be removed. Omit username ' +
|
|
396
|
+
`for your own account. ${CONFIRM_FLOW}`,
|
|
397
|
+
annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
|
|
398
|
+
inputSchema: z.object({
|
|
399
|
+
username: UsernameArg,
|
|
400
|
+
confirmToken: confirmTokenParam,
|
|
401
|
+
}),
|
|
402
|
+
}, async ({ username, confirmToken }, ctx) => {
|
|
403
|
+
const user = resolveUser(username, client.loginName);
|
|
404
|
+
const cache = cacheProvider();
|
|
405
|
+
// Bound into the token: if a sync adds rows after the preview, the
|
|
406
|
+
// token no longer matches and the user re-confirms the real counts.
|
|
407
|
+
const counts = {
|
|
408
|
+
checkins: await cache.cachedCount(user),
|
|
409
|
+
distinct_beers: await cache.distinctBeersCount(user),
|
|
410
|
+
};
|
|
411
|
+
const gate = await confirmWrite(ctx, {
|
|
412
|
+
tool: 'untappd_cache_forget',
|
|
413
|
+
action: 'untappd.cache_forget',
|
|
414
|
+
message: `Review and confirm deleting ${user}'s data from the local Untappd cache:`,
|
|
415
|
+
confirmToken,
|
|
416
|
+
target: user.toLowerCase(),
|
|
417
|
+
payload: { username: user.toLowerCase(), ...counts },
|
|
418
|
+
preview: {
|
|
419
|
+
action: 'cache_forget',
|
|
420
|
+
username: user,
|
|
421
|
+
...counts,
|
|
422
|
+
note: 'Deletes these rows from the LOCAL cache only; Untappd itself is unchanged and a re-sync can restore them.',
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
if (gate)
|
|
426
|
+
return gate;
|
|
427
|
+
const removed = await cache.forgetUser(user);
|
|
428
|
+
return minifiedResult({ forgotten: true, username: user, removed });
|
|
429
|
+
});
|
|
389
430
|
}
|
package/dist/tools/checkin.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import { realpathSync, statSync } from 'node:fs';
|
|
1
|
+
import { createReadStream, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { pipeline } from 'node:stream/promises';
|
|
2
4
|
import { delimiter, extname } from 'node:path';
|
|
3
5
|
import { z } from 'zod';
|
|
4
|
-
import { McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead,
|
|
6
|
+
import { confirmTokenParam, McpToolError, assertPathWithinRoots, createHelpfulError, fileBlob, messageOf, minifiedResult, readEnvVar, readFileHead, sniffMimeBytes, toolAnnotations, UnreachableError, } from '@chrischall/mcp-utils';
|
|
7
|
+
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
5
8
|
const CheckinIdSchema = z.number().int().positive().describe('Untappd check-in id');
|
|
6
9
|
// Keyed by the NORMALISED extension that photoExt() returns (jpeg → jpg), so
|
|
7
10
|
// there is no dead `jpeg` entry.
|
|
@@ -23,7 +26,7 @@ function photoRoots() {
|
|
|
23
26
|
* free-form, model-supplied path, so an injected instruction could aim it at
|
|
24
27
|
* any file with an image-like name. Require the bytes to BE a JPEG/PNG that
|
|
25
28
|
* matches the extension, cap the size, honour the optional UNTAPPD_PHOTO_DIR
|
|
26
|
-
* allow-list, and return the resolved path + size so the
|
|
29
|
+
* allow-list, and return the resolved path + size so the preview shows a human
|
|
27
30
|
* exactly which file would be uploaded. Errors never echo the path back.
|
|
28
31
|
*/
|
|
29
32
|
async function checkPhoto(photoPath) {
|
|
@@ -61,7 +64,15 @@ async function checkPhoto(photoPath) {
|
|
|
61
64
|
hint: 'Attach a normal-sized JPEG or PNG photo.',
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
|
-
|
|
67
|
+
// Re-check UNTAPPD_PHOTO_DIR at open time, so a swap after the check above can't escape it.
|
|
68
|
+
let head;
|
|
69
|
+
try {
|
|
70
|
+
head = await readFileHead(real, 16, { allowedRoots: roots });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new McpToolError('Photo file not found or not readable.');
|
|
74
|
+
}
|
|
75
|
+
const sniffed = sniffMimeBytes(head);
|
|
65
76
|
if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
|
|
66
77
|
throw createHelpfulError(sniffed === 'image/jpeg' || sniffed === 'image/png'
|
|
67
78
|
? `Photo content (${sniffed}) does not match its .${ext} extension.`
|
|
@@ -69,6 +80,16 @@ async function checkPhoto(photoPath) {
|
|
|
69
80
|
}
|
|
70
81
|
return { path: real, size_bytes: size, ext, content_type: sniffed };
|
|
71
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* sha256 of the photo's bytes, streamed. Bound into the confirm token so a
|
|
85
|
+
* different image swapped in at the same path — even one of identical size and
|
|
86
|
+
* type — no longer matches the preview the user approved.
|
|
87
|
+
*/
|
|
88
|
+
async function photoSha256(path) {
|
|
89
|
+
const hash = createHash('sha256');
|
|
90
|
+
await pipeline(createReadStream(path), hash);
|
|
91
|
+
return hash.digest('hex');
|
|
92
|
+
}
|
|
72
93
|
// Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
|
|
73
94
|
const RatingSchema = z
|
|
74
95
|
.number()
|
|
@@ -159,22 +180,30 @@ export function registerCheckinTools(server, client) {
|
|
|
159
180
|
server.registerTool('untappd_toast', {
|
|
160
181
|
title: 'Toast an Untappd check-in',
|
|
161
182
|
description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have " +
|
|
162
|
-
|
|
163
|
-
'call; with confirm: true it posts. Writes to your Untappd account and is visible to others.',
|
|
183
|
+
`already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
|
|
164
184
|
annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
165
185
|
inputSchema: z.object({
|
|
166
186
|
checkin_id: CheckinIdSchema,
|
|
167
|
-
|
|
187
|
+
confirmToken: confirmTokenParam,
|
|
168
188
|
}),
|
|
169
|
-
}, async ({ checkin_id,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
189
|
+
}, async ({ checkin_id, confirmToken }, ctx) => {
|
|
190
|
+
const request = { method: 'POST', path: `/checkin/toast/${checkin_id}` };
|
|
191
|
+
const gate = await confirmWrite(ctx, {
|
|
192
|
+
tool: 'untappd_toast',
|
|
193
|
+
action: 'untappd.toast',
|
|
194
|
+
message: 'Review and confirm toggling your toast on this Untappd check-in:',
|
|
195
|
+
confirmToken,
|
|
196
|
+
target: checkin_id,
|
|
197
|
+
payload: request,
|
|
198
|
+
preview: {
|
|
173
199
|
action: 'toast',
|
|
174
200
|
checkin_id,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
201
|
+
...request,
|
|
202
|
+
note: 'Toggles your toast on this check-in (removes it if you have already toasted).',
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
if (gate)
|
|
206
|
+
return gate;
|
|
178
207
|
const data = await nonIdempotentWrite(() => client.write('POST', `/checkin/toast/${checkin_id}`), `Untappd did not answer the toast request for check-in ${checkin_id} in time, so it may or may not have ` +
|
|
179
208
|
'been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with ' +
|
|
180
209
|
`untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`);
|
|
@@ -182,67 +211,87 @@ export function registerCheckinTools(server, client) {
|
|
|
182
211
|
});
|
|
183
212
|
server.registerTool('untappd_add_comment', {
|
|
184
213
|
title: 'Comment on an Untappd check-in',
|
|
185
|
-
description: 'Post a comment on a check-in from YOUR account.
|
|
186
|
-
|
|
214
|
+
description: 'Post a comment on a check-in from YOUR account. Writes to your Untappd account and is visible to others. ' +
|
|
215
|
+
CONFIRM_FLOW,
|
|
187
216
|
annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
188
217
|
inputSchema: z.object({
|
|
189
218
|
checkin_id: CheckinIdSchema,
|
|
190
219
|
comment: z.string().min(1).max(2000).describe('Comment text to post'),
|
|
191
|
-
|
|
220
|
+
confirmToken: confirmTokenParam,
|
|
192
221
|
}),
|
|
193
|
-
}, async ({ checkin_id, comment,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
222
|
+
}, async ({ checkin_id, comment, confirmToken }, ctx) => {
|
|
223
|
+
const request = { method: 'POST', path: `/checkin/addcomment/${checkin_id}`, form: { comment } };
|
|
224
|
+
const gate = await confirmWrite(ctx, {
|
|
225
|
+
tool: 'untappd_add_comment',
|
|
226
|
+
action: 'untappd.add_comment',
|
|
227
|
+
message: 'Review and confirm posting this comment from your Untappd account:',
|
|
228
|
+
confirmToken,
|
|
229
|
+
target: checkin_id,
|
|
230
|
+
payload: request,
|
|
231
|
+
preview: {
|
|
197
232
|
action: 'add_comment',
|
|
198
233
|
checkin_id,
|
|
199
234
|
comment,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
235
|
+
...request,
|
|
236
|
+
note: 'Posts this comment to the check-in from your Untappd account, visible to others.',
|
|
237
|
+
},
|
|
238
|
+
});
|
|
239
|
+
if (gate)
|
|
240
|
+
return gate;
|
|
203
241
|
const data = await nonIdempotentWrite(() => client.write('POST', `/checkin/addcomment/${checkin_id}`, { form: { comment } }), `Untappd did not answer in time, so the comment may have been posted already. Check the comments with ` +
|
|
204
242
|
`untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`);
|
|
205
243
|
return minifiedResult({ posted: true, checkin_id, response: data });
|
|
206
244
|
});
|
|
207
245
|
server.registerTool('untappd_delete_comment', {
|
|
208
246
|
title: 'Delete a comment from an Untappd check-in',
|
|
209
|
-
description:
|
|
210
|
-
'confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.',
|
|
247
|
+
description: `Delete one of YOUR comments by its comment id (the id from a check-in's comments list). ${CONFIRM_FLOW}`,
|
|
211
248
|
annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
|
|
212
249
|
inputSchema: z.object({
|
|
213
250
|
comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
|
|
214
|
-
|
|
251
|
+
confirmToken: confirmTokenParam,
|
|
215
252
|
}),
|
|
216
|
-
}, async ({ comment_id,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
253
|
+
}, async ({ comment_id, confirmToken }, ctx) => {
|
|
254
|
+
const request = { method: 'POST', path: `/checkin/deletecomment/${comment_id}` };
|
|
255
|
+
const gate = await confirmWrite(ctx, {
|
|
256
|
+
tool: 'untappd_delete_comment',
|
|
257
|
+
action: 'untappd.delete_comment',
|
|
258
|
+
message: 'Review and confirm deleting this comment from your Untappd account:',
|
|
259
|
+
confirmToken,
|
|
260
|
+
target: comment_id,
|
|
261
|
+
payload: request,
|
|
262
|
+
preview: { action: 'delete_comment', comment_id, ...request, note: 'Deletes this comment from your Untappd account.' },
|
|
263
|
+
});
|
|
264
|
+
if (gate)
|
|
265
|
+
return gate;
|
|
225
266
|
const data = await client.write('POST', `/checkin/deletecomment/${comment_id}`);
|
|
226
267
|
return minifiedResult({ deleted: true, comment_id, result: data?.result });
|
|
227
268
|
});
|
|
228
269
|
server.registerTool('untappd_delete_checkin', {
|
|
229
270
|
title: 'Delete an Untappd check-in',
|
|
230
|
-
description:
|
|
231
|
-
'confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.',
|
|
271
|
+
description: `Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. ${CONFIRM_FLOW}`,
|
|
232
272
|
annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
|
|
233
273
|
inputSchema: z.object({
|
|
234
274
|
checkin_id: CheckinIdSchema,
|
|
235
|
-
|
|
275
|
+
confirmToken: confirmTokenParam,
|
|
236
276
|
}),
|
|
237
|
-
}, async ({ checkin_id,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
277
|
+
}, async ({ checkin_id, confirmToken }, ctx) => {
|
|
278
|
+
const request = { method: 'POST', path: `/checkin/delete/${checkin_id}` };
|
|
279
|
+
const gate = await confirmWrite(ctx, {
|
|
280
|
+
tool: 'untappd_delete_checkin',
|
|
281
|
+
action: 'untappd.delete_checkin',
|
|
282
|
+
message: 'Review and confirm PERMANENTLY deleting this Untappd check-in:',
|
|
283
|
+
confirmToken,
|
|
284
|
+
target: checkin_id,
|
|
285
|
+
payload: request,
|
|
286
|
+
preview: {
|
|
241
287
|
action: 'delete_checkin',
|
|
242
288
|
checkin_id,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
289
|
+
...request,
|
|
290
|
+
note: 'PERMANENTLY deletes this check-in. This cannot be undone.',
|
|
291
|
+
},
|
|
292
|
+
});
|
|
293
|
+
if (gate)
|
|
294
|
+
return gate;
|
|
246
295
|
const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
|
|
247
296
|
return minifiedResult({ deleted: true, checkin_id, result: data?.result });
|
|
248
297
|
});
|
|
@@ -250,8 +299,8 @@ export function registerCheckinTools(server, client) {
|
|
|
250
299
|
title: 'Check in a beer on Untappd',
|
|
251
300
|
description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
|
|
252
301
|
'(bid) from untappd_search_beer; optionally a rating (0–5 in 0.25 steps), a shout (comment), a venue via ' +
|
|
253
|
-
'foursquare_id, and a local photo via photo_path (JPEG/PNG).
|
|
254
|
-
|
|
302
|
+
'foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo ' +
|
|
303
|
+
`that will be posted. ${CONFIRM_FLOW}`,
|
|
255
304
|
annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
256
305
|
inputSchema: z.object({
|
|
257
306
|
bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
|
|
@@ -262,7 +311,7 @@ export function registerCheckinTools(server, client) {
|
|
|
262
311
|
.string()
|
|
263
312
|
.optional()
|
|
264
313
|
.describe('Optional path to a local JPEG/PNG photo (max 15 MB) to attach — it is published publicly. Only use a ' +
|
|
265
|
-
'file the user explicitly chose; the
|
|
314
|
+
'file the user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
|
|
266
315
|
geolat: z.number().optional().describe('Optional latitude of the check-in'),
|
|
267
316
|
geolng: z.number().optional().describe('Optional longitude of the check-in'),
|
|
268
317
|
container_id: z
|
|
@@ -277,13 +326,15 @@ export function registerCheckinTools(server, client) {
|
|
|
277
326
|
.describe("The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to " +
|
|
278
327
|
"UNTAPPD_TIMEZONE, else the server's own zone — which on a hosted connector is usually UTC, so pass it " +
|
|
279
328
|
'when you know where the user is.'),
|
|
280
|
-
|
|
329
|
+
confirmToken: confirmTokenParam,
|
|
281
330
|
}),
|
|
282
|
-
}, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz,
|
|
331
|
+
}, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken }, ctx) => {
|
|
283
332
|
const { timezone, gmt_offset } = checkinTimezone(requestedTz);
|
|
284
|
-
// Vetted on
|
|
285
|
-
//
|
|
333
|
+
// Vetted on every call, so the preview names the exact file (resolved path
|
|
334
|
+
// + size) that will be published — and a file whose bytes change between
|
|
335
|
+
// the preview and the confirmed call no longer matches the token.
|
|
286
336
|
const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
|
|
337
|
+
const photo_sha256 = photo ? await photoSha256(photo.path) : undefined;
|
|
287
338
|
const form = {
|
|
288
339
|
bid,
|
|
289
340
|
rating: rating !== undefined ? rating.toFixed(2) : undefined,
|
|
@@ -298,15 +349,23 @@ export function registerCheckinTools(server, client) {
|
|
|
298
349
|
photo_file_ext: photo?.ext,
|
|
299
350
|
platform: 'ios',
|
|
300
351
|
};
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
352
|
+
const request = { method: 'POST', path: '/checkin/add', form };
|
|
353
|
+
const gate = await confirmWrite(ctx, {
|
|
354
|
+
tool: 'untappd_checkin',
|
|
355
|
+
action: 'untappd.checkin',
|
|
356
|
+
message: 'Review and confirm posting this check-in to your public Untappd feed:',
|
|
357
|
+
confirmToken,
|
|
358
|
+
target: bid,
|
|
359
|
+
payload: { ...request, photo, photo_sha256 },
|
|
360
|
+
preview: {
|
|
304
361
|
action: 'checkin',
|
|
305
|
-
|
|
362
|
+
...request,
|
|
306
363
|
photo: photo ? { ...photo, note: 'this exact file will be uploaded PUBLICLY after the check-in is created' } : undefined,
|
|
307
|
-
note: '
|
|
308
|
-
}
|
|
309
|
-
}
|
|
364
|
+
note: 'POSTs this check-in to your public Untappd feed.',
|
|
365
|
+
},
|
|
366
|
+
});
|
|
367
|
+
if (gate)
|
|
368
|
+
return gate;
|
|
310
369
|
// Open the photo BEFORE creating the check-in, so a missing/unreadable
|
|
311
370
|
// file fails fast without leaving an orphaned photo-less check-in behind.
|
|
312
371
|
let blob;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { confirmationFromEnv, requireConfirmationWithFallback } from '@chrischall/mcp-utils';
|
|
2
|
+
/** The sentence every gated write's description carries. */
|
|
3
|
+
export const CONFIRM_FLOW = 'Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call ' +
|
|
4
|
+
'returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).';
|
|
5
|
+
/**
|
|
6
|
+
* Gate a write behind the fleet confirmation flow. `undefined` means proceed;
|
|
7
|
+
* anything else is the result to return unchanged. Callers rebuild `payload`
|
|
8
|
+
* and `preview` from the arguments on every call, so a token only authorises
|
|
9
|
+
* the exact request its preview showed.
|
|
10
|
+
*/
|
|
11
|
+
export function confirmWrite(ctx, c) {
|
|
12
|
+
return requireConfirmationWithFallback(ctx, confirmationFromEnv({
|
|
13
|
+
action: c.action,
|
|
14
|
+
message: c.message,
|
|
15
|
+
details: c.preview,
|
|
16
|
+
tool: c.tool,
|
|
17
|
+
confirmToken: c.confirmToken,
|
|
18
|
+
subject: () => ({ target: String(c.target), payload: c.payload, preview: c.preview }),
|
|
19
|
+
}));
|
|
20
|
+
}
|
package/dist/tools/friends.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { confirmTokenParam, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
3
4
|
// The endpoint PATHS below are confirmed from the Untappd app's own JS bundle
|
|
4
5
|
// (`friend/request`, `friend/accept`, `friend/reject`, `friend/remove`). The
|
|
5
6
|
// HTTP method (POST) mirrors the other captured action-writes (toast/comment),
|
|
6
7
|
// but — unlike the rest of this server's writes — these were NOT live-verified
|
|
7
8
|
// against the API, because doing so would send real friend requests to / alter
|
|
8
|
-
// real relationships with other people. They are
|
|
9
|
-
// without
|
|
9
|
+
// real relationships with other people. They are confirmation-gated so nothing
|
|
10
|
+
// fires without the user approving the exact request first.
|
|
10
11
|
const TargetUidSchema = z
|
|
11
12
|
.number()
|
|
12
13
|
.int()
|
|
@@ -47,8 +48,7 @@ export function registerFriendActionTools(server, client) {
|
|
|
47
48
|
server.registerTool(action.tool, {
|
|
48
49
|
title: action.title,
|
|
49
50
|
description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. ` +
|
|
50
|
-
|
|
51
|
-
'performs the action. Note: this endpoint path is taken from the Untappd app but is not otherwise ' +
|
|
51
|
+
`${CONFIRM_FLOW} Note: this endpoint path is taken from the Untappd app but is not otherwise ` +
|
|
52
52
|
'independently verified.',
|
|
53
53
|
// All four reach ANOTHER PERSON — the description says so itself: "affects
|
|
54
54
|
// a real relationship with another person". A friend request cannot be
|
|
@@ -57,17 +57,21 @@ export function registerFriendActionTools(server, client) {
|
|
|
57
57
|
annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true, destructive: true }),
|
|
58
58
|
inputSchema: z.object({
|
|
59
59
|
target_uid: TargetUidSchema,
|
|
60
|
-
|
|
60
|
+
confirmToken: confirmTokenParam,
|
|
61
61
|
}),
|
|
62
|
-
}, async ({ target_uid,
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
62
|
+
}, async ({ target_uid, confirmToken }, ctx) => {
|
|
63
|
+
const request = { method: 'POST', path: `/friend/${action.path}/${target_uid}` };
|
|
64
|
+
const gate = await confirmWrite(ctx, {
|
|
65
|
+
tool: action.tool,
|
|
66
|
+
action: `untappd.friend_${action.path}`,
|
|
67
|
+
message: `Review and confirm: ${action.verb} Untappd user ${target_uid}.`,
|
|
68
|
+
confirmToken,
|
|
69
|
+
target: target_uid,
|
|
70
|
+
payload: request,
|
|
71
|
+
preview: { action: action.path, target_uid, ...request, note: `Will ${action.verb} user ${target_uid}.` },
|
|
72
|
+
});
|
|
73
|
+
if (gate)
|
|
74
|
+
return gate;
|
|
71
75
|
const data = await client.write('POST', `/friend/${action.path}/${target_uid}`);
|
|
72
76
|
return minifiedResult({ done: true, action: action.path, target_uid, result: data?.result });
|
|
73
77
|
});
|
package/dist/tools/wishlist.js
CHANGED
|
@@ -1,46 +1,53 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import {
|
|
2
|
+
import { confirmTokenParam, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
3
4
|
const BidSchema = z.number().int().positive().describe('Untappd beer id (bid) — from untappd_search_beer');
|
|
4
5
|
export function registerWishlistTools(server, client) {
|
|
5
6
|
server.registerTool('untappd_wishlist_add', {
|
|
6
7
|
title: 'Add a beer to your wishlist',
|
|
7
|
-
description:
|
|
8
|
-
'makes NO network call; with confirm: true it adds. Writes to your account.',
|
|
8
|
+
description: `Add a beer to YOUR Untappd wishlist by its bid. Writes to your account. ${CONFIRM_FLOW}`,
|
|
9
9
|
annotations: toolAnnotations({ title: 'Add a beer to your wishlist', readOnly: false, idempotent: true, openWorld: true, destructive: false }),
|
|
10
10
|
inputSchema: z.object({
|
|
11
11
|
bid: BidSchema,
|
|
12
|
-
|
|
12
|
+
confirmToken: confirmTokenParam,
|
|
13
13
|
}),
|
|
14
|
-
}, async ({ bid,
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
}, async ({ bid, confirmToken }, ctx) => {
|
|
15
|
+
const request = { method: 'GET', path: '/user/wishlist/add', query: { bid } };
|
|
16
|
+
const gate = await confirmWrite(ctx, {
|
|
17
|
+
tool: 'untappd_wishlist_add',
|
|
18
|
+
action: 'untappd.wishlist_add',
|
|
19
|
+
message: 'Review and confirm adding this beer to your Untappd wishlist:',
|
|
20
|
+
confirmToken,
|
|
21
|
+
target: bid,
|
|
22
|
+
payload: request,
|
|
23
|
+
preview: { action: 'wishlist_add', bid, ...request, note: 'Adds this beer to your Untappd wishlist.' },
|
|
24
|
+
});
|
|
25
|
+
if (gate)
|
|
26
|
+
return gate;
|
|
23
27
|
const data = await client.write('GET', '/user/wishlist/add', { query: { bid } });
|
|
24
28
|
return minifiedResult({ added: true, bid, result: data?.result });
|
|
25
29
|
});
|
|
26
30
|
server.registerTool('untappd_wishlist_remove', {
|
|
27
31
|
title: 'Remove a beer from your wishlist',
|
|
28
|
-
description:
|
|
29
|
-
'and makes NO network call; with confirm: true it removes. Writes to your account.',
|
|
32
|
+
description: `Remove a beer from YOUR Untappd wishlist by its bid. Writes to your account. ${CONFIRM_FLOW}`,
|
|
30
33
|
annotations: toolAnnotations({ title: 'Remove a beer from your wishlist', readOnly: false, idempotent: true, openWorld: true, destructive: false }),
|
|
31
34
|
inputSchema: z.object({
|
|
32
35
|
bid: BidSchema,
|
|
33
|
-
|
|
36
|
+
confirmToken: confirmTokenParam,
|
|
34
37
|
}),
|
|
35
|
-
}, async ({ bid,
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
}, async ({ bid, confirmToken }, ctx) => {
|
|
39
|
+
const request = { method: 'GET', path: '/user/wishlist/delete', query: { bid } };
|
|
40
|
+
const gate = await confirmWrite(ctx, {
|
|
41
|
+
tool: 'untappd_wishlist_remove',
|
|
42
|
+
action: 'untappd.wishlist_remove',
|
|
43
|
+
message: 'Review and confirm removing this beer from your Untappd wishlist:',
|
|
44
|
+
confirmToken,
|
|
45
|
+
target: bid,
|
|
46
|
+
payload: request,
|
|
47
|
+
preview: { action: 'wishlist_remove', bid, ...request, note: 'Removes this beer from your Untappd wishlist.' },
|
|
48
|
+
});
|
|
49
|
+
if (gate)
|
|
50
|
+
return gate;
|
|
44
51
|
const data = await client.write('GET', '/user/wishlist/delete', { query: { bid } });
|
|
45
52
|
return minifiedResult({ removed: true, bid, result: data?.result });
|
|
46
53
|
});
|
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 = '2.1
|
|
6
|
+
export const VERSION = '2.2.1'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "untappd-mcp",
|
|
3
|
-
"version": "2.1
|
|
3
|
+
"version": "2.2.1",
|
|
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>",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"@chrischall/mcp-utils": "^2.
|
|
48
|
+
"@chrischall/mcp-utils": "^2.6.0",
|
|
49
49
|
"@modelcontextprotocol/server": "^2.0.0",
|
|
50
50
|
"ai": "^7.0.19",
|
|
51
51
|
"dotenv": "^18.0.1",
|