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/dist/bundle.js CHANGED
@@ -23315,6 +23315,33 @@ var storage2 = new AsyncLocalStorage2();
23315
23315
  function withCallerCapabilities(capabilities, fn) {
23316
23316
  return capabilities ? storage2.run(capabilities, fn) : fn();
23317
23317
  }
23318
+ function currentCallerCapabilities() {
23319
+ return storage2.getStore();
23320
+ }
23321
+ var ENVELOPE_CAPABILITIES_KEY = "io.modelcontextprotocol/clientCapabilities";
23322
+ var ELICITATION_MODES = ["form", "url"];
23323
+ function isRecord(value) {
23324
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23325
+ }
23326
+ function callerCapabilities(ctx) {
23327
+ const envelope = isRecord(ctx) && isRecord(ctx.mcpReq) ? ctx.mcpReq.envelope : void 0;
23328
+ if (isRecord(envelope)) {
23329
+ const declared = envelope[ENVELOPE_CAPABILITIES_KEY];
23330
+ if (isRecord(declared))
23331
+ return declared;
23332
+ }
23333
+ return currentCallerCapabilities();
23334
+ }
23335
+ function callerAcceptsFormElicitation(ctx) {
23336
+ const capabilities = callerCapabilities(ctx);
23337
+ if (!capabilities)
23338
+ return void 0;
23339
+ const elicitation = capabilities.elicitation;
23340
+ if (!isRecord(elicitation))
23341
+ return false;
23342
+ const namedModes = ELICITATION_MODES.filter((mode) => mode in elicitation);
23343
+ return namedModes.length === 0 || namedModes.includes("form");
23344
+ }
23318
23345
 
23319
23346
  // node_modules/@modelcontextprotocol/server/dist/chunk-Br0eD_fh.mjs
23320
23347
  var __create2 = Object.create;
@@ -27713,6 +27740,37 @@ var inputRequired = Object.assign(buildInputRequired, {
27713
27740
  return { method: "roots/list" };
27714
27741
  }
27715
27742
  });
27743
+ function acceptedContent(responses, key, schema) {
27744
+ const view = inputResponse(responses, key);
27745
+ if (view.kind !== "elicit" || view.action !== "accept" || view.content === void 0) return void 0;
27746
+ if (schema === void 0) return view.content;
27747
+ const outcome = schema["~standard"].validate(view.content);
27748
+ if (outcome instanceof Promise) throw new TypeError("acceptedContent(responses, key, schema) requires a synchronously-validating schema");
27749
+ return outcome.issues === void 0 ? outcome.value : void 0;
27750
+ }
27751
+ function inputResponse(responses, key) {
27752
+ if (responses === void 0 || typeof responses !== "object" || responses === null) return { kind: "missing" };
27753
+ const entry = responses[key];
27754
+ if (entry === null || typeof entry !== "object" || Array.isArray(entry)) return { kind: "missing" };
27755
+ const candidate = entry;
27756
+ if (candidate["action"] === "accept" || candidate["action"] === "decline" || candidate["action"] === "cancel") {
27757
+ const content = candidate["content"];
27758
+ return {
27759
+ kind: "elicit",
27760
+ action: candidate["action"],
27761
+ ...content !== null && typeof content === "object" && !Array.isArray(content) && { content }
27762
+ };
27763
+ }
27764
+ if (Array.isArray(candidate["roots"])) return {
27765
+ kind: "roots",
27766
+ roots: candidate["roots"]
27767
+ };
27768
+ if (typeof candidate["role"] === "string" && candidate["content"] !== void 0) return {
27769
+ kind: "sampling",
27770
+ result: candidate
27771
+ };
27772
+ return { kind: "missing" };
27773
+ }
27716
27774
  var REQUEST_STATE_ONLY_LEG_PACING_MS = 250;
27717
27775
  function inputRequiredRoundsExceededMessage(method, maxRounds) {
27718
27776
  return `Multi-round-trip request '${method}' still required input after ${maxRounds} rounds (inputRequired.maxRounds)`;
@@ -37760,14 +37818,14 @@ function serveStdio(factory, options = {}) {
37760
37818
  return false;
37761
37819
  };
37762
37820
  const answerLegacyRejection = (request, reason, requestedVersion) => {
37763
- const rejection2 = modernOnlyStrictRejection({
37821
+ const rejection3 = modernOnlyStrictRejection({
37764
37822
  kind: "legacy",
37765
37823
  reason,
37766
37824
  ...requestedVersion !== void 0 && { requestedVersion }
37767
37825
  }, SUPPORTED_MODERN_PROTOCOL_VERSIONS);
37768
- if (rejection2 === void 0) return Promise.resolve();
37769
- reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection2.cell}): ${rejection2.message}`));
37770
- return writeErrorResponse(request.id, rejection2.code, rejection2.message, rejection2.data);
37826
+ if (rejection3 === void 0) return Promise.resolve();
37827
+ reportError(/* @__PURE__ */ new Error(`Rejected 2025-era request on a modern-only stdio connection (${rejection3.cell}): ${rejection3.message}`));
37828
+ return writeErrorResponse(request.id, rejection3.code, rejection3.message, rejection3.data);
37771
37829
  };
37772
37830
  const onInstanceClosed = (channel) => {
37773
37831
  if (closing || channel === discarding) return;
@@ -57758,6 +57816,11 @@ var MEDIA_QUALIFIER = "(?:primary|secondary|main|default|cover|hero|profile|mast
57758
57816
  var MEDIA_KEY = new RegExp(`^(?:(?:${MEDIA_QUALIFIER}|${MEDIA_NOUN})[_-]?)?${MEDIA_NOUN}s?(?:[_-]?(?:link|uri|url|src)s?)?$`, "i");
57759
57817
 
57760
57818
  // node_modules/@chrischall/mcp-utils/dist/response/index.js
57819
+ function textResult(data) {
57820
+ return {
57821
+ content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
57822
+ };
57823
+ }
57761
57824
  function errorResult(message) {
57762
57825
  return {
57763
57826
  content: [{ type: "text", text: redactSecrets(message) }],
@@ -57765,6 +57828,418 @@ function errorResult(message) {
57765
57828
  };
57766
57829
  }
57767
57830
 
57831
+ // node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
57832
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
57833
+
57834
+ // node_modules/@chrischall/mcp-utils/dist/server/canonical.js
57835
+ function canonicalJson(value) {
57836
+ if (value === null || typeof value === "string" || typeof value === "boolean")
57837
+ return JSON.stringify(value);
57838
+ if (value === void 0)
57839
+ return "null";
57840
+ if (typeof value === "number")
57841
+ return Number.isFinite(value) ? JSON.stringify(value) : `{"$num":"${String(value)}"}`;
57842
+ if (typeof value === "bigint")
57843
+ return `{"$bigint":"${value.toString()}"}`;
57844
+ if (typeof value === "function" || typeof value === "symbol") {
57845
+ throw new TypeError(`confirmation: cannot bind a ${typeof value} argument value.`);
57846
+ }
57847
+ if (Array.isArray(value))
57848
+ return `[${value.map(canonicalJson).join(",")}]`;
57849
+ if (value instanceof Date) {
57850
+ return `{"$date":${JSON.stringify(Number.isNaN(value.getTime()) ? "Invalid Date" : value.toISOString())}}`;
57851
+ }
57852
+ if (ArrayBuffer.isView(value)) {
57853
+ const bytes = Buffer.from(value.buffer, value.byteOffset, value.byteLength);
57854
+ return `{"$bytes":${JSON.stringify(bytes.toString("base64"))}}`;
57855
+ }
57856
+ if (value instanceof ArrayBuffer)
57857
+ return `{"$bytes":${JSON.stringify(Buffer.from(value).toString("base64"))}}`;
57858
+ if (value instanceof Map) {
57859
+ const entries2 = [...value.entries()].map(([k, v]) => `[${canonicalJson(k)},${canonicalJson(v)}]`).sort();
57860
+ return `{"$map":[${entries2.join(",")}]}`;
57861
+ }
57862
+ if (value instanceof Set) {
57863
+ return `{"$set":[${[...value].map(canonicalJson).sort().join(",")}]}`;
57864
+ }
57865
+ const proto = Object.getPrototypeOf(value);
57866
+ if (proto !== Object.prototype && proto !== null) {
57867
+ throw new TypeError("confirmation: cannot bind a non-plain object argument value (class instance).");
57868
+ }
57869
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
57870
+ return `{${entries.map(([k, v]) => `${JSON.stringify(escapeKey(k))}:${canonicalJson(v)}`).join(",")}}`;
57871
+ }
57872
+ function escapeKey(key) {
57873
+ return key.startsWith("$") ? `$${key}` : key;
57874
+ }
57875
+
57876
+ // node_modules/@chrischall/mcp-utils/dist/server/confirmation.js
57877
+ var DEFAULT_REQUEST_KEY = "confirmation";
57878
+ var DEFAULT_CONFIRMATION_LABEL = "Confirm this action should proceed.";
57879
+ var UNSUPPORTED_NOTE = "Nothing was done because this client cannot show a confirmation prompt (it declares no MCP elicitation capability), and this action is never taken without one";
57880
+ var STATE_PREFIX = "mcpu.confirm.v1.";
57881
+ function bindingKey(binding) {
57882
+ if (binding.ttlSeconds !== void 0 && !(Number.isFinite(binding.ttlSeconds) && binding.ttlSeconds > 0)) {
57883
+ throw new RangeError("requireConfirmation: binding.ttlSeconds must be a finite number greater than 0.");
57884
+ }
57885
+ const key = typeof binding.key === "string" ? Buffer.from(binding.key, "utf8") : Buffer.from(binding.key);
57886
+ if (key.length < 32) {
57887
+ throw new RangeError("requireConfirmation: binding.key must be at least 32 bytes.");
57888
+ }
57889
+ return key;
57890
+ }
57891
+ function commitment(action, args) {
57892
+ return createHash("sha256").update(`${action}\0${canonicalJson(args)}`).digest("base64url");
57893
+ }
57894
+ function mintState(key, action, binding) {
57895
+ const exp = Math.floor(Date.now() / 1e3) + (binding.ttlSeconds ?? 600);
57896
+ const body = Buffer.from(JSON.stringify({ c: commitment(action, binding.args), exp })).toString("base64url");
57897
+ const mac3 = createHmac("sha256", key).update(`${STATE_PREFIX}${body}`).digest("base64url");
57898
+ return `${STATE_PREFIX}${body}.${mac3}`;
57899
+ }
57900
+ function verifyState(key, action, binding, state) {
57901
+ if (typeof state !== "string" || !state.startsWith(STATE_PREFIX))
57902
+ return false;
57903
+ const rest = state.slice(STATE_PREFIX.length);
57904
+ const dot = rest.indexOf(".");
57905
+ if (dot < 0)
57906
+ return false;
57907
+ const body = rest.slice(0, dot);
57908
+ const mac3 = Buffer.from(rest.slice(dot + 1), "base64url");
57909
+ const expected = createHmac("sha256", key).update(`${STATE_PREFIX}${body}`).digest();
57910
+ if (mac3.length !== expected.length || !timingSafeEqual(mac3, expected))
57911
+ return false;
57912
+ let payload;
57913
+ try {
57914
+ payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
57915
+ } catch {
57916
+ return false;
57917
+ }
57918
+ if (typeof payload.exp !== "number" || payload.exp * 1e3 <= Date.now())
57919
+ return false;
57920
+ const want = Buffer.from(commitment(action, binding.args));
57921
+ const got = Buffer.from(typeof payload.c === "string" ? payload.c : "");
57922
+ return got.length === want.length && timingSafeEqual(got, want);
57923
+ }
57924
+ function echoedState(ctx) {
57925
+ const accessor = ctx.mcpReq.requestState;
57926
+ return typeof accessor === "function" ? accessor() : void 0;
57927
+ }
57928
+ function requireConfirmation(ctx, options) {
57929
+ const requestKey = options.requestKey ?? DEFAULT_REQUEST_KEY;
57930
+ const key = options.binding ? bindingKey(options.binding) : void 0;
57931
+ const confirmationSchema = external_exports.object({
57932
+ confirmed: external_exports.boolean().describe(options.confirmationLabel ?? DEFAULT_CONFIRMATION_LABEL)
57933
+ });
57934
+ const ask = () => {
57935
+ const preview = {
57936
+ action: options.action,
57937
+ ...options.details === void 0 ? {} : { details: options.details }
57938
+ };
57939
+ return inputRequired({
57940
+ inputRequests: {
57941
+ [requestKey]: inputRequired.elicit({
57942
+ message: `${options.message}
57943
+ ${JSON.stringify(preview, null, 2)}`,
57944
+ requestedSchema: confirmationSchema
57945
+ })
57946
+ },
57947
+ ...key && options.binding ? { requestState: mintState(key, options.action, options.binding) } : {}
57948
+ });
57949
+ };
57950
+ const response = inputResponse(ctx.mcpReq.inputResponses, requestKey);
57951
+ const accepted = acceptedContent(ctx.mcpReq.inputResponses, requestKey, confirmationSchema);
57952
+ if (response.kind === "missing") {
57953
+ if (callerAcceptsFormElicitation(ctx) === false) {
57954
+ return textResult({
57955
+ confirmed: false,
57956
+ dispatched: false,
57957
+ action: options.action,
57958
+ reason: "confirmation-unsupported",
57959
+ note: options.unsupportedNote ? `${UNSUPPORTED_NOTE}. ${options.unsupportedNote}` : `${UNSUPPORTED_NOTE}.`
57960
+ });
57961
+ }
57962
+ return ask();
57963
+ }
57964
+ if (response.kind === "elicit" && response.action === "accept" && accepted?.confirmed === true) {
57965
+ if (key && options.binding) {
57966
+ const state = echoedState(ctx);
57967
+ if (state === void 0 || state === null) {
57968
+ return errorResult(`Confirmation for ${options.action} was accepted, but the retry carried no requestState, so it cannot be checked against the prompt that was shown. Nothing was done. The likely cause is that the MCP client or host does not round-trip requestState (it must echo the requestState from the input_required result back on the retry); asking again would loop.`);
57969
+ }
57970
+ if (!verifyState(key, options.action, options.binding, state))
57971
+ return ask();
57972
+ }
57973
+ return void 0;
57974
+ }
57975
+ return textResult({
57976
+ confirmed: false,
57977
+ cancelled: true,
57978
+ action: options.action,
57979
+ note: "Nothing was changed because the confirmation was declined, cancelled, or left unchecked."
57980
+ });
57981
+ }
57982
+
57983
+ // node_modules/@chrischall/mcp-utils/dist/server/confirm-token.js
57984
+ import { createHash as createHash2, createHmac as createHmac2, randomBytes, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
57985
+ function createSpentTokenStore() {
57986
+ const spent = /* @__PURE__ */ new Map();
57987
+ return {
57988
+ has: (nonce) => spent.has(nonce),
57989
+ add: (nonce, exp) => {
57990
+ spent.set(nonce, exp);
57991
+ },
57992
+ prune: (now) => {
57993
+ for (const [nonce, exp] of spent)
57994
+ if (exp < now)
57995
+ spent.delete(nonce);
57996
+ },
57997
+ clear: () => spent.clear(),
57998
+ get size() {
57999
+ return spent.size;
58000
+ }
58001
+ };
58002
+ }
58003
+ var PROCESS_SPENT = createSpentTokenStore();
58004
+ var PREFIX = "mcpu.token.v1.";
58005
+ var DEFAULT_TTL_SECONDS = 600;
58006
+ function tokenKey(key) {
58007
+ const bytes = typeof key === "string" ? Buffer.from(key, "utf8") : Buffer.from(key);
58008
+ if (bytes.length < 32)
58009
+ throw new RangeError("confirm token: key must be at least 32 bytes.");
58010
+ return bytes;
58011
+ }
58012
+ function ttlOf(ttlSeconds) {
58013
+ if (ttlSeconds === void 0)
58014
+ return DEFAULT_TTL_SECONDS;
58015
+ if (!(Number.isFinite(ttlSeconds) && ttlSeconds > 0)) {
58016
+ throw new RangeError("confirm token: ttlSeconds must be a finite number greater than 0.");
58017
+ }
58018
+ return ttlSeconds;
58019
+ }
58020
+ function sign(key, body) {
58021
+ return createHmac2("sha256", key).update(`${PREFIX}${body}`).digest();
58022
+ }
58023
+ function hashConfirmPayload(payload) {
58024
+ return createHash2("sha256").update(canonicalJson(payload)).digest("base64url");
58025
+ }
58026
+ function issueConfirmToken(key, binding, options = {}) {
58027
+ const k = tokenKey(key);
58028
+ const now = options.now ?? Date.now();
58029
+ const exp = now + ttlOf(options.ttlSeconds) * 1e3;
58030
+ const claims = {
58031
+ t: binding.tool,
58032
+ ...binding.account === void 0 ? {} : { a: binding.account },
58033
+ g: binding.target,
58034
+ ...binding.revision === void 0 ? {} : { r: binding.revision },
58035
+ h: binding.payloadHash,
58036
+ exp,
58037
+ n: randomBytes(16).toString("base64url")
58038
+ };
58039
+ const body = Buffer.from(JSON.stringify(claims), "utf8").toString("base64url");
58040
+ return { token: `${PREFIX}${body}.${sign(k, body).toString("base64url")}`, expiresAt: new Date(exp).toISOString() };
58041
+ }
58042
+ function parseClaims(body) {
58043
+ try {
58044
+ const claims = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
58045
+ return claims && typeof claims === "object" ? claims : void 0;
58046
+ } catch {
58047
+ return void 0;
58048
+ }
58049
+ }
58050
+ function verifyConfirmToken(key, token, binding, options = {}) {
58051
+ const k = tokenKey(key);
58052
+ const now = options.now ?? Date.now();
58053
+ const spent = options.spent ?? PROCESS_SPENT;
58054
+ spent.prune(now);
58055
+ if (!token.startsWith(PREFIX))
58056
+ return { ok: false, error: "TOKEN_INVALID" };
58057
+ const rest = token.slice(PREFIX.length);
58058
+ const dot = rest.indexOf(".");
58059
+ const body = dot < 0 ? "" : rest.slice(0, dot);
58060
+ const mac3 = Buffer.from(dot < 0 ? "" : rest.slice(dot + 1), "base64url");
58061
+ const expected = sign(k, body);
58062
+ if (!body || mac3.length !== expected.length || !timingSafeEqual2(mac3, expected))
58063
+ return { ok: false, error: "TOKEN_INVALID" };
58064
+ const claims = parseClaims(body);
58065
+ if (!claims)
58066
+ return { ok: false, error: "TOKEN_INVALID" };
58067
+ if (claims.t !== binding.tool || claims.a !== binding.account || claims.g !== binding.target) {
58068
+ return { ok: false, error: "TOKEN_INVALID" };
58069
+ }
58070
+ if (spent.has(claims.n))
58071
+ return { ok: false, error: "TOKEN_REUSED" };
58072
+ if (now > claims.exp)
58073
+ return { ok: false, error: "TOKEN_EXPIRED" };
58074
+ if (claims.r !== binding.revision)
58075
+ return { ok: false, error: "DRAFT_CHANGED", reason: "revision-changed" };
58076
+ if (claims.h !== binding.payloadHash)
58077
+ return { ok: false, error: "DRAFT_CHANGED", reason: "payload-changed" };
58078
+ spent.add(claims.n, claims.exp);
58079
+ return { ok: true };
58080
+ }
58081
+ var CONFIRM_TOKEN_INSTRUCTION = "Show this preview to the user verbatim and proceed only after they explicitly approve in chat. Then call again with confirmToken.";
58082
+ var confirmTokenParam = external_exports.string().optional().describe(`ONLY for the two-step confirmation fallback (a client without MCP elicitation). The confirmToken from this same tool's phase-1 "confirmation-required" response, passed back ONLY after the user has seen that preview and explicitly approved it in chat \u2014 never on the first call, never invented, never reused. Call again with the same arguments. Ignored when the client supports elicitation.`);
58083
+ var TOKEN_ERROR_NOTE = {
58084
+ TOKEN_EXPIRED: "Nothing was sent or changed: the confirmToken expired. Call again WITHOUT confirmToken for a fresh preview, and ask the user to approve it again.",
58085
+ TOKEN_REUSED: "Nothing was sent or changed by this call: this confirmToken was already used, and one approval acts once. If doing it again is really intended, call again WITHOUT confirmToken and get a new approval.",
58086
+ TOKEN_INVALID: "Nothing was sent or changed: this confirmToken was not issued by this server for this tool, account and target (or the server has restarted since). Call again WITHOUT confirmToken for a fresh preview and approval."
58087
+ };
58088
+ var CHANGED_NOTE = {
58089
+ "revision-changed": "Nothing was sent or changed: the target was edited since the user approved it (its version rotated), so what would happen is not what they saw.",
58090
+ "payload-changed": "Nothing was sent or changed: what would happen no longer matches what the user approved."
58091
+ };
58092
+ function isToolResult(value) {
58093
+ return Array.isArray(value.content);
58094
+ }
58095
+ function rejection2(data) {
58096
+ return { ...textResult({ status: "confirmation-rejected", confirmed: false, dispatched: false, ...data }), isError: true };
58097
+ }
58098
+ async function tokenConfirmation(action, fb) {
58099
+ const subject = await fb.subject();
58100
+ if (isToolResult(subject))
58101
+ return subject;
58102
+ const binding = {
58103
+ tool: fb.tool,
58104
+ ...fb.account === void 0 ? {} : { account: fb.account },
58105
+ target: subject.target,
58106
+ ...subject.revision === void 0 ? {} : { revision: subject.revision },
58107
+ payloadHash: hashConfirmPayload(subject.payload)
58108
+ };
58109
+ const phaseOne = () => {
58110
+ const { token, expiresAt } = issueConfirmToken(fb.key, binding, { ttlSeconds: fb.ttlSeconds });
58111
+ return {
58112
+ action,
58113
+ preview: subject.preview,
58114
+ confirmToken: token,
58115
+ expiresAt,
58116
+ ttlSeconds: ttlOf(fb.ttlSeconds),
58117
+ instruction: fb.instruction ?? CONFIRM_TOKEN_INSTRUCTION
58118
+ };
58119
+ };
58120
+ if (!fb.confirmToken) {
58121
+ return textResult({ status: "confirmation-required", confirmed: false, dispatched: false, ...phaseOne() });
58122
+ }
58123
+ const verdict = verifyConfirmToken(fb.key, fb.confirmToken, binding, { spent: fb.spent });
58124
+ if (verdict.ok)
58125
+ return void 0;
58126
+ if (verdict.error === "DRAFT_CHANGED") {
58127
+ return rejection2({
58128
+ error: "DRAFT_CHANGED",
58129
+ reason: verdict.reason,
58130
+ note: `${CHANGED_NOTE[verdict.reason]} The current preview and a fresh confirmToken are below.`,
58131
+ ...phaseOne()
58132
+ });
58133
+ }
58134
+ return rejection2({ error: verdict.error, action, note: TOKEN_ERROR_NOTE[verdict.error] });
58135
+ }
58136
+ async function requireConfirmationWithFallback(ctx, options) {
58137
+ const { tokenFallback, ...confirmation } = options;
58138
+ if (tokenFallback && callerAcceptsFormElicitation(ctx) === false) {
58139
+ return tokenConfirmation(options.action, tokenFallback);
58140
+ }
58141
+ return requireConfirmation(ctx, confirmation);
58142
+ }
58143
+
58144
+ // node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js
58145
+ import { createHash as createHash3, randomBytes as randomBytes2 } from "node:crypto";
58146
+
58147
+ // node_modules/@chrischall/mcp-utils/dist/config/index.js
58148
+ import { homedir } from "node:os";
58149
+ import { isAbsolute, join, resolve } from "node:path";
58150
+ var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
58151
+ function readEnvVar(key, opts = {}) {
58152
+ const env = opts.env ?? process.env;
58153
+ const raw = env[key];
58154
+ if (typeof raw === "string") {
58155
+ const trimmed = raw.trim();
58156
+ if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
58157
+ return trimmed;
58158
+ }
58159
+ }
58160
+ return opts.default;
58161
+ }
58162
+ function expandPath(p) {
58163
+ let expanded = p;
58164
+ if (p === "~") {
58165
+ expanded = homedir();
58166
+ } else if (p.startsWith("~/")) {
58167
+ expanded = join(homedir(), p.slice(2));
58168
+ }
58169
+ return isAbsolute(expanded) ? expanded : resolve(expanded);
58170
+ }
58171
+ async function loadDotenvSafely(opts = {}) {
58172
+ try {
58173
+ const mod = await import(
58174
+ /* @vite-ignore */
58175
+ "dotenv"
58176
+ );
58177
+ const result = mod.config({
58178
+ ...opts.path !== void 0 ? { path: opts.path } : {},
58179
+ override: opts.override ?? false,
58180
+ quiet: true
58181
+ });
58182
+ return result.error === void 0;
58183
+ } catch {
58184
+ return false;
58185
+ }
58186
+ }
58187
+
58188
+ // node_modules/@chrischall/mcp-utils/dist/server/confirm-env.js
58189
+ var MODES = /* @__PURE__ */ new Set(["ask-user", "auto", "refuse"]);
58190
+ var DEFAULT_TTL_SECONDS2 = 600;
58191
+ var warned = /* @__PURE__ */ new Set();
58192
+ function readConfirmMode(env = process.env) {
58193
+ const raw = readEnvVar("MCP_CONFIRM_MODE", { env })?.trim().toLowerCase();
58194
+ if (!raw)
58195
+ return "ask-user";
58196
+ if (MODES.has(raw))
58197
+ return raw;
58198
+ if (!warned.has(raw)) {
58199
+ warned.add(raw);
58200
+ process.stderr.write(`MCP_CONFIRM_MODE="${raw}" is not one of ask-user, auto, refuse; treating it as refuse.
58201
+ `);
58202
+ }
58203
+ return "refuse";
58204
+ }
58205
+ function confirmTtlFromEnv(env = process.env) {
58206
+ const raw = readEnvVar("MCP_CONFIRM_TTL_SECONDS", { env })?.trim();
58207
+ return raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : DEFAULT_TTL_SECONDS2;
58208
+ }
58209
+ var processKey;
58210
+ function confirmKeyFromEnv(env = process.env) {
58211
+ const secret = readEnvVar("MCP_CONFIRM_SECRET", { env });
58212
+ if (secret)
58213
+ return createHash3("sha256").update(secret, "utf8").digest();
58214
+ processKey ??= randomBytes2(32);
58215
+ return processKey;
58216
+ }
58217
+ var CONFIRM_TOKEN_AUTO_INSTRUCTION = "Nothing has been done yet. Review this preview; if it is what was intended, call again with the same arguments plus confirmToken. (This server runs with MCP_CONFIRM_MODE=auto, so the user's approval in chat is not required.)";
58218
+ var REFUSE_HINT = "Set MCP_CONFIRM_MODE=ask-user on the server to allow two-step confirmation instead.";
58219
+ function confirmationFromEnv(options) {
58220
+ const { tool, account, confirmToken, subject, instruction, spent, env = process.env, ...confirmation } = options;
58221
+ const mode = readConfirmMode(env);
58222
+ if (mode === "refuse") {
58223
+ return {
58224
+ ...confirmation,
58225
+ unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${REFUSE_HINT}` : REFUSE_HINT
58226
+ };
58227
+ }
58228
+ return {
58229
+ ...confirmation,
58230
+ tokenFallback: {
58231
+ key: confirmKeyFromEnv(env),
58232
+ tool,
58233
+ ...account === void 0 ? {} : { account },
58234
+ ...confirmToken === void 0 ? {} : { confirmToken },
58235
+ subject,
58236
+ ttlSeconds: confirmTtlFromEnv(env),
58237
+ instruction: mode === "auto" ? CONFIRM_TOKEN_AUTO_INSTRUCTION : instruction ?? CONFIRM_TOKEN_INSTRUCTION,
58238
+ ...spent === void 0 ? {} : { spent }
58239
+ }
58240
+ };
58241
+ }
58242
+
57768
58243
  // node_modules/@chrischall/mcp-utils/dist/server/index.js
57769
58244
  var SERVER_PROTOCOL_VERSIONS = Object.freeze([
57770
58245
  "2026-07-28",
@@ -57940,47 +58415,6 @@ function runMcp(opts) {
57940
58415
  return handle;
57941
58416
  }
57942
58417
 
57943
- // node_modules/@chrischall/mcp-utils/dist/config/index.js
57944
- import { homedir } from "node:os";
57945
- import { isAbsolute, join, resolve } from "node:path";
57946
- var PLACEHOLDER_RE = /^\$\{[^}]*\}$/;
57947
- function readEnvVar(key, opts = {}) {
57948
- const env = opts.env ?? process.env;
57949
- const raw = env[key];
57950
- if (typeof raw === "string") {
57951
- const trimmed = raw.trim();
57952
- if (trimmed.length > 0 && trimmed !== "undefined" && trimmed !== "null" && !PLACEHOLDER_RE.test(trimmed)) {
57953
- return trimmed;
57954
- }
57955
- }
57956
- return opts.default;
57957
- }
57958
- function expandPath(p) {
57959
- let expanded = p;
57960
- if (p === "~") {
57961
- expanded = homedir();
57962
- } else if (p.startsWith("~/")) {
57963
- expanded = join(homedir(), p.slice(2));
57964
- }
57965
- return isAbsolute(expanded) ? expanded : resolve(expanded);
57966
- }
57967
- async function loadDotenvSafely(opts = {}) {
57968
- try {
57969
- const mod = await import(
57970
- /* @vite-ignore */
57971
- "dotenv"
57972
- );
57973
- const result = mod.config({
57974
- ...opts.path !== void 0 ? { path: opts.path } : {},
57975
- override: opts.override ?? false,
57976
- quiet: true
57977
- });
57978
- return result.error === void 0;
57979
- } catch {
57980
- return false;
57981
- }
57982
- }
57983
-
57984
58418
  // node_modules/@chrischall/mcp-utils/dist/fs/index.js
57985
58419
  import { openAsBlob } from "node:fs";
57986
58420
  import { open as open2 } from "node:fs/promises";
@@ -58126,7 +58560,7 @@ function toolAnnotations(opts = {}) {
58126
58560
  }
58127
58561
 
58128
58562
  // src/version.ts
58129
- var VERSION = "2.1.3";
58563
+ var VERSION = "2.2.1";
58130
58564
 
58131
58565
  // src/client.ts
58132
58566
  import { dirname as dirname2, join as join2 } from "path";
@@ -58944,6 +59378,22 @@ var CheckinStoreCore = class {
58944
59378
  [...params, limit]
58945
59379
  );
58946
59380
  }
59381
+ /** Delete one user's cached rows (case-insensitive) in a single transaction. */
59382
+ forgetUser(username) {
59383
+ const key = username.toLowerCase();
59384
+ const count2 = (table) => Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
59385
+ const removed = {
59386
+ checkins: count2("checkins"),
59387
+ distinct_beers: count2("distinct_beers"),
59388
+ sync_state: count2("sync_state")
59389
+ };
59390
+ this.db.transaction(() => {
59391
+ this.db.run("DELETE FROM checkins WHERE username = ?", [key]);
59392
+ this.db.run("DELETE FROM distinct_beers WHERE username = ?", [key]);
59393
+ this.db.run("DELETE FROM sync_state WHERE username = ?", [key]);
59394
+ });
59395
+ return removed;
59396
+ }
58947
59397
  getBeerMeta(bids) {
58948
59398
  if (bids.length === 0) return [];
58949
59399
  const placeholders = bids.map(() => "?").join(", ");
@@ -59024,6 +59474,9 @@ var LocalCacheStore = class {
59024
59474
  async upsertBeerMeta(rows) {
59025
59475
  this.core.upsertBeerMeta(rows);
59026
59476
  }
59477
+ async forgetUser(username) {
59478
+ return this.core.forgetUser(username);
59479
+ }
59027
59480
  };
59028
59481
  function escapeLike(s) {
59029
59482
  return s.replace(/[%_]/g, " ");
@@ -59667,6 +60120,22 @@ function registerDiscoverTools(server, client2) {
59667
60120
  );
59668
60121
  }
59669
60122
 
60123
+ // src/tools/confirm.ts
60124
+ var CONFIRM_FLOW = "Asks the user to confirm first: a confirmation prompt where the client supports one; otherwise the first call returns a preview and a confirmToken, and only a repeat call with that token proceeds (see MCP_CONFIRM_MODE).";
60125
+ function confirmWrite(ctx, c) {
60126
+ return requireConfirmationWithFallback(
60127
+ ctx,
60128
+ confirmationFromEnv({
60129
+ action: c.action,
60130
+ message: c.message,
60131
+ details: c.preview,
60132
+ tool: c.tool,
60133
+ confirmToken: c.confirmToken,
60134
+ subject: () => ({ target: String(c.target), payload: c.payload, preview: c.preview })
60135
+ })
60136
+ );
60137
+ }
60138
+
59670
60139
  // src/tools/friends.ts
59671
60140
  var TargetUidSchema = external_exports.number().int().positive().describe("The target user id (uid) \u2014 from a user profile (response.user.uid) or a friends-list item");
59672
60141
  var ACTIONS = [
@@ -59705,7 +60174,7 @@ function registerFriendActionTools(server, client2) {
59705
60174
  action.tool,
59706
60175
  {
59707
60176
  title: action.title,
59708
- description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it performs the action. Note: this endpoint path is taken from the Untappd app but is not otherwise independently verified.`,
60177
+ description: `${action.detail} Acts on YOUR account and affects a real relationship with another person. ${CONFIRM_FLOW} Note: this endpoint path is taken from the Untappd app but is not otherwise independently verified.`,
59709
60178
  // All four reach ANOTHER PERSON — the description says so itself: "affects
59710
60179
  // a real relationship with another person". A friend request cannot be
59711
60180
  // un-sent, and a rejection cannot be un-rejected, so none of them has an
@@ -59713,18 +60182,21 @@ function registerFriendActionTools(server, client2) {
59713
60182
  annotations: toolAnnotations({ title: action.title, readOnly: false, idempotent: true, openWorld: true, destructive: true }),
59714
60183
  inputSchema: external_exports.object({
59715
60184
  target_uid: TargetUidSchema,
59716
- confirm: schemaConfirm
60185
+ confirmToken: confirmTokenParam
59717
60186
  })
59718
60187
  },
59719
- async ({ target_uid, confirm }) => {
59720
- if (confirm !== true) {
59721
- return minifiedResult({
59722
- dryRun: true,
59723
- action: action.path,
59724
- target_uid,
59725
- note: `Dry run \u2014 re-run with confirm: true to ${action.verb} user ${target_uid}.`
59726
- });
59727
- }
60188
+ async ({ target_uid, confirmToken }, ctx) => {
60189
+ const request = { method: "POST", path: `/friend/${action.path}/${target_uid}` };
60190
+ const gate = await confirmWrite(ctx, {
60191
+ tool: action.tool,
60192
+ action: `untappd.friend_${action.path}`,
60193
+ message: `Review and confirm: ${action.verb} Untappd user ${target_uid}.`,
60194
+ confirmToken,
60195
+ target: target_uid,
60196
+ payload: request,
60197
+ preview: { action: action.path, target_uid, ...request, note: `Will ${action.verb} user ${target_uid}.` }
60198
+ });
60199
+ if (gate) return gate;
59728
60200
  const data = await client2.write("POST", `/friend/${action.path}/${target_uid}`);
59729
60201
  return minifiedResult({ done: true, action: action.path, target_uid, result: data?.result });
59730
60202
  }
@@ -59739,22 +60211,25 @@ function registerWishlistTools(server, client2) {
59739
60211
  "untappd_wishlist_add",
59740
60212
  {
59741
60213
  title: "Add a beer to your wishlist",
59742
- description: "Add a beer to YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it adds. Writes to your account.",
60214
+ description: `Add a beer to YOUR Untappd wishlist by its bid. Writes to your account. ${CONFIRM_FLOW}`,
59743
60215
  annotations: toolAnnotations({ title: "Add a beer to your wishlist", readOnly: false, idempotent: true, openWorld: true, destructive: false }),
59744
60216
  inputSchema: external_exports.object({
59745
60217
  bid: BidSchema2,
59746
- confirm: schemaConfirm
60218
+ confirmToken: confirmTokenParam
59747
60219
  })
59748
60220
  },
59749
- async ({ bid, confirm }) => {
59750
- if (confirm !== true) {
59751
- return minifiedResult({
59752
- dryRun: true,
59753
- action: "wishlist_add",
59754
- bid,
59755
- note: "Dry run \u2014 re-run with confirm: true to add this beer to your wishlist."
59756
- });
59757
- }
60221
+ async ({ bid, confirmToken }, ctx) => {
60222
+ const request = { method: "GET", path: "/user/wishlist/add", query: { bid } };
60223
+ const gate = await confirmWrite(ctx, {
60224
+ tool: "untappd_wishlist_add",
60225
+ action: "untappd.wishlist_add",
60226
+ message: "Review and confirm adding this beer to your Untappd wishlist:",
60227
+ confirmToken,
60228
+ target: bid,
60229
+ payload: request,
60230
+ preview: { action: "wishlist_add", bid, ...request, note: "Adds this beer to your Untappd wishlist." }
60231
+ });
60232
+ if (gate) return gate;
59758
60233
  const data = await client2.write("GET", "/user/wishlist/add", { query: { bid } });
59759
60234
  return minifiedResult({ added: true, bid, result: data?.result });
59760
60235
  }
@@ -59763,22 +60238,25 @@ function registerWishlistTools(server, client2) {
59763
60238
  "untappd_wishlist_remove",
59764
60239
  {
59765
60240
  title: "Remove a beer from your wishlist",
59766
- description: "Remove a beer from YOUR Untappd wishlist by its bid. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it removes. Writes to your account.",
60241
+ description: `Remove a beer from YOUR Untappd wishlist by its bid. Writes to your account. ${CONFIRM_FLOW}`,
59767
60242
  annotations: toolAnnotations({ title: "Remove a beer from your wishlist", readOnly: false, idempotent: true, openWorld: true, destructive: false }),
59768
60243
  inputSchema: external_exports.object({
59769
60244
  bid: BidSchema2,
59770
- confirm: schemaConfirm
60245
+ confirmToken: confirmTokenParam
59771
60246
  })
59772
60247
  },
59773
- async ({ bid, confirm }) => {
59774
- if (confirm !== true) {
59775
- return minifiedResult({
59776
- dryRun: true,
59777
- action: "wishlist_remove",
59778
- bid,
59779
- note: "Dry run \u2014 re-run with confirm: true to remove this beer from your wishlist."
59780
- });
59781
- }
60248
+ async ({ bid, confirmToken }, ctx) => {
60249
+ const request = { method: "GET", path: "/user/wishlist/delete", query: { bid } };
60250
+ const gate = await confirmWrite(ctx, {
60251
+ tool: "untappd_wishlist_remove",
60252
+ action: "untappd.wishlist_remove",
60253
+ message: "Review and confirm removing this beer from your Untappd wishlist:",
60254
+ confirmToken,
60255
+ target: bid,
60256
+ payload: request,
60257
+ preview: { action: "wishlist_remove", bid, ...request, note: "Removes this beer from your Untappd wishlist." }
60258
+ });
60259
+ if (gate) return gate;
59782
60260
  const data = await client2.write("GET", "/user/wishlist/delete", { query: { bid } });
59783
60261
  return minifiedResult({ removed: true, bid, result: data?.result });
59784
60262
  }
@@ -59786,7 +60264,9 @@ function registerWishlistTools(server, client2) {
59786
60264
  }
59787
60265
 
59788
60266
  // src/tools/checkin.ts
59789
- import { realpathSync as realpathSync2, statSync } from "node:fs";
60267
+ import { createReadStream, realpathSync as realpathSync2, statSync } from "node:fs";
60268
+ import { createHash as createHash4 } from "node:crypto";
60269
+ import { pipeline } from "node:stream/promises";
59790
60270
  import { delimiter, extname } from "node:path";
59791
60271
  var CheckinIdSchema = external_exports.number().int().positive().describe("Untappd check-in id");
59792
60272
  var PHOTO_CONTENT_TYPES = { jpg: "image/jpeg", png: "image/png" };
@@ -59832,7 +60312,13 @@ async function checkPhoto(photoPath) {
59832
60312
  hint: "Attach a normal-sized JPEG or PNG photo."
59833
60313
  });
59834
60314
  }
59835
- const sniffed = sniffMimeBytes(await readFileHead(real, 16));
60315
+ let head;
60316
+ try {
60317
+ head = await readFileHead(real, 16, { allowedRoots: roots });
60318
+ } catch {
60319
+ throw new McpToolError("Photo file not found or not readable.");
60320
+ }
60321
+ const sniffed = sniffMimeBytes(head);
59836
60322
  if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
59837
60323
  throw createHelpfulError(
59838
60324
  sniffed === "image/jpeg" || sniffed === "image/png" ? `Photo content (${sniffed}) does not match its .${ext} extension.` : "The file is not a JPEG or PNG image.",
@@ -59841,6 +60327,11 @@ async function checkPhoto(photoPath) {
59841
60327
  }
59842
60328
  return { path: real, size_bytes: size, ext, content_type: sniffed };
59843
60329
  }
60330
+ async function photoSha256(path) {
60331
+ const hash2 = createHash4("sha256");
60332
+ await pipeline(createReadStream(path), hash2);
60333
+ return hash2.digest("hex");
60334
+ }
59844
60335
  var RatingSchema = external_exports.number().min(0).max(5).refine((r) => Math.round(r * 4) === r * 4, { message: "rating must be a multiple of 0.25" });
59845
60336
  function gmtOffsetHours(timeZone, at) {
59846
60337
  const name = new Intl.DateTimeFormat("en-US", { timeZone, timeZoneName: "longOffset" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
@@ -59894,22 +60385,30 @@ function registerCheckinTools(server, client2) {
59894
60385
  "untappd_toast",
59895
60386
  {
59896
60387
  title: "Toast an Untappd check-in",
59897
- description: "Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have already toasted removes the toast. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it posts. Writes to your Untappd account and is visible to others.",
60388
+ description: `Toast (like) a check-in on YOUR account. This endpoint is a TOGGLE: calling it on a check-in you have already toasted removes the toast. Writes to your Untappd account and is visible to others. ${CONFIRM_FLOW}`,
59898
60389
  annotations: toolAnnotations({ title: "Toast an Untappd check-in", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
59899
60390
  inputSchema: external_exports.object({
59900
60391
  checkin_id: CheckinIdSchema,
59901
- confirm: schemaConfirm
60392
+ confirmToken: confirmTokenParam
59902
60393
  })
59903
60394
  },
59904
- async ({ checkin_id, confirm }) => {
59905
- if (confirm !== true) {
59906
- return minifiedResult({
59907
- dryRun: true,
60395
+ async ({ checkin_id, confirmToken }, ctx) => {
60396
+ const request = { method: "POST", path: `/checkin/toast/${checkin_id}` };
60397
+ const gate = await confirmWrite(ctx, {
60398
+ tool: "untappd_toast",
60399
+ action: "untappd.toast",
60400
+ message: "Review and confirm toggling your toast on this Untappd check-in:",
60401
+ confirmToken,
60402
+ target: checkin_id,
60403
+ payload: request,
60404
+ preview: {
59908
60405
  action: "toast",
59909
60406
  checkin_id,
59910
- note: "Dry run \u2014 re-run with confirm: true to toggle your toast on this check-in."
59911
- });
59912
- }
60407
+ ...request,
60408
+ note: "Toggles your toast on this check-in (removes it if you have already toasted)."
60409
+ }
60410
+ });
60411
+ if (gate) return gate;
59913
60412
  const data = await nonIdempotentWrite(
59914
60413
  () => client2.write("POST", `/checkin/toast/${checkin_id}`),
59915
60414
  `Untappd did not answer the toast request for check-in ${checkin_id} in time, so it may or may not have been applied. Toast is a TOGGLE: retrying could remove a toast that did land. Check with untappd_checkin_info (checkin_id ${checkin_id}) before retrying.`
@@ -59921,24 +60420,32 @@ function registerCheckinTools(server, client2) {
59921
60420
  "untappd_add_comment",
59922
60421
  {
59923
60422
  title: "Comment on an Untappd check-in",
59924
- description: "Post a comment on a check-in from YOUR account. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it posts. Writes to your Untappd account and is visible to others.",
60423
+ description: "Post a comment on a check-in from YOUR account. Writes to your Untappd account and is visible to others. " + CONFIRM_FLOW,
59925
60424
  annotations: toolAnnotations({ title: "Comment on an Untappd check-in", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
59926
60425
  inputSchema: external_exports.object({
59927
60426
  checkin_id: CheckinIdSchema,
59928
60427
  comment: external_exports.string().min(1).max(2e3).describe("Comment text to post"),
59929
- confirm: schemaConfirm
60428
+ confirmToken: confirmTokenParam
59930
60429
  })
59931
60430
  },
59932
- async ({ checkin_id, comment, confirm }) => {
59933
- if (confirm !== true) {
59934
- return minifiedResult({
59935
- dryRun: true,
60431
+ async ({ checkin_id, comment, confirmToken }, ctx) => {
60432
+ const request = { method: "POST", path: `/checkin/addcomment/${checkin_id}`, form: { comment } };
60433
+ const gate = await confirmWrite(ctx, {
60434
+ tool: "untappd_add_comment",
60435
+ action: "untappd.add_comment",
60436
+ message: "Review and confirm posting this comment from your Untappd account:",
60437
+ confirmToken,
60438
+ target: checkin_id,
60439
+ payload: request,
60440
+ preview: {
59936
60441
  action: "add_comment",
59937
60442
  checkin_id,
59938
60443
  comment,
59939
- note: "Dry run \u2014 re-run with confirm: true to post this comment to your Untappd account."
59940
- });
59941
- }
60444
+ ...request,
60445
+ note: "Posts this comment to the check-in from your Untappd account, visible to others."
60446
+ }
60447
+ });
60448
+ if (gate) return gate;
59942
60449
  const data = await nonIdempotentWrite(
59943
60450
  () => client2.write("POST", `/checkin/addcomment/${checkin_id}`, { form: { comment } }),
59944
60451
  `Untappd did not answer in time, so the comment may have been posted already. Check the comments with untappd_checkin_info (checkin_id ${checkin_id}) before retrying, or it may be posted twice.`
@@ -59950,22 +60457,25 @@ function registerCheckinTools(server, client2) {
59950
60457
  "untappd_delete_comment",
59951
60458
  {
59952
60459
  title: "Delete a comment from an Untappd check-in",
59953
- description: "Delete one of YOUR comments by its comment id (the id from a check-in's comments list). Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.",
60460
+ description: `Delete one of YOUR comments by its comment id (the id from a check-in's comments list). ${CONFIRM_FLOW}`,
59954
60461
  annotations: toolAnnotations({ title: "Delete a comment from an Untappd check-in", readOnly: false, idempotent: true, openWorld: true, destructive: true }),
59955
60462
  inputSchema: external_exports.object({
59956
60463
  comment_id: external_exports.number().int().positive().describe("Untappd comment id (from a check-in's comments.items)"),
59957
- confirm: schemaConfirm
60464
+ confirmToken: confirmTokenParam
59958
60465
  })
59959
60466
  },
59960
- async ({ comment_id, confirm }) => {
59961
- if (confirm !== true) {
59962
- return minifiedResult({
59963
- dryRun: true,
59964
- action: "delete_comment",
59965
- comment_id,
59966
- note: "Dry run \u2014 re-run with confirm: true to delete this comment from your Untappd account."
59967
- });
59968
- }
60467
+ async ({ comment_id, confirmToken }, ctx) => {
60468
+ const request = { method: "POST", path: `/checkin/deletecomment/${comment_id}` };
60469
+ const gate = await confirmWrite(ctx, {
60470
+ tool: "untappd_delete_comment",
60471
+ action: "untappd.delete_comment",
60472
+ message: "Review and confirm deleting this comment from your Untappd account:",
60473
+ confirmToken,
60474
+ target: comment_id,
60475
+ payload: request,
60476
+ preview: { action: "delete_comment", comment_id, ...request, note: "Deletes this comment from your Untappd account." }
60477
+ });
60478
+ if (gate) return gate;
59969
60479
  const data = await client2.write("POST", `/checkin/deletecomment/${comment_id}`);
59970
60480
  return minifiedResult({ deleted: true, comment_id, result: data?.result });
59971
60481
  }
@@ -59974,22 +60484,30 @@ function registerCheckinTools(server, client2) {
59974
60484
  "untappd_delete_checkin",
59975
60485
  {
59976
60486
  title: "Delete an Untappd check-in",
59977
- description: "Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. Without confirm: true it returns a dry-run preview and makes NO network call; with confirm: true it deletes.",
60487
+ description: `Permanently delete one of YOUR check-ins by its id. This is destructive and cannot be undone. ${CONFIRM_FLOW}`,
59978
60488
  annotations: toolAnnotations({ title: "Delete an Untappd check-in", readOnly: false, idempotent: true, openWorld: true, destructive: true }),
59979
60489
  inputSchema: external_exports.object({
59980
60490
  checkin_id: CheckinIdSchema,
59981
- confirm: schemaConfirm
60491
+ confirmToken: confirmTokenParam
59982
60492
  })
59983
60493
  },
59984
- async ({ checkin_id, confirm }) => {
59985
- if (confirm !== true) {
59986
- return minifiedResult({
59987
- dryRun: true,
60494
+ async ({ checkin_id, confirmToken }, ctx) => {
60495
+ const request = { method: "POST", path: `/checkin/delete/${checkin_id}` };
60496
+ const gate = await confirmWrite(ctx, {
60497
+ tool: "untappd_delete_checkin",
60498
+ action: "untappd.delete_checkin",
60499
+ message: "Review and confirm PERMANENTLY deleting this Untappd check-in:",
60500
+ confirmToken,
60501
+ target: checkin_id,
60502
+ payload: request,
60503
+ preview: {
59988
60504
  action: "delete_checkin",
59989
60505
  checkin_id,
59990
- note: "Dry run \u2014 re-run with confirm: true to PERMANENTLY delete this check-in. This cannot be undone."
59991
- });
59992
- }
60506
+ ...request,
60507
+ note: "PERMANENTLY deletes this check-in. This cannot be undone."
60508
+ }
60509
+ });
60510
+ if (gate) return gate;
59993
60511
  const data = await client2.write("POST", `/checkin/delete/${checkin_id}`);
59994
60512
  return minifiedResult({ deleted: true, checkin_id, result: data?.result });
59995
60513
  }
@@ -59998,7 +60516,7 @@ function registerCheckinTools(server, client2) {
59998
60516
  "untappd_checkin",
59999
60517
  {
60000
60518
  title: "Check in a beer on Untappd",
60001
- description: "Post a NEW beer check-in to YOUR Untappd account \u2014 this publishes to your public feed. Provide the beer id (bid) from untappd_search_beer; optionally a rating (0\u20135 in 0.25 steps), a shout (comment), a venue via foursquare_id, and a local photo via photo_path (JPEG/PNG). Without confirm: true it returns a dry-run preview of the exact fields and makes NO network call; with confirm: true it posts.",
60519
+ description: `Post a NEW beer check-in to YOUR Untappd account \u2014 this publishes to your public feed. Provide the beer id (bid) from untappd_search_beer; optionally a rating (0\u20135 in 0.25 steps), a shout (comment), a venue via foursquare_id, and a local photo via photo_path (JPEG/PNG). The preview shows the exact fields and photo that will be posted. ${CONFIRM_FLOW}`,
60002
60520
  annotations: toolAnnotations({ title: "Check in a beer on Untappd", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
60003
60521
  inputSchema: external_exports.object({
60004
60522
  bid: external_exports.number().int().positive().describe("Untappd beer id to check in (from untappd_search_beer)"),
@@ -60006,7 +60524,7 @@ function registerCheckinTools(server, client2) {
60006
60524
  shout: external_exports.string().max(2e3).optional().describe("Optional shout / comment text for the check-in"),
60007
60525
  foursquare_id: external_exports.string().optional().describe("Optional Foursquare venue id to tag the check-in location"),
60008
60526
  photo_path: external_exports.string().optional().describe(
60009
- "Optional path to a local JPEG/PNG photo (max 15 MB) to attach \u2014 it is published publicly. Only use a file the user explicitly chose; the dry run shows the resolved path and size for them to confirm."
60527
+ "Optional path to a local JPEG/PNG photo (max 15 MB) to attach \u2014 it is published publicly. Only use a file the user explicitly chose; the preview shows the resolved path and size for them to confirm."
60010
60528
  ),
60011
60529
  geolat: external_exports.number().optional().describe("Optional latitude of the check-in"),
60012
60530
  geolng: external_exports.number().optional().describe("Optional longitude of the check-in"),
@@ -60014,12 +60532,13 @@ function registerCheckinTools(server, client2) {
60014
60532
  timezone: external_exports.string().min(1).optional().describe(
60015
60533
  "The drinker's IANA timezone (e.g. America/New_York), which sets the check-in's local time. Defaults to UNTAPPD_TIMEZONE, else the server's own zone \u2014 which on a hosted connector is usually UTC, so pass it when you know where the user is."
60016
60534
  ),
60017
- confirm: schemaConfirm
60535
+ confirmToken: confirmTokenParam
60018
60536
  })
60019
60537
  },
60020
- async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirm }) => {
60538
+ async ({ bid, rating, shout, foursquare_id, photo_path, geolat, geolng, container_id, timezone: requestedTz, confirmToken }, ctx) => {
60021
60539
  const { timezone, gmt_offset } = checkinTimezone(requestedTz);
60022
60540
  const photo = photo_path !== void 0 ? await checkPhoto(photo_path) : void 0;
60541
+ const photo_sha256 = photo ? await photoSha256(photo.path) : void 0;
60023
60542
  const form = {
60024
60543
  bid,
60025
60544
  rating: rating !== void 0 ? rating.toFixed(2) : void 0,
@@ -60034,15 +60553,22 @@ function registerCheckinTools(server, client2) {
60034
60553
  photo_file_ext: photo?.ext,
60035
60554
  platform: "ios"
60036
60555
  };
60037
- if (confirm !== true) {
60038
- return minifiedResult({
60039
- dryRun: true,
60556
+ const request = { method: "POST", path: "/checkin/add", form };
60557
+ const gate = await confirmWrite(ctx, {
60558
+ tool: "untappd_checkin",
60559
+ action: "untappd.checkin",
60560
+ message: "Review and confirm posting this check-in to your public Untappd feed:",
60561
+ confirmToken,
60562
+ target: bid,
60563
+ payload: { ...request, photo, photo_sha256 },
60564
+ preview: {
60040
60565
  action: "checkin",
60041
- form,
60566
+ ...request,
60042
60567
  photo: photo ? { ...photo, note: "this exact file will be uploaded PUBLICLY after the check-in is created" } : void 0,
60043
- note: "Dry run \u2014 re-run with confirm: true to POST this check-in to your public Untappd feed."
60044
- });
60045
- }
60568
+ note: "POSTs this check-in to your public Untappd feed."
60569
+ }
60570
+ });
60571
+ if (gate) return gate;
60046
60572
  let blob;
60047
60573
  if (photo) {
60048
60574
  blob = await fileBlob(photo.path, { maxBytes: MAX_PHOTO_BYTES, label: "Photo", allowedRoots: photoRoots() });
@@ -60787,11 +61313,48 @@ function registerCacheTools(server, client2, cacheProvider) {
60787
61313
  });
60788
61314
  }
60789
61315
  );
61316
+ server.registerTool(
61317
+ "untappd_cache_forget",
61318
+ {
61319
+ title: "Forget a user's cached check-ins",
61320
+ description: `Delete everything the LOCAL cache holds for one user \u2014 their cached check-ins (beer, rating, comment, venue, date), distinct-beers list and sync state \u2014 e.g. after syncing a friend you no longer want a history of. Only the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it. Shared beer metadata is kept. The preview shows the username and exactly how many rows will be removed. Omit username for your own account. ${CONFIRM_FLOW}`,
61321
+ annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
61322
+ inputSchema: external_exports.object({
61323
+ username: UsernameArg2,
61324
+ confirmToken: confirmTokenParam
61325
+ })
61326
+ },
61327
+ async ({ username, confirmToken }, ctx) => {
61328
+ const user = resolveUser2(username, client2.loginName);
61329
+ const cache = cacheProvider();
61330
+ const counts = {
61331
+ checkins: await cache.cachedCount(user),
61332
+ distinct_beers: await cache.distinctBeersCount(user)
61333
+ };
61334
+ const gate = await confirmWrite(ctx, {
61335
+ tool: "untappd_cache_forget",
61336
+ action: "untappd.cache_forget",
61337
+ message: `Review and confirm deleting ${user}'s data from the local Untappd cache:`,
61338
+ confirmToken,
61339
+ target: user.toLowerCase(),
61340
+ payload: { username: user.toLowerCase(), ...counts },
61341
+ preview: {
61342
+ action: "cache_forget",
61343
+ username: user,
61344
+ ...counts,
61345
+ note: "Deletes these rows from the LOCAL cache only; Untappd itself is unchanged and a re-sync can restore them."
61346
+ }
61347
+ });
61348
+ if (gate) return gate;
61349
+ const removed = await cache.forgetUser(user);
61350
+ return minifiedResult({ forgotten: true, username: user, removed });
61351
+ }
61352
+ );
60790
61353
  }
60791
61354
 
60792
61355
  // src/cache/db.ts
60793
61356
  import { DatabaseSync } from "node:sqlite";
60794
- import { mkdirSync } from "node:fs";
61357
+ import { chmodSync, closeSync, existsSync as existsSync2, mkdirSync, openSync } from "node:fs";
60795
61358
  import { dirname as dirname3, join as join3 } from "node:path";
60796
61359
  import { homedir as homedir2 } from "node:os";
60797
61360
  var NodeSqlDriver = class {
@@ -60828,15 +61391,39 @@ var CheckinCache = class _CheckinCache extends LocalCacheStore {
60828
61391
  this.db = db;
60829
61392
  }
60830
61393
  db;
60831
- /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
61394
+ /**
61395
+ * Open (creating parent dirs) a file-backed cache, or `:memory:` for tests.
61396
+ * The file holds other people's dated check-in and venue history, so it is
61397
+ * kept owner-only: dirs 0700, the db (and any -wal/-shm/-journal) 0600.
61398
+ */
60832
61399
  static open(path) {
60833
- if (path !== ":memory:") mkdirSync(dirname3(path), { recursive: true });
60834
- return new _CheckinCache(new DatabaseSync(path));
61400
+ if (path !== ":memory:") preparePrivateFile(path);
61401
+ const cache = new _CheckinCache(new DatabaseSync(path));
61402
+ if (path !== ":memory:") tightenSidecars(path);
61403
+ return cache;
60835
61404
  }
60836
61405
  close() {
60837
61406
  this.db.close();
60838
61407
  }
60839
61408
  };
61409
+ function preparePrivateFile(path) {
61410
+ const dir = dirname3(path);
61411
+ mkdirSync(dir, { recursive: true, mode: 448 });
61412
+ bestEffortChmod(dir, 448);
61413
+ if (!existsSync2(path)) closeSync(openSync(path, "a", 384));
61414
+ bestEffortChmod(path, 384);
61415
+ }
61416
+ function tightenSidecars(path) {
61417
+ for (const suffix of ["-wal", "-shm", "-journal"]) {
61418
+ if (existsSync2(path + suffix)) bestEffortChmod(path + suffix, 384);
61419
+ }
61420
+ }
61421
+ function bestEffortChmod(path, mode) {
61422
+ try {
61423
+ chmodSync(path, mode);
61424
+ } catch {
61425
+ }
61426
+ }
60840
61427
  function defaultCachePath() {
60841
61428
  return readEnvVar("UNTAPPD_CACHE_DB") ?? join3(homedir2(), ".untappd-mcp", "checkins.db");
60842
61429
  }