untappd-mcp 2.1.3 → 2.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 +16 -2
- package/dist/bundle.js +630 -129
- package/dist/tools/checkin.js +110 -59
- 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 +10 -5
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) {
|
|
@@ -69,6 +72,16 @@ async function checkPhoto(photoPath) {
|
|
|
69
72
|
}
|
|
70
73
|
return { path: real, size_bytes: size, ext, content_type: sniffed };
|
|
71
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* sha256 of the photo's bytes, streamed. Bound into the confirm token so a
|
|
77
|
+
* different image swapped in at the same path — even one of identical size and
|
|
78
|
+
* type — no longer matches the preview the user approved.
|
|
79
|
+
*/
|
|
80
|
+
async function photoSha256(path) {
|
|
81
|
+
const hash = createHash('sha256');
|
|
82
|
+
await pipeline(createReadStream(path), hash);
|
|
83
|
+
return hash.digest('hex');
|
|
84
|
+
}
|
|
72
85
|
// Untappd ratings are 0–5 in 0.25 increments; 0 (or omitted) means no rating.
|
|
73
86
|
const RatingSchema = z
|
|
74
87
|
.number()
|
|
@@ -159,22 +172,30 @@ export function registerCheckinTools(server, client) {
|
|
|
159
172
|
server.registerTool('untappd_toast', {
|
|
160
173
|
title: 'Toast an Untappd check-in',
|
|
161
174
|
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.',
|
|
175
|
+
`already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
|
|
164
176
|
annotations: toolAnnotations({ title: 'Toast an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
165
177
|
inputSchema: z.object({
|
|
166
178
|
checkin_id: CheckinIdSchema,
|
|
167
|
-
|
|
179
|
+
confirmToken: confirmTokenParam,
|
|
168
180
|
}),
|
|
169
|
-
}, async ({ checkin_id,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
181
|
+
}, async ({ checkin_id, confirmToken }, ctx) => {
|
|
182
|
+
const request = { method: 'POST', path: `/checkin/toast/${checkin_id}` };
|
|
183
|
+
const gate = await confirmWrite(ctx, {
|
|
184
|
+
tool: 'untappd_toast',
|
|
185
|
+
action: 'untappd.toast',
|
|
186
|
+
message: 'Review and confirm toggling your toast on this Untappd check-in:',
|
|
187
|
+
confirmToken,
|
|
188
|
+
target: checkin_id,
|
|
189
|
+
payload: request,
|
|
190
|
+
preview: {
|
|
173
191
|
action: 'toast',
|
|
174
192
|
checkin_id,
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
193
|
+
...request,
|
|
194
|
+
note: 'Toggles your toast on this check-in (removes it if you have already toasted).',
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
if (gate)
|
|
198
|
+
return gate;
|
|
178
199
|
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
200
|
'been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with ' +
|
|
180
201
|
`untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`);
|
|
@@ -182,67 +203,87 @@ export function registerCheckinTools(server, client) {
|
|
|
182
203
|
});
|
|
183
204
|
server.registerTool('untappd_add_comment', {
|
|
184
205
|
title: 'Comment on an Untappd check-in',
|
|
185
|
-
description: 'Post a comment on a check-in from YOUR account.
|
|
186
|
-
|
|
206
|
+
description: 'Post a comment on a check-in from YOUR account. Writes to your Untappd account and is visible to others. ' +
|
|
207
|
+
CONFIRM_FLOW,
|
|
187
208
|
annotations: toolAnnotations({ title: 'Comment on an Untappd check-in', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
188
209
|
inputSchema: z.object({
|
|
189
210
|
checkin_id: CheckinIdSchema,
|
|
190
211
|
comment: z.string().min(1).max(2000).describe('Comment text to post'),
|
|
191
|
-
|
|
212
|
+
confirmToken: confirmTokenParam,
|
|
192
213
|
}),
|
|
193
|
-
}, async ({ checkin_id, comment,
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
214
|
+
}, async ({ checkin_id, comment, confirmToken }, ctx) => {
|
|
215
|
+
const request = { method: 'POST', path: `/checkin/addcomment/${checkin_id}`, form: { comment } };
|
|
216
|
+
const gate = await confirmWrite(ctx, {
|
|
217
|
+
tool: 'untappd_add_comment',
|
|
218
|
+
action: 'untappd.add_comment',
|
|
219
|
+
message: 'Review and confirm posting this comment from your Untappd account:',
|
|
220
|
+
confirmToken,
|
|
221
|
+
target: checkin_id,
|
|
222
|
+
payload: request,
|
|
223
|
+
preview: {
|
|
197
224
|
action: 'add_comment',
|
|
198
225
|
checkin_id,
|
|
199
226
|
comment,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
227
|
+
...request,
|
|
228
|
+
note: 'Posts this comment to the check-in from your Untappd account, visible to others.',
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
if (gate)
|
|
232
|
+
return gate;
|
|
203
233
|
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
234
|
`untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`);
|
|
205
235
|
return minifiedResult({ posted: true, checkin_id, response: data });
|
|
206
236
|
});
|
|
207
237
|
server.registerTool('untappd_delete_comment', {
|
|
208
238
|
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.',
|
|
239
|
+
description: `Delete one of YOUR comments by its comment id (the id from a check-in's comments list). ${CONFIRM_FLOW}`,
|
|
211
240
|
annotations: toolAnnotations({ title: 'Delete a comment from an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
|
|
212
241
|
inputSchema: z.object({
|
|
213
242
|
comment_id: z.number().int().positive().describe('Untappd comment id (from a check-in\'s comments.items)'),
|
|
214
|
-
|
|
243
|
+
confirmToken: confirmTokenParam,
|
|
215
244
|
}),
|
|
216
|
-
}, async ({ comment_id,
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
245
|
+
}, async ({ comment_id, confirmToken }, ctx) => {
|
|
246
|
+
const request = { method: 'POST', path: `/checkin/deletecomment/${comment_id}` };
|
|
247
|
+
const gate = await confirmWrite(ctx, {
|
|
248
|
+
tool: 'untappd_delete_comment',
|
|
249
|
+
action: 'untappd.delete_comment',
|
|
250
|
+
message: 'Review and confirm deleting this comment from your Untappd account:',
|
|
251
|
+
confirmToken,
|
|
252
|
+
target: comment_id,
|
|
253
|
+
payload: request,
|
|
254
|
+
preview: { action: 'delete_comment', comment_id, ...request, note: 'Deletes this comment from your Untappd account.' },
|
|
255
|
+
});
|
|
256
|
+
if (gate)
|
|
257
|
+
return gate;
|
|
225
258
|
const data = await client.write('POST', `/checkin/deletecomment/${comment_id}`);
|
|
226
259
|
return minifiedResult({ deleted: true, comment_id, result: data?.result });
|
|
227
260
|
});
|
|
228
261
|
server.registerTool('untappd_delete_checkin', {
|
|
229
262
|
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.',
|
|
263
|
+
description: `Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. ${CONFIRM_FLOW}`,
|
|
232
264
|
annotations: toolAnnotations({ title: 'Delete an Untappd check-in', readOnly: false, idempotent: true, openWorld: true, destructive: true }),
|
|
233
265
|
inputSchema: z.object({
|
|
234
266
|
checkin_id: CheckinIdSchema,
|
|
235
|
-
|
|
267
|
+
confirmToken: confirmTokenParam,
|
|
236
268
|
}),
|
|
237
|
-
}, async ({ checkin_id,
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
269
|
+
}, async ({ checkin_id, confirmToken }, ctx) => {
|
|
270
|
+
const request = { method: 'POST', path: `/checkin/delete/${checkin_id}` };
|
|
271
|
+
const gate = await confirmWrite(ctx, {
|
|
272
|
+
tool: 'untappd_delete_checkin',
|
|
273
|
+
action: 'untappd.delete_checkin',
|
|
274
|
+
message: 'Review and confirm PERMANENTLY deleting this Untappd check-in:',
|
|
275
|
+
confirmToken,
|
|
276
|
+
target: checkin_id,
|
|
277
|
+
payload: request,
|
|
278
|
+
preview: {
|
|
241
279
|
action: 'delete_checkin',
|
|
242
280
|
checkin_id,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
281
|
+
...request,
|
|
282
|
+
note: 'PERMANENTLY deletes this check-in. This cannot be undone.',
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
if (gate)
|
|
286
|
+
return gate;
|
|
246
287
|
const data = await client.write('POST', `/checkin/delete/${checkin_id}`);
|
|
247
288
|
return minifiedResult({ deleted: true, checkin_id, result: data?.result });
|
|
248
289
|
});
|
|
@@ -250,8 +291,8 @@ export function registerCheckinTools(server, client) {
|
|
|
250
291
|
title: 'Check in a beer on Untappd',
|
|
251
292
|
description: 'Post a NEW beer check-in to YOUR Untappd account — this publishes to your public feed. Provide the beer id ' +
|
|
252
293
|
'(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
|
-
|
|
294
|
+
'foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo ' +
|
|
295
|
+
`that will be posted. ${CONFIRM_FLOW}`,
|
|
255
296
|
annotations: toolAnnotations({ title: 'Check in a beer on Untappd', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
256
297
|
inputSchema: z.object({
|
|
257
298
|
bid: z.number().int().positive().describe('Untappd beer id to check in (from untappd_search_beer)'),
|
|
@@ -262,7 +303,7 @@ export function registerCheckinTools(server, client) {
|
|
|
262
303
|
.string()
|
|
263
304
|
.optional()
|
|
264
305
|
.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
|
|
306
|
+
'file the user explicitly chose; the preview shows the resolved path and size for them to confirm.'),
|
|
266
307
|
geolat: z.number().optional().describe('Optional latitude of the check-in'),
|
|
267
308
|
geolng: z.number().optional().describe('Optional longitude of the check-in'),
|
|
268
309
|
container_id: z
|
|
@@ -277,13 +318,15 @@ export function registerCheckinTools(server, client) {
|
|
|
277
318
|
.describe("The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to " +
|
|
278
319
|
"UNTAPPD_TIMEZONE, else the server's own zone — which on a hosted connector is usually UTC, so pass it " +
|
|
279
320
|
'when you know where the user is.'),
|
|
280
|
-
|
|
321
|
+
confirmToken: confirmTokenParam,
|
|
281
322
|
}),
|
|
282
|
-
}, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz,
|
|
323
|
+
}, async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken }, ctx) => {
|
|
283
324
|
const { timezone, gmt_offset } = checkinTimezone(requestedTz);
|
|
284
|
-
// Vetted on
|
|
285
|
-
//
|
|
325
|
+
// Vetted on every call, so the preview names the exact file (resolved path
|
|
326
|
+
// + size) that will be published — and a file whose bytes change between
|
|
327
|
+
// the preview and the confirmed call no longer matches the token.
|
|
286
328
|
const photo = photo_path !== undefined ? await checkPhoto(photo_path) : undefined;
|
|
329
|
+
const photo_sha256 = photo ? await photoSha256(photo.path) : undefined;
|
|
287
330
|
const form = {
|
|
288
331
|
bid,
|
|
289
332
|
rating: rating !== undefined ? rating.toFixed(2) : undefined,
|
|
@@ -298,15 +341,23 @@ export function registerCheckinTools(server, client) {
|
|
|
298
341
|
photo_file_ext: photo?.ext,
|
|
299
342
|
platform: 'ios',
|
|
300
343
|
};
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
344
|
+
const request = { method: 'POST', path: '/checkin/add', form };
|
|
345
|
+
const gate = await confirmWrite(ctx, {
|
|
346
|
+
tool: 'untappd_checkin',
|
|
347
|
+
action: 'untappd.checkin',
|
|
348
|
+
message: 'Review and confirm posting this check-in to your public Untappd feed:',
|
|
349
|
+
confirmToken,
|
|
350
|
+
target: bid,
|
|
351
|
+
payload: { ...request, photo, photo_sha256 },
|
|
352
|
+
preview: {
|
|
304
353
|
action: 'checkin',
|
|
305
|
-
|
|
354
|
+
...request,
|
|
306
355
|
photo: photo ? { ...photo, note: 'this exact file will be uploaded PUBLICLY after the check-in is created' } : undefined,
|
|
307
|
-
note: '
|
|
308
|
-
}
|
|
309
|
-
}
|
|
356
|
+
note: 'POSTs this check-in to your public Untappd feed.',
|
|
357
|
+
},
|
|
358
|
+
});
|
|
359
|
+
if (gate)
|
|
360
|
+
return gate;
|
|
310
361
|
// Open the photo BEFORE creating the check-in, so a missing/unreadable
|
|
311
362
|
// file fails fast without leaving an orphaned photo-less check-in behind.
|
|
312
363
|
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.
|
|
6
|
+
export const VERSION = '2.2.0'; // x-release-please-version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "untappd-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.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>",
|
|
@@ -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",
|
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": "2.
|
|
9
|
+
"version": "2.2.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "2.
|
|
14
|
+
"version": "2.2.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|
|
@@ -109,9 +109,9 @@ one that exists.
|
|
|
109
109
|
Each for its own reason — and none of them will tell you it ignored the
|
|
110
110
|
parameter, because an undeclared key is dropped by zod without a warning:
|
|
111
111
|
|
|
112
|
-
- **The 11
|
|
112
|
+
- **The 11 confirmation-gated writes** (`untappd_checkin`, `untappd_toast`,
|
|
113
113
|
`untappd_add_comment`, the two deletes, the wishlist pair, the four friend
|
|
114
|
-
actions) answer with a
|
|
114
|
+
actions) answer with a confirmation preview or a receipt. Nothing in a receipt is
|
|
115
115
|
decoration.
|
|
116
116
|
- **`untappd_sync_checkins` / `untappd_sync_user_beers`** answer with sync
|
|
117
117
|
PROGRESS — pages walked, `another_run_needed`, `backfill_complete`. Slimming
|
|
@@ -140,10 +140,15 @@ parameter, because an undeclared key is dropped by zod without a warning:
|
|
|
140
140
|
(#161) — it takes a `view` now, and its field shape is documented with the
|
|
141
141
|
other projections above.
|
|
142
142
|
|
|
143
|
-
## Write tools (
|
|
143
|
+
## Write tools (confirmation-gated — these post to your public account)
|
|
144
144
|
|
|
145
|
-
Each
|
|
146
|
-
|
|
145
|
+
Each asks the user to confirm first. Where the client can show a confirmation
|
|
146
|
+
prompt, it does. Otherwise the first call makes NO network call and returns
|
|
147
|
+
`status: "confirmation-required"` with a `preview` of the exact request and a
|
|
148
|
+
`confirmToken`; show the preview to the user, and only after they approve call
|
|
149
|
+
the tool again with the same arguments plus `confirmToken`. A token works once
|
|
150
|
+
and only for those arguments (a change returns `DRAFT_CHANGED` with a fresh
|
|
151
|
+
preview). `MCP_CONFIRM_MODE` on the server can make this `auto` or `refuse`.
|
|
147
152
|
|
|
148
153
|
- `untappd_toast` — toast/like a check-in (this endpoint is a toggle).
|
|
149
154
|
- `untappd_add_comment` — comment on a check-in.
|