untappd-mcp 2.2.0 → 2.2.2

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.
@@ -7,7 +7,7 @@
7
7
  },
8
8
  "metadata": {
9
9
  "description": "MCP server for Untappd — beers, breweries, venues, check-ins, wishlists, and your friend feed",
10
- "version": "2.2.0"
10
+ "version": "2.2.2"
11
11
  },
12
12
  "plugins": [
13
13
  {
@@ -15,7 +15,7 @@
15
15
  "displayName": "Untappd",
16
16
  "source": "./",
17
17
  "description": "MCP server for Untappd — search beers/breweries/venues, read profiles/check-ins/wishlists, and post check-ins, toasts, and comments",
18
- "version": "2.2.0",
18
+ "version": "2.2.2",
19
19
  "author": {
20
20
  "name": "Chris Hall"
21
21
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
3
  "displayName": "Untappd",
4
- "version": "2.2.0",
4
+ "version": "2.2.2",
5
5
  "description": "MCP server for Untappd — search beers/breweries/venues, read check-ins and wishlists, and post check-ins, toasts, and comments",
6
6
  "author": {
7
7
  "name": "Chris Hall",
package/README.md CHANGED
@@ -36,7 +36,7 @@ it goes stale.
36
36
  | `UNTAPPD_USER_AGENT` | no | Override the User-Agent (default mimics the app). |
37
37
  | `UNTAPPD_TIMEZONE` | no | IANA timezone (e.g. `America/New_York`) that check-ins are stamped with when the call doesn't pass `timezone`. Set it when the server runs somewhere other than the drinker's zone (e.g. a hosted connector, usually UTC). Defaults to the server process's zone. |
38
38
  | `UNTAPPD_PHOTO_DIR` | no | Restrict `untappd_checkin`'s `photo_path` to files inside this directory (several allowed, separated by `:`). Recommended wherever the model can be steered by untrusted content. |
39
- | `UNTAPPD_CACHE_DB` | no | Path to the local check-in cache SQLite file (default `~/.untappd-mcp/checkins.db`). Local/stdio only. |
39
+ | `UNTAPPD_CACHE_DB` | no | Path to the local check-in cache SQLite file (default `~/.untappd-mcp/checkins.db`; created owner-only — dir `0700`, file `0600`). Local/stdio only. |
40
40
 
41
41
  Copy `.env.example` to `.env` and fill it in for local use.
42
42
 
@@ -86,7 +86,7 @@ Writes (each asks you to confirm first — see [Confirmations](#confirmations)):
86
86
 
87
87
  Check-in cache: `untappd_sync_checkins`, `untappd_sync_user_beers`,
88
88
  `untappd_cache_has_had`, `untappd_cache_has_had_many`, `untappd_cache_not_had`,
89
- `untappd_cache_query`, `untappd_top_not_had`.
89
+ `untappd_cache_query`, `untappd_top_not_had`, `untappd_cache_forget`.
90
90
 
91
91
  ## Check-in cache
92
92
 
@@ -149,6 +149,15 @@ Syncing **another** user goes through the same authed endpoint as
149
149
  account is public or your friend. Otherwise the sync returns a clear error
150
150
  telling you to add them as a friend first.
151
151
 
152
+ **Retention.** Nothing in the cache expires: synced rows — including another
153
+ user's dated check-ins, comments and venues — stay until you remove them. The
154
+ file is created owner-only (directory `0700`, database `0600`, and older
155
+ installs are tightened on open). `untappd_cache_forget` deletes one user's
156
+ cached check-ins, distinct-beers list and sync state after a confirmation
157
+ (preview shows the username and row counts); it touches only the local cache,
158
+ never Untappd, and a later sync can re-fetch. Deleting the file removes
159
+ everything.
160
+
152
161
  A cache holds only the check-ins the account it belongs to was allowed to
153
162
  fetch. `untappd_healthcheck` reports the running version and the exact tool set
154
163
  (count + names + a stable hash), so you can confirm which build is serving.
package/dist/bundle.js CHANGED
@@ -23400,7 +23400,7 @@ function declaredDialect(schema, remedy) {
23400
23400
  throw new Error(`JSON Schema declares an unsupported dialect ("$schema": "${schema.$schema.slice(0, 200)}"). The default validator supports JSON Schema 2020-12, 2019-09, draft-07, and draft-06; ${remedy}`);
23401
23401
  }
23402
23402
 
23403
- // node_modules/@modelcontextprotocol/core/dist/auth-CUe6YdwF.mjs
23403
+ // node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/dist/auth-CGP0BDVq.mjs
23404
23404
  var z = __toESM(require_v4(), 1);
23405
23405
  var LATEST_PROTOCOL_VERSION = "2025-11-25";
23406
23406
  var SUPPORTED_PROTOCOL_VERSIONS = [
@@ -24238,7 +24238,8 @@ var OAuthMetadataSchema = z.looseObject({
24238
24238
  introspection_endpoint_auth_signing_alg_values_supported: z.array(z.string()).optional(),
24239
24239
  code_challenge_methods_supported: z.array(z.string()).optional(),
24240
24240
  client_id_metadata_document_supported: z.boolean().optional(),
24241
- authorization_response_iss_parameter_supported: z.boolean().optional().catch(void 0)
24241
+ authorization_response_iss_parameter_supported: z.boolean().optional().catch(void 0),
24242
+ dpop_signing_alg_values_supported: z.array(z.string()).optional()
24242
24243
  });
24243
24244
  var OpenIdProviderMetadataSchema = z.looseObject({
24244
24245
  issuer: z.string(),
@@ -24339,7 +24340,7 @@ var OAuthTokenRevocationRequestSchema = z.object({
24339
24340
  token_type_hint: z.string().optional()
24340
24341
  }).strip();
24341
24342
 
24342
- // node_modules/@modelcontextprotocol/server/dist/src-CX2iR2pK.mjs
24343
+ // node_modules/@modelcontextprotocol/server/dist/src-D-y6h4N7.mjs
24343
24344
  var z2 = __toESM(require_v4(), 1);
24344
24345
  var BRANDS = /* @__PURE__ */ Symbol.for("mcp.sdk.errorBrands");
24345
24346
  function stampErrorBrands(instance, ctor) {
@@ -24454,8 +24455,18 @@ var SdkError = class extends Error {
24454
24455
  if (typeof this !== "function") throw new TypeError("isInstance must be called on the class (e.g. `SdkError.isInstance(value)`); for callbacks use `v => SdkError.isInstance(v)`");
24455
24456
  return brandedHasInstance(this, value);
24456
24457
  }
24457
- constructor(code, message, data) {
24458
- super(message);
24458
+ /**
24459
+ * @param code - Stable string code identifying the failure ({@linkcode SdkErrorCode}).
24460
+ * @param message - Human-readable description.
24461
+ * @param data - Optional structured payload (for example the HTTP status carried by
24462
+ * {@linkcode SdkHttpError}). Opaque to the SDK: a `cause` key inside `data` is not
24463
+ * promoted to `Error.cause`.
24464
+ * @param options - Standard `ErrorOptions`, forwarded to `Error`. Pass the underlying
24465
+ * failure as `{ cause }` so it is reachable through the `Error.cause` chain that
24466
+ * loggers and error trackers walk.
24467
+ */
24468
+ constructor(code, message, data, options) {
24469
+ super(message, options);
24459
24470
  this.code = code;
24460
24471
  this.data = data;
24461
24472
  this.name = "SdkError";
@@ -24466,8 +24477,11 @@ var SdkHttpError = class extends SdkError {
24466
24477
  static {
24467
24478
  Object.defineProperty(this, "mcpBrand", { value: "mcp.SdkHttpError" });
24468
24479
  }
24469
- constructor(code, message, data) {
24470
- super(code, message, data);
24480
+ /**
24481
+ * @param options - Standard `ErrorOptions`, forwarded to `Error` (see {@linkcode SdkError}).
24482
+ */
24483
+ constructor(code, message, data, options) {
24484
+ super(code, message, data, options);
24471
24485
  this.name = "SdkHttpError";
24472
24486
  }
24473
24487
  get status() {
@@ -27418,7 +27432,7 @@ var INBOUND_VALIDATION_LADDER = [
27418
27432
  evaluatedAt: "pre-dispatch",
27419
27433
  codes: [HEADER_MISMATCH_ERROR_CODE],
27420
27434
  conformance: ["http-header-validation"],
27421
- rationale: "SEP-2243 standard `Mcp-Method` / `Mcp-Name` headers \u2014 presence, sentinel decoding, and `Mcp-Name` \u2194 body cross-check \u2014 are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before dispatch. The classifier\u2019s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge `era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half. Evaluated before the capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request that also fails a dispatch rung is answered here before the dispatch rungs (5\u20136) are consulted."
27435
+ rationale: "SEP-2243 standard `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` headers \u2014 presence, sentinel decoding, and `Mcp-Name` \u2194 body cross-check \u2014 are validated by the HTTP entry on a modern-classified request after the supported-revision gate and before dispatch. The spec requires `MCP-Protocol-Version` and `Mcp-Method` on every modern *request* POST (`Mcp-Name` only for the methods that mirror `params.name` / `params.uri` / `params.taskId`, see `MCP_NAME_HEADER_SOURCE`) and names them in that order, so a request missing several is answered by the earliest. Notification POSTs are exempt: the presence half runs on requests only, so a modern-enveloped notification is dispatched even with no standard headers at all. The classifier\u2019s own header-mismatch cells (protocol-version, `Mcp-Method` mismatch) stay on the edge `era-classification` rung; this rung carries the entry-layer presence/`Mcp-Name` half \u2014 including the missing `MCP-Protocol-Version` cell, which cannot live on the edge rung without breaking body-primary classification. Evaluated before the capability gate, the factory call, and the `Mcp-Param-*` rung so a request that fails several rungs is answered by the standard-header rung first. The documented order (after method-registry 5 and request-params 6) is NOT the observed precedence: serveModern evaluates this rung immediately after the supported-revision gate, so a request that also fails a dispatch rung is answered here before the dispatch rungs (5\u20136) are consulted."
27422
27436
  },
27423
27437
  {
27424
27438
  rung: "client-capabilities",
@@ -28219,7 +28233,7 @@ var Protocol = class {
28219
28233
  return this._requestHandlers.get(method);
28220
28234
  }
28221
28235
  async _oncancel(notification) {
28222
- if (!notification.params.requestId) return;
28236
+ if (notification.params.requestId === void 0) return;
28223
28237
  this._requestHandlerAbortControllers.get(notification.params.requestId)?.abort(notification.params.reason);
28224
28238
  }
28225
28239
  _setupTimeout(messageId, timeout, maxTotalTimeout, onTimeout, resetTimeoutOnProgress = false) {
@@ -28618,19 +28632,20 @@ var Protocol = class {
28618
28632
  const cancel = (reason) => {
28619
28633
  if (responseReceived) return;
28620
28634
  this._progressHandlers.delete(messageId);
28621
- if (requestAbort === void 0) this._transport?.send(this._envelopeOutbound({
28622
- jsonrpc: "2.0",
28623
- method: "notifications/cancelled",
28624
- params: {
28625
- requestId: messageId,
28626
- reason: String(reason)
28627
- }
28628
- }), {
28629
- relatedRequestId,
28630
- resumptionToken,
28631
- onresumptiontoken
28632
- }).catch((error62) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error62}`)));
28633
- else requestAbort.abort();
28635
+ if (requestAbort === void 0) {
28636
+ if (request.method !== "initialize") this._transport?.send(this._envelopeOutbound({
28637
+ jsonrpc: "2.0",
28638
+ method: "notifications/cancelled",
28639
+ params: {
28640
+ requestId: messageId,
28641
+ reason: String(reason)
28642
+ }
28643
+ }), {
28644
+ relatedRequestId,
28645
+ resumptionToken,
28646
+ onresumptiontoken
28647
+ }).catch((error62) => this._onerror(/* @__PURE__ */ new Error(`Failed to send cancellation: ${error62}`)));
28648
+ } else requestAbort.abort();
28634
28649
  reject(reason instanceof SdkError ? reason : new SdkError(SdkErrorCode.RequestTimeout, String(reason)));
28635
28650
  };
28636
28651
  this._responseHandlers.set(messageId, (response) => {
@@ -28710,7 +28725,7 @@ var Protocol = class {
28710
28725
  jsonrpc: "2.0",
28711
28726
  ...notification
28712
28727
  });
28713
- if ((this._options?.debouncedNotificationMethods ?? []).includes(notification.method) && !notification.params && !options?.relatedRequestId) {
28728
+ if ((this._options?.debouncedNotificationMethods ?? []).includes(notification.method) && !notification.params && options?.relatedRequestId === void 0) {
28714
28729
  if (this._pendingDebouncedNotifications.has(notification.method)) return;
28715
28730
  this._pendingDebouncedNotifications.add(notification.method);
28716
28731
  Promise.resolve().then(() => {
@@ -36013,7 +36028,7 @@ var Ajv = import_ajv.Ajv;
36013
36028
  // node_modules/@modelcontextprotocol/server/dist/shimsNode.mjs
36014
36029
  import process2 from "node:process";
36015
36030
 
36016
- // node_modules/@modelcontextprotocol/server/dist/mcp-DXXb3Vv3.mjs
36031
+ // node_modules/@modelcontextprotocol/server/dist/mcp-Dw2OlZ1f.mjs
36017
36032
  var COMPLETABLE_SYMBOL = /* @__PURE__ */ Symbol.for("mcp.completable");
36018
36033
  function isCompletable(schema) {
36019
36034
  return !!schema && typeof schema === "object" && COMPLETABLE_SYMBOL in schema;
@@ -36206,6 +36221,9 @@ function notificationToServerEvent(method, uri) {
36206
36221
  };
36207
36222
  }
36208
36223
  }
36224
+ function supportsScopeChallengeResolver(transport) {
36225
+ return typeof transport === "object" && transport !== null && "setScopeChallengeResolver" in transport && typeof transport.setScopeChallengeResolver === "function";
36226
+ }
36209
36227
  var DEFAULT_LEGACY_SHIM_MAX_ROUNDS = 8;
36210
36228
  var DEFAULT_LEGACY_SHIM_ROUND_TIMEOUT_MS = 6e5;
36211
36229
  function resolveLegacyShimOptions(options) {
@@ -37016,6 +37034,7 @@ var McpServer = class {
37016
37034
  * ```
37017
37035
  */
37018
37036
  async connect(transport) {
37037
+ if (supportsScopeChallengeResolver(transport)) transport.setScopeChallengeResolver((context) => this.resolveScopeChallenge(context));
37019
37038
  return await this.server.connect(transport);
37020
37039
  }
37021
37040
  /**
@@ -37024,6 +37043,41 @@ var McpServer = class {
37024
37043
  async close() {
37025
37044
  await this.server.close();
37026
37045
  }
37046
+ /** @internal */
37047
+ resolveScopeChallenge = (context) => {
37048
+ switch (context.request.method) {
37049
+ case "tools/call": {
37050
+ const toolName = context.request.params?.name;
37051
+ if (typeof toolName !== "string") return;
37052
+ const tool = this._registeredTools[toolName];
37053
+ if (tool === void 0 || !tool.enabled) return;
37054
+ return tool.scopeChallenge?.(context);
37055
+ }
37056
+ case "resources/read": {
37057
+ const resourceUri = context.request.params?.uri;
37058
+ if (typeof resourceUri !== "string") return;
37059
+ let uri;
37060
+ try {
37061
+ uri = new URL(resourceUri);
37062
+ } catch {
37063
+ return;
37064
+ }
37065
+ const resource = this._registeredResources[uri.toString()];
37066
+ if (resource !== void 0) return resource.enabled ? resource.scopeChallenge?.(context) : void 0;
37067
+ for (const template of Object.values(this._registeredResourceTemplates)) if (template.resourceTemplate.uriTemplate.match(uri.toString())) return template.enabled ? template.scopeChallenge?.(context) : void 0;
37068
+ return;
37069
+ }
37070
+ case "prompts/get": {
37071
+ const promptName = context.request.params?.name;
37072
+ if (typeof promptName !== "string") return;
37073
+ const prompt = this._registeredPrompts[promptName];
37074
+ if (prompt === void 0 || !prompt.enabled) return;
37075
+ return prompt.scopeChallenge?.(context);
37076
+ }
37077
+ default:
37078
+ return;
37079
+ }
37080
+ };
37027
37081
  _toolHandlersInitialized = false;
37028
37082
  setToolRequestHandlers() {
37029
37083
  if (this._toolHandlersInitialized) return;
@@ -37190,7 +37244,10 @@ var McpServer = class {
37190
37244
  }
37191
37245
  for (const template of Object.values(this._registeredResourceTemplates)) {
37192
37246
  const variables = template.resourceTemplate.uriTemplate.match(uri.toString());
37193
- if (variables) return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint);
37247
+ if (variables) {
37248
+ if (!template.enabled) throw new ProtocolError(ProtocolErrorCode.InvalidParams, `Resource template ${template.resourceTemplate.uriTemplate} disabled`);
37249
+ return attachCacheHintFallback(await template.readCallback(uri, variables, ctx), template.cacheHint);
37250
+ }
37194
37251
  }
37195
37252
  throw new ResourceNotFoundError(request.params.uri);
37196
37253
  });
@@ -37221,36 +37278,32 @@ var McpServer = class {
37221
37278
  this._promptHandlersInitialized = true;
37222
37279
  }
37223
37280
  registerResource(name, uriOrTemplate, config2, readCallback) {
37224
- const cacheHint = config2.cacheHint;
37225
- let metadata = config2;
37226
- if (cacheHint !== void 0) {
37227
- assertValidCacheHint(cacheHint, `resource ${name}`);
37228
- const rest = { ...config2 };
37229
- delete rest.cacheHint;
37230
- metadata = rest;
37231
- }
37281
+ const { cacheHint, scopeChallenge, ...resourceMetadata } = config2;
37282
+ const metadata = resourceMetadata;
37283
+ if (cacheHint !== void 0) assertValidCacheHint(cacheHint, `resource ${name}`);
37232
37284
  if (typeof uriOrTemplate === "string") {
37233
37285
  if (this._registeredResources[uriOrTemplate]) throw new Error(`Resource ${uriOrTemplate} is already registered`);
37234
- const registeredResource = this._createRegisteredResource(name, config2.title, uriOrTemplate, metadata, readCallback);
37286
+ const registeredResource = this._createRegisteredResource(name, config2.title, uriOrTemplate, metadata, scopeChallenge, readCallback);
37235
37287
  if (cacheHint !== void 0) registeredResource.cacheHint = cacheHint;
37236
37288
  this.setResourceRequestHandlers();
37237
37289
  this.sendResourceListChanged();
37238
37290
  return registeredResource;
37239
37291
  } else {
37240
37292
  if (this._registeredResourceTemplates[name]) throw new Error(`Resource template ${name} is already registered`);
37241
- const registeredResourceTemplate = this._createRegisteredResourceTemplate(name, config2.title, uriOrTemplate, metadata, readCallback);
37293
+ const registeredResourceTemplate = this._createRegisteredResourceTemplate(name, config2.title, uriOrTemplate, metadata, scopeChallenge, readCallback);
37242
37294
  if (cacheHint !== void 0) registeredResourceTemplate.cacheHint = cacheHint;
37243
37295
  this.setResourceRequestHandlers();
37244
37296
  this.sendResourceListChanged();
37245
37297
  return registeredResourceTemplate;
37246
37298
  }
37247
37299
  }
37248
- _createRegisteredResource(name, title, uri, metadata, readCallback) {
37300
+ _createRegisteredResource(name, title, uri, metadata, scopeChallenge, readCallback) {
37249
37301
  const registeredResource = {
37250
37302
  name,
37251
37303
  title,
37252
37304
  metadata,
37253
37305
  readCallback,
37306
+ scopeChallenge,
37254
37307
  enabled: true,
37255
37308
  disable: () => registeredResource.update({ enabled: false }),
37256
37309
  enable: () => registeredResource.update({ enabled: true }),
@@ -37264,6 +37317,7 @@ var McpServer = class {
37264
37317
  if (updates.title !== void 0) registeredResource.title = updates.title;
37265
37318
  if (updates.metadata !== void 0) registeredResource.metadata = updates.metadata;
37266
37319
  if (updates.callback !== void 0) registeredResource.readCallback = updates.callback;
37320
+ if (updates.scopeChallenge !== void 0) registeredResource.scopeChallenge = updates.scopeChallenge === null ? void 0 : updates.scopeChallenge;
37267
37321
  if (updates.enabled !== void 0) registeredResource.enabled = updates.enabled;
37268
37322
  this.sendResourceListChanged();
37269
37323
  }
@@ -37271,12 +37325,13 @@ var McpServer = class {
37271
37325
  this._registeredResources[uri] = registeredResource;
37272
37326
  return registeredResource;
37273
37327
  }
37274
- _createRegisteredResourceTemplate(name, title, template, metadata, readCallback) {
37328
+ _createRegisteredResourceTemplate(name, title, template, metadata, scopeChallenge, readCallback) {
37275
37329
  const registeredResourceTemplate = {
37276
37330
  resourceTemplate: template,
37277
37331
  title,
37278
37332
  metadata,
37279
37333
  readCallback,
37334
+ scopeChallenge,
37280
37335
  enabled: true,
37281
37336
  disable: () => registeredResourceTemplate.update({ enabled: false }),
37282
37337
  enable: () => registeredResourceTemplate.update({ enabled: true }),
@@ -37290,6 +37345,7 @@ var McpServer = class {
37290
37345
  if (updates.template !== void 0) registeredResourceTemplate.resourceTemplate = updates.template;
37291
37346
  if (updates.metadata !== void 0) registeredResourceTemplate.metadata = updates.metadata;
37292
37347
  if (updates.callback !== void 0) registeredResourceTemplate.readCallback = updates.callback;
37348
+ if (updates.scopeChallenge !== void 0) registeredResourceTemplate.scopeChallenge = updates.scopeChallenge === null ? void 0 : updates.scopeChallenge;
37293
37349
  if (updates.enabled !== void 0) registeredResourceTemplate.enabled = updates.enabled;
37294
37350
  this.sendResourceListChanged();
37295
37351
  }
@@ -37299,7 +37355,7 @@ var McpServer = class {
37299
37355
  if (Array.isArray(variableNames) && variableNames.some((v) => !!template.completeCallback(v))) this.setCompletionRequestHandler();
37300
37356
  return registeredResourceTemplate;
37301
37357
  }
37302
- _createRegisteredPrompt(name, title, description, argsSchema, callback, icons, _meta) {
37358
+ _createRegisteredPrompt(name, title, description, argsSchema, callback, icons, scopeChallenge, _meta) {
37303
37359
  let currentArgsSchema = argsSchema;
37304
37360
  let currentCallback = callback;
37305
37361
  const registeredPrompt = {
@@ -37307,6 +37363,7 @@ var McpServer = class {
37307
37363
  description,
37308
37364
  argsSchema,
37309
37365
  icons,
37366
+ scopeChallenge,
37310
37367
  _meta,
37311
37368
  handler: createPromptHandler(name, argsSchema, callback),
37312
37369
  enabled: true,
@@ -37321,6 +37378,7 @@ var McpServer = class {
37321
37378
  if (updates.title !== void 0) registeredPrompt.title = updates.title;
37322
37379
  if (updates.description !== void 0) registeredPrompt.description = updates.description;
37323
37380
  if (updates.icons !== void 0) registeredPrompt.icons = updates.icons;
37381
+ if (updates.scopeChallenge !== void 0) registeredPrompt.scopeChallenge = updates.scopeChallenge === null ? void 0 : updates.scopeChallenge;
37324
37382
  if (updates._meta !== void 0) registeredPrompt._meta = updates._meta;
37325
37383
  let needsHandlerRegen = false;
37326
37384
  if (updates.argsSchema !== void 0) {
@@ -37348,7 +37406,7 @@ var McpServer = class {
37348
37406
  }
37349
37407
  return registeredPrompt;
37350
37408
  }
37351
- _createRegisteredTool(name, title, description, inputSchema, outputSchema, annotations, icons, execution, _meta, handler) {
37409
+ _createRegisteredTool(name, title, description, inputSchema, outputSchema, annotations, icons, execution, scopeChallenge, _meta, handler) {
37352
37410
  validateAndWarnToolName(name);
37353
37411
  if (inputSchema !== void 0) try {
37354
37412
  const json2 = standardSchemaToJsonSchema(inputSchema, "input");
@@ -37367,6 +37425,7 @@ var McpServer = class {
37367
37425
  annotations,
37368
37426
  icons,
37369
37427
  execution,
37428
+ scopeChallenge,
37370
37429
  _meta,
37371
37430
  handler,
37372
37431
  executor: createToolExecutor(inputSchema, handler),
@@ -37405,6 +37464,7 @@ var McpServer = class {
37405
37464
  }
37406
37465
  if (updates.annotations !== void 0) registeredTool.annotations = updates.annotations;
37407
37466
  if (updates.icons !== void 0) registeredTool.icons = updates.icons;
37467
+ if (updates.scopeChallenge !== void 0) registeredTool.scopeChallenge = updates.scopeChallenge === null ? void 0 : updates.scopeChallenge;
37408
37468
  if (updates._meta !== void 0) registeredTool._meta = updates._meta;
37409
37469
  if (updates.enabled !== void 0) registeredTool.enabled = updates.enabled;
37410
37470
  this.sendToolListChanged();
@@ -37417,13 +37477,13 @@ var McpServer = class {
37417
37477
  }
37418
37478
  registerTool(name, config2, cb) {
37419
37479
  if (this._registeredTools[name]) throw new Error(`Tool ${name} is already registered`);
37420
- const { title, description, inputSchema, outputSchema, annotations, icons, _meta } = config2;
37421
- return this._createRegisteredTool(name, title, description, normalizeRawShapeSchema(inputSchema), normalizeRawShapeSchema(outputSchema), annotations, icons, void 0, _meta, cb);
37480
+ const { title, description, inputSchema, outputSchema, annotations, icons, scopeChallenge, _meta } = config2;
37481
+ return this._createRegisteredTool(name, title, description, normalizeRawShapeSchema(inputSchema), normalizeRawShapeSchema(outputSchema), annotations, icons, void 0, scopeChallenge, _meta, cb);
37422
37482
  }
37423
37483
  registerPrompt(name, config2, cb) {
37424
37484
  if (this._registeredPrompts[name]) throw new Error(`Prompt ${name} is already registered`);
37425
- const { title, description, argsSchema, icons, _meta } = config2;
37426
- const registeredPrompt = this._createRegisteredPrompt(name, title, description, normalizeRawShapeSchema(argsSchema), cb, icons, _meta);
37485
+ const { title, description, argsSchema, icons, scopeChallenge, _meta } = config2;
37486
+ const registeredPrompt = this._createRegisteredPrompt(name, title, description, normalizeRawShapeSchema(argsSchema), cb, icons, scopeChallenge, _meta);
37427
37487
  this.setPromptRequestHandlers();
37428
37488
  this.sendPromptListChanged();
37429
37489
  return registeredPrompt;
@@ -37535,6 +37595,7 @@ function unwrapOptionalSchema(schema) {
37535
37595
  }
37536
37596
 
37537
37597
  // node_modules/@modelcontextprotocol/server/dist/stdio.mjs
37598
+ var swallowsErrorsAfterClose = /* @__PURE__ */ Symbol("swallowsErrorsAfterClose");
37538
37599
  var StdioServerTransport = class {
37539
37600
  _readBuffer;
37540
37601
  _started = false;
@@ -37561,18 +37622,27 @@ var StdioServerTransport = class {
37561
37622
  this.onerror?.(error62);
37562
37623
  };
37563
37624
  _onstdouterror = (error62) => {
37625
+ if (this._closed) return;
37564
37626
  this.onerror?.(error62);
37565
37627
  this.close().catch(() => {
37566
37628
  });
37567
37629
  };
37630
+ _onstdinclose = () => {
37631
+ this.close().catch(() => {
37632
+ });
37633
+ };
37568
37634
  /**
37569
37635
  * Starts listening for messages on `stdin`.
37570
37636
  */
37571
37637
  async start() {
37572
37638
  if (this._started) throw new Error("StdioServerTransport already started! If using Server class, note that connect() calls start() automatically.");
37573
37639
  this._started = true;
37640
+ for (const listener of this._stdout.listeners("error")) if (listener[swallowsErrorsAfterClose]) this._stdout.off("error", listener);
37641
+ if (this._stdin.readableEnded || this._stdin.destroyed) setImmediate(this._onstdinclose);
37574
37642
  this._stdin.on("data", this._ondata);
37575
37643
  this._stdin.on("error", this._onerror);
37644
+ this._stdin.on("end", this._onstdinclose);
37645
+ this._stdin.on("close", this._onstdinclose);
37576
37646
  this._stdout.on("error", this._onstdouterror);
37577
37647
  }
37578
37648
  processReadBuffer() {
@@ -37589,7 +37659,9 @@ var StdioServerTransport = class {
37589
37659
  this._closed = true;
37590
37660
  this._stdin.off("data", this._ondata);
37591
37661
  this._stdin.off("error", this._onerror);
37592
- this._stdout.off("error", this._onstdouterror);
37662
+ this._stdin.off("end", this._onstdinclose);
37663
+ this._stdin.off("close", this._onstdinclose);
37664
+ this._onstdouterror[swallowsErrorsAfterClose] = true;
37593
37665
  if (this._stdin.listenerCount("data") === 0) this._stdin.pause();
37594
37666
  this._readBuffer.clear();
37595
37667
  this.onclose?.();
@@ -37866,6 +37938,7 @@ function serveStdio(factory, options = {}) {
37866
37938
  return;
37867
37939
  }
37868
37940
  if (state.era === "modern" && await tryServeListen(message)) return;
37941
+ if (isTornDown()) return;
37869
37942
  state.instance.channel.deliver(message);
37870
37943
  return;
37871
37944
  }
@@ -37932,6 +38005,7 @@ function serveStdio(factory, options = {}) {
37932
38005
  };
37933
38006
  }
37934
38007
  if (await tryServeListen(message)) return;
38008
+ if (isTornDown()) return;
37935
38009
  state.instance.channel.deliver(message, { classification: opening.classification });
37936
38010
  return;
37937
38011
  case "legacy": {
@@ -38018,6 +38092,9 @@ function toError(value) {
38018
38092
  return value instanceof Error ? value : new Error(String(value));
38019
38093
  }
38020
38094
 
38095
+ // node_modules/@modelcontextprotocol/server/dist/index.mjs
38096
+ var DEFAULT_MAX_REQUEST_BODY_SIZE = 4 * 1024 * 1024;
38097
+
38021
38098
  // node_modules/@chrischall/mcp-utils/dist/errors/index.js
38022
38099
  var DEFAULT_ERROR_MESSAGE_MAX = 500;
38023
38100
  var McpToolError = class extends Error {
@@ -38058,11 +38135,12 @@ var UnreachableError = class extends McpToolError {
38058
38135
  function createHelpfulError(message, opts) {
38059
38136
  return new McpToolError(message, opts);
38060
38137
  }
38138
+ var HEADER_NAME_RUN = "[a-z0-9_-]{0,40}";
38061
38139
  var BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
38062
- var JWT_RE = /\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b/g;
38140
+ var JWT_RE = /(?<![A-Za-z0-9_-])[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}(?![A-Za-z0-9_-])/g;
38063
38141
  var BASIC_AUTH_RE = /(authorization\s*:\s*basic\s+)[A-Za-z0-9+/=_-]{6,}/gi;
38064
- var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,\s]+)=[^;,\s]*/gi;
38065
- var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,\s]+=[^;,\s]*)(?:;\s*[^=;,\s]+=[^;,\s]*)*)/gi;
38142
+ var SET_COOKIE_RE = /(\bset-cookie\s*:\s*)([^=;,:\s]+)=[^;,\s]*/gi;
38143
+ var COOKIE_HEADER_RE = /((?<!set-)\bcookie\s*:\s*)((?:[^=;,:\s]+=[^;,\s]*)(?:;\s*[^=;,:\s]+=[^;,\s]*)*)/gi;
38066
38144
  var API_KEY_RE = new RegExp([
38067
38145
  "sk-[A-Za-z0-9_-]{20,}(?![A-Za-z0-9_-])",
38068
38146
  // OpenAI / Anthropic (incl. sk-ant-…)
@@ -38077,10 +38155,10 @@ var API_KEY_RE = new RegExp([
38077
38155
  "whsec_[A-Za-z0-9]{16,}\\b"
38078
38156
  // webhook signing secret (Stripe-style)
38079
38157
  ].map((p) => `\\b${p}`).join("|"), "g");
38080
- var QUERY_SECRET_RE = /([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig)=)[^&#\s"'<>`]+/gi;
38158
+ var QUERY_SECRET_RE = new RegExp(`([?&](?:(?:access|refresh|id|auth|session|csrf|xsrf)[_-]?token|client[_-]?secret|api[_-]?secret|api[_-]?key|password|passwd|signature|token|key|sig|(?:php|j)?sess(?:ion)?[_-]?id|sid|x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN})=)[^&#\\s"'<>\`]+`, "gi");
38081
38159
  var OAUTH_CODE_RE = /([?&]code=)(?=[^&#\s"'<>`]{16,})[^&#\s"'<>`]+/gi;
38082
38160
  var FORM_PASSWORD_RE = /((?:^|[\s,;:"'(\[{])(?:password|passwd)=)[^&#\s"'<>`]+/gim;
38083
- var HEADER_SECRET_RE = /(\b(?:x[-_][a-z0-9_-]*?(?:api[-_]?key|token|secret|auth)[a-z0-9_-]*|api[-_]?key)\s*:\s*)[^\s,;"'<>`]+/gi;
38161
+ var HEADER_SECRET_RE = new RegExp(`(\\b(?:x[-_]${HEADER_NAME_RUN}?(?:api[-_]?key|token|secret|auth)${HEADER_NAME_RUN}|api[-_]?key)\\s*:\\s*)[^\\s,;"'<>\`]+`, "gi");
38084
38162
  var AWS_SIGV4_RE = /([?&]X-Amz-(?:Signature|Security-Token|Credential)=)[^&#\s"'<>`]+/gi;
38085
38163
  var JSON_SECRET_KEYS = [
38086
38164
  "(?:access|refresh|id|auth|session|bearer|csrf|xsrf)[_-]?token",
@@ -38088,7 +38166,8 @@ var JSON_SECRET_KEYS = [
38088
38166
  "api[_-]?secret",
38089
38167
  "(?:x[_-])?api[_-]?key",
38090
38168
  "private[_-]?key",
38091
- "x[_-][a-z0-9_-]*?(?:api[_-]?key|token|secret|auth)[a-z0-9_-]*",
38169
+ `x[_-]${HEADER_NAME_RUN}?(?:api[_-]?key|token|secret|auth)${HEADER_NAME_RUN}`,
38170
+ // bounded like HEADER_SECRET_RE
38092
38171
  "(?:proxy-)?authorization",
38093
38172
  "password",
38094
38173
  "passwd",
@@ -38097,12 +38176,30 @@ var JSON_SECRET_KEYS = [
38097
38176
  ].join("|");
38098
38177
  var JSON_SECRET_DQ_RE = new RegExp(`("(?:${JSON_SECRET_KEYS})"\\s*:\\s*")(?:[^"\\\\]|\\\\.)*(")`, "gi");
38099
38178
  var JSON_SECRET_SQ_RE = new RegExp(`('(?:${JSON_SECRET_KEYS})'\\s*:\\s*')(?:[^'\\\\]|\\\\.)*(')`, "gi");
38179
+ var JSON_SECRET_NUM_RE = new RegExp(`(["'](?:${JSON_SECRET_KEYS})["']\\s*:\\s*)-?\\d+(?:\\.\\d+)?(?![\\d.])`, "gi");
38180
+ var JSON_COOKIE_DQ_RE = /("((?:set-)?cookie)"\s*:\s*)("(?:[^"\\]|\\.)*"|\[\s*"(?:[^"\\]|\\.)*"(?:\s*,\s*"(?:[^"\\]|\\.)*")*)/gi;
38181
+ var JSON_COOKIE_SQ_RE = /('((?:set-)?cookie)'\s*:\s*)('(?:[^'\\]|\\.)*'|\[\s*'(?:[^'\\]|\\.)*'(?:\s*,\s*'(?:[^'\\]|\\.)*')*)/gi;
38182
+ var DQ_STRING_RE = /"((?:[^"\\]|\\.)*)"/g;
38183
+ var SQ_STRING_RE = /'((?:[^'\\]|\\.)*)'/g;
38184
+ function redactJsonCookie(quote) {
38185
+ const stringRe = quote === '"' ? DQ_STRING_RE : SQ_STRING_RE;
38186
+ return (_m, prefix, key, value) => {
38187
+ const setCookie = key.toLowerCase() === "set-cookie";
38188
+ const redacted = value.replace(stringRe, (_s, body) => {
38189
+ const out = setCookie ? body.replace(/^(\s*[^=;]+)=[^;]*/, "$1=[REDACTED]") : body.replace(/=[^;]*/g, "=[REDACTED]");
38190
+ return `${quote}${out}${quote}`;
38191
+ });
38192
+ return `${prefix}${redacted}`;
38193
+ };
38194
+ }
38100
38195
  function redactSecrets(text) {
38101
- return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(HEADER_SECRET_RE, "$1[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(OAUTH_CODE_RE, "$1[REDACTED]").replace(FORM_PASSWORD_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
38196
+ return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(HEADER_SECRET_RE, "$1[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(OAUTH_CODE_RE, "$1[REDACTED]").replace(FORM_PASSWORD_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_COOKIE_DQ_RE, redactJsonCookie('"')).replace(JSON_COOKIE_SQ_RE, redactJsonCookie("'")).replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_NUM_RE, "$1[REDACTED]").replace(JWT_RE, "[REDACTED]");
38102
38197
  }
38198
+ var ERROR_REDACTION_INPUT_MAX = 64 * 1024;
38103
38199
  function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
38104
38200
  const str2 = text === null || text === void 0 ? "" : String(text);
38105
- const redacted = redactSecrets(str2);
38201
+ const cap = Math.max(ERROR_REDACTION_INPUT_MAX, max);
38202
+ const redacted = redactSecrets(str2.length > cap ? str2.slice(0, cap) : str2);
38106
38203
  if (redacted.length <= max)
38107
38204
  return redacted;
38108
38205
  return `${redacted.slice(0, max)}\u2026 [truncated]`;
@@ -58099,6 +58196,9 @@ async function tokenConfirmation(action, fb) {
58099
58196
  const subject = await fb.subject();
58100
58197
  if (isToolResult(subject))
58101
58198
  return subject;
58199
+ if (subject.payload === void 0) {
58200
+ throw new TypeError(`confirm token: ${fb.tool}'s subject() returned no payload. Bind what the call will do (the arguments or the request body), or pass \`args\` to confirmationFromEnv.`);
58201
+ }
58102
58202
  const binding = {
58103
58203
  tool: fb.tool,
58104
58204
  ...fb.account === void 0 ? {} : { account: fb.account },
@@ -58202,9 +58302,19 @@ function readConfirmMode(env = process.env) {
58202
58302
  }
58203
58303
  return "refuse";
58204
58304
  }
58205
- function confirmTtlFromEnv(env = process.env) {
58305
+ function readConfirmTtl(env) {
58206
58306
  const raw = readEnvVar("MCP_CONFIRM_TTL_SECONDS", { env })?.trim();
58207
- return raw && /^[1-9]\d*$/.test(raw) ? Number(raw) : DEFAULT_TTL_SECONDS2;
58307
+ if (!raw)
58308
+ return DEFAULT_TTL_SECONDS2;
58309
+ if (/^[1-9]\d*$/.test(raw))
58310
+ return Number(raw);
58311
+ const key = `ttl:${raw}`;
58312
+ if (!warned.has(key)) {
58313
+ warned.add(key);
58314
+ process.stderr.write(`MCP_CONFIRM_TTL_SECONDS="${raw}" is not a positive whole number of seconds; gated writes will refuse on clients that cannot show a confirmation prompt until it is fixed.
58315
+ `);
58316
+ }
58317
+ return void 0;
58208
58318
  }
58209
58319
  var processKey;
58210
58320
  function confirmKeyFromEnv(env = process.env) {
@@ -58216,15 +58326,33 @@ function confirmKeyFromEnv(env = process.env) {
58216
58326
  }
58217
58327
  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
58328
  var REFUSE_HINT = "Set MCP_CONFIRM_MODE=ask-user on the server to allow two-step confirmation instead.";
58329
+ var BAD_TTL_HINT = "MCP_CONFIRM_TTL_SECONDS on the server is not a positive whole number of seconds; fix it to allow two-step confirmation.";
58330
+ function boundArgs(args) {
58331
+ if (args === null || typeof args !== "object" || Array.isArray(args) || !("confirmToken" in args))
58332
+ return args;
58333
+ const { confirmToken, ...rest } = args;
58334
+ void confirmToken;
58335
+ return rest;
58336
+ }
58219
58337
  function confirmationFromEnv(options) {
58220
- const { tool, account, confirmToken, subject, instruction, spent, env = process.env, ...confirmation } = options;
58338
+ const { tool, account, confirmToken, subject, instruction, spent, args, env = process.env, ...rest } = options;
58221
58339
  const mode = readConfirmMode(env);
58222
- if (mode === "refuse") {
58340
+ const ttlSeconds = readConfirmTtl(env);
58341
+ const bound = args === void 0 ? void 0 : boundArgs(args);
58342
+ const confirmation = bound === void 0 || rest.binding ? rest : { ...rest, binding: { key: confirmKeyFromEnv(env), args: bound, ttlSeconds: ttlSeconds ?? DEFAULT_TTL_SECONDS2 } };
58343
+ if (mode === "refuse" || ttlSeconds === void 0) {
58344
+ const hint = mode === "refuse" ? REFUSE_HINT : BAD_TTL_HINT;
58223
58345
  return {
58224
58346
  ...confirmation,
58225
- unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${REFUSE_HINT}` : REFUSE_HINT
58347
+ unsupportedNote: confirmation.unsupportedNote ? `${confirmation.unsupportedNote} ${hint}` : hint
58226
58348
  };
58227
58349
  }
58350
+ const boundSubject = bound === void 0 ? subject : async () => {
58351
+ const read = await subject();
58352
+ if ("content" in read)
58353
+ return read;
58354
+ return { ...read, payload: { payload: read.payload, args: bound } };
58355
+ };
58228
58356
  return {
58229
58357
  ...confirmation,
58230
58358
  tokenFallback: {
@@ -58232,8 +58360,8 @@ function confirmationFromEnv(options) {
58232
58360
  tool,
58233
58361
  ...account === void 0 ? {} : { account },
58234
58362
  ...confirmToken === void 0 ? {} : { confirmToken },
58235
- subject,
58236
- ttlSeconds: confirmTtlFromEnv(env),
58363
+ subject: boundSubject,
58364
+ ttlSeconds,
58237
58365
  instruction: mode === "auto" ? CONFIRM_TOKEN_AUTO_INSTRUCTION : instruction ?? CONFIRM_TOKEN_INSTRUCTION,
58238
58366
  ...spent === void 0 ? {} : { spent }
58239
58367
  }
@@ -58560,7 +58688,7 @@ function toolAnnotations(opts = {}) {
58560
58688
  }
58561
58689
 
58562
58690
  // src/version.ts
58563
- var VERSION = "2.2.0";
58691
+ var VERSION = "2.2.2";
58564
58692
 
58565
58693
  // src/client.ts
58566
58694
  import { dirname as dirname2, join as join2 } from "path";
@@ -59378,6 +59506,22 @@ var CheckinStoreCore = class {
59378
59506
  [...params, limit]
59379
59507
  );
59380
59508
  }
59509
+ /** Delete one user's cached rows (case-insensitive) in a single transaction. */
59510
+ forgetUser(username) {
59511
+ const key = username.toLowerCase();
59512
+ const count2 = (table) => Number(this.db.get(`SELECT COUNT(*) AS n FROM ${table} WHERE username = ?`, [key])?.n ?? 0);
59513
+ const removed = {
59514
+ checkins: count2("checkins"),
59515
+ distinct_beers: count2("distinct_beers"),
59516
+ sync_state: count2("sync_state")
59517
+ };
59518
+ this.db.transaction(() => {
59519
+ this.db.run("DELETE FROM checkins WHERE username = ?", [key]);
59520
+ this.db.run("DELETE FROM distinct_beers WHERE username = ?", [key]);
59521
+ this.db.run("DELETE FROM sync_state WHERE username = ?", [key]);
59522
+ });
59523
+ return removed;
59524
+ }
59381
59525
  getBeerMeta(bids) {
59382
59526
  if (bids.length === 0) return [];
59383
59527
  const placeholders = bids.map(() => "?").join(", ");
@@ -59458,6 +59602,9 @@ var LocalCacheStore = class {
59458
59602
  async upsertBeerMeta(rows) {
59459
59603
  this.core.upsertBeerMeta(rows);
59460
59604
  }
59605
+ async forgetUser(username) {
59606
+ return this.core.forgetUser(username);
59607
+ }
59461
59608
  };
59462
59609
  function escapeLike(s) {
59463
59610
  return s.replace(/[%_]/g, " ");
@@ -60293,7 +60440,13 @@ async function checkPhoto(photoPath) {
60293
60440
  hint: "Attach a normal-sized JPEG or PNG photo."
60294
60441
  });
60295
60442
  }
60296
- const sniffed = sniffMimeBytes(await readFileHead(real, 16));
60443
+ let head;
60444
+ try {
60445
+ head = await readFileHead(real, 16, { allowedRoots: roots });
60446
+ } catch {
60447
+ throw new McpToolError("Photo file not found or not readable.");
60448
+ }
60449
+ const sniffed = sniffMimeBytes(head);
60297
60450
  if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
60298
60451
  throw createHelpfulError(
60299
60452
  sniffed === "image/jpeg" || sniffed === "image/png" ? `Photo content (${sniffed}) does not match its .${ext} extension.` : "The file is not a JPEG or PNG image.",
@@ -61288,11 +61441,48 @@ function registerCacheTools(server, client2, cacheProvider) {
61288
61441
  });
61289
61442
  }
61290
61443
  );
61444
+ server.registerTool(
61445
+ "untappd_cache_forget",
61446
+ {
61447
+ title: "Forget a user's cached check-ins",
61448
+ 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}`,
61449
+ annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
61450
+ inputSchema: external_exports.object({
61451
+ username: UsernameArg2,
61452
+ confirmToken: confirmTokenParam
61453
+ })
61454
+ },
61455
+ async ({ username, confirmToken }, ctx) => {
61456
+ const user = resolveUser2(username, client2.loginName);
61457
+ const cache = cacheProvider();
61458
+ const counts = {
61459
+ checkins: await cache.cachedCount(user),
61460
+ distinct_beers: await cache.distinctBeersCount(user)
61461
+ };
61462
+ const gate = await confirmWrite(ctx, {
61463
+ tool: "untappd_cache_forget",
61464
+ action: "untappd.cache_forget",
61465
+ message: `Review and confirm deleting ${user}'s data from the local Untappd cache:`,
61466
+ confirmToken,
61467
+ target: user.toLowerCase(),
61468
+ payload: { username: user.toLowerCase(), ...counts },
61469
+ preview: {
61470
+ action: "cache_forget",
61471
+ username: user,
61472
+ ...counts,
61473
+ note: "Deletes these rows from the LOCAL cache only; Untappd itself is unchanged and a re-sync can restore them."
61474
+ }
61475
+ });
61476
+ if (gate) return gate;
61477
+ const removed = await cache.forgetUser(user);
61478
+ return minifiedResult({ forgotten: true, username: user, removed });
61479
+ }
61480
+ );
61291
61481
  }
61292
61482
 
61293
61483
  // src/cache/db.ts
61294
61484
  import { DatabaseSync } from "node:sqlite";
61295
- import { mkdirSync } from "node:fs";
61485
+ import { chmodSync, closeSync, existsSync as existsSync2, mkdirSync, openSync } from "node:fs";
61296
61486
  import { dirname as dirname3, join as join3 } from "node:path";
61297
61487
  import { homedir as homedir2 } from "node:os";
61298
61488
  var NodeSqlDriver = class {
@@ -61329,15 +61519,39 @@ var CheckinCache = class _CheckinCache extends LocalCacheStore {
61329
61519
  this.db = db;
61330
61520
  }
61331
61521
  db;
61332
- /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
61522
+ /**
61523
+ * Open (creating parent dirs) a file-backed cache, or `:memory:` for tests.
61524
+ * The file holds other people's dated check-in and venue history, so it is
61525
+ * kept owner-only: dirs 0700, the db (and any -wal/-shm/-journal) 0600.
61526
+ */
61333
61527
  static open(path) {
61334
- if (path !== ":memory:") mkdirSync(dirname3(path), { recursive: true });
61335
- return new _CheckinCache(new DatabaseSync(path));
61528
+ if (path !== ":memory:") preparePrivateFile(path);
61529
+ const cache = new _CheckinCache(new DatabaseSync(path));
61530
+ if (path !== ":memory:") tightenSidecars(path);
61531
+ return cache;
61336
61532
  }
61337
61533
  close() {
61338
61534
  this.db.close();
61339
61535
  }
61340
61536
  };
61537
+ function preparePrivateFile(path) {
61538
+ const dir = dirname3(path);
61539
+ mkdirSync(dir, { recursive: true, mode: 448 });
61540
+ bestEffortChmod(dir, 448);
61541
+ if (!existsSync2(path)) closeSync(openSync(path, "a", 384));
61542
+ bestEffortChmod(path, 384);
61543
+ }
61544
+ function tightenSidecars(path) {
61545
+ for (const suffix of ["-wal", "-shm", "-journal"]) {
61546
+ if (existsSync2(path + suffix)) bestEffortChmod(path + suffix, 384);
61547
+ }
61548
+ }
61549
+ function bestEffortChmod(path, mode) {
61550
+ try {
61551
+ chmodSync(path, mode);
61552
+ } catch {
61553
+ }
61554
+ }
61341
61555
  function defaultCachePath() {
61342
61556
  return readEnvVar("UNTAPPD_CACHE_DB") ?? join3(homedir2(), ".untappd-mcp", "checkins.db");
61343
61557
  }
@@ -61371,7 +61585,7 @@ await runMcp({
61371
61585
  });
61372
61586
  /*! Bundled license information:
61373
61587
 
61374
- @modelcontextprotocol/server/dist/src-CX2iR2pK.mjs:
61588
+ @modelcontextprotocol/server/dist/src-D-y6h4N7.mjs:
61375
61589
  (*!
61376
61590
  * content-type
61377
61591
  * Copyright(c) 2015 Douglas Christopher Wilson
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
- /** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
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
- mkdirSync(dirname(path), { recursive: true });
53
- return new CheckinCache(new DatabaseSync(path));
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');
@@ -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
@@ -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
  }
@@ -64,7 +64,15 @@ async function checkPhoto(photoPath) {
64
64
  hint: 'Attach a normal-sized JPEG or PNG photo.',
65
65
  });
66
66
  }
67
- const sniffed = sniffMimeBytes(await readFileHead(real, 16));
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);
68
76
  if (sniffed !== PHOTO_CONTENT_TYPES[ext]) {
69
77
  throw createHelpfulError(sniffed === 'image/jpeg' || sniffed === 'image/png'
70
78
  ? `Photo content (${sniffed}) does not match its .${ext} extension.`
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.2.0'; // x-release-please-version
6
+ export const VERSION = '2.2.2'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "2.2.0",
3
+ "version": "2.2.2",
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>",
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.2.0",
9
+ "version": "2.2.2",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "2.2.0",
14
+ "version": "2.2.2",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },
@@ -42,7 +42,7 @@ Most user tools default `username` to your configured account when omitted.
42
42
 
43
43
  ## Response shape (`view`)
44
44
 
45
- Thirteen of this server's 45 tools take `view: "compact" | "full"`, and
45
+ Thirteen of this server's 46 tools take `view: "compact" | "full"`, and
46
46
  **`compact` is the DEFAULT** — the slim rung is what you get without asking
47
47
  for it.
48
48
 
@@ -104,14 +104,14 @@ silently manufactured.
104
104
  `full` already IS the upstream response and a third value would silently alias
105
105
  one that exists.
106
106
 
107
- ### The 32 tools without `view`
107
+ ### The 33 tools without `view`
108
108
 
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 confirmation-gated writes** (`untappd_checkin`, `untappd_toast`,
112
+ - **The 12 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 confirmation preview or a receipt. Nothing in a receipt is
114
+ actions, and the local `untappd_cache_forget`) 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
@@ -190,6 +190,12 @@ Query tools (has-had ones consult BOTH sources — a hit in either = had):
190
190
  - `untappd_cache_query` — filter cached check-ins by brewery, style, `min_rating`,
191
191
  venue, and date range.
192
192
 
193
+ **Retention.** The cache keeps what you sync until you remove it — including
194
+ friends' dated check-ins, comments and venues. `untappd_cache_forget` (confirmation-gated)
195
+ deletes one user's check-ins, distinct beers and sync state from the LOCAL cache
196
+ only; Untappd is untouched and a later sync can re-fetch. The file is created
197
+ owner-only (dir `0700`, file `0600`).
198
+
193
199
  Every cache read returns a `freshness` block reporting each source's completeness
194
200
  separately (plus `coverage_complete` and a `caveat` when incomplete), so you can
195
201
  flag a "not found" as possibly a false negative until the relevant sync finishes.