untappd-mcp 1.3.1 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,5 @@
1
1
  import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
- import { mapCheckinRow } from './db.js';
2
+ import { mapCheckinRow } from './store.js';
3
3
  const PAGE_LIMIT = 50; // Untappd's max page size for /user/checkins.
4
4
  /** One `/user/checkins` page fetch, tolerant of the fat/undocumented shape. */
5
5
  async function fetchPage(client, encodedUser, maxId) {
@@ -46,7 +46,7 @@ async function fetchTotalCheckins(client, encodedUser) {
46
46
  */
47
47
  export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
48
48
  const encodedUser = encodeURIComponent(rawUsername);
49
- const prior = cache.getState(rawUsername);
49
+ const prior = await cache.getState(rawUsername);
50
50
  const priorNewest = prior?.newest_checkin_id ?? null;
51
51
  let pages = 0;
52
52
  let added = 0;
@@ -92,7 +92,7 @@ export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
92
92
  break;
93
93
  }
94
94
  const rows = rowsOf(rawUsername, page.items);
95
- added += cache.upsertCheckins(rawUsername, rows);
95
+ added += await cache.upsertCheckins(rawUsername, rows);
96
96
  const oldestOnPage = rows[rows.length - 1].checkin_id;
97
97
  if (oldestOnPage <= priorNewest) {
98
98
  caughtUp = true; // reconnected to the cached block
@@ -108,16 +108,16 @@ export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
108
108
  }
109
109
  catchupMaxId = page.nextMaxId;
110
110
  // Persist the resume cursor after EVERY page; the boundary stays put.
111
- cache.setState(rawUsername, { catchup_max_id: catchupMaxId, last_synced_at: now() });
111
+ await cache.setState(rawUsername, { catchup_max_id: catchupMaxId, last_synced_at: now() });
112
112
  maxId = page.nextMaxId;
113
113
  }
114
114
  if (caughtUp) {
115
115
  // Contiguous from the true newest down to the old block: advance the
116
116
  // boundary and clear the catch-up cursor.
117
- newest = cache.newestCachedId(rawUsername);
117
+ newest = await cache.newestCachedId(rawUsername);
118
118
  catchupMaxId = null;
119
119
  catchupInProgress = false;
120
- cache.setState(rawUsername, {
120
+ await cache.setState(rawUsername, {
121
121
  newest_checkin_id: newest,
122
122
  catchup_max_id: null,
123
123
  backfill_complete: backfillComplete,
@@ -144,14 +144,14 @@ export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
144
144
  break;
145
145
  }
146
146
  const rows = rowsOf(rawUsername, page.items);
147
- added += cache.upsertCheckins(rawUsername, rows);
147
+ added += await cache.upsertCheckins(rawUsername, rows);
148
148
  if (newest === null)
149
149
  newest = rows[0].checkin_id; // first-ever sync sets the boundary
150
150
  oldestMaxId = page.nextMaxId;
151
151
  if (page.nextMaxId === null)
152
152
  backfillComplete = true;
153
153
  // Persist progress after EVERY page so an interruption never loses work.
154
- cache.setState(rawUsername, {
154
+ await cache.setState(rawUsername, {
155
155
  newest_checkin_id: newest,
156
156
  oldest_max_id: oldestMaxId,
157
157
  backfill_complete: backfillComplete,
@@ -163,8 +163,8 @@ export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
163
163
  }
164
164
  }
165
165
  const total = await fetchTotalCheckins(client, encodedUser);
166
- const cached = cache.cachedCount(rawUsername);
167
- cache.setState(rawUsername, {
166
+ const cached = await cache.cachedCount(rawUsername);
167
+ await cache.setState(rawUsername, {
168
168
  newest_checkin_id: newest,
169
169
  oldest_max_id: oldestMaxId,
170
170
  catchup_max_id: catchupMaxId,
@@ -190,7 +190,7 @@ export async function syncCheckins(client, cache, rawUsername, maxPages = 10) {
190
190
  rows_added: added,
191
191
  pages_fetched: pages,
192
192
  cached_checkins: cached,
193
- distinct_beers: cache.distinctBeerCount(rawUsername),
193
+ distinct_beers: await cache.distinctBeerCount(rawUsername),
194
194
  total_checkins: total,
195
195
  backfill_percent: backfillPercent,
196
196
  backfill_complete: backfillComplete,
package/dist/index.js CHANGED
@@ -14,10 +14,16 @@ import { registerWishlistTools } from './tools/wishlist.js';
14
14
  import { registerCheckinTools } from './tools/checkin.js';
15
15
  import { registerUtilityTools } from './tools/utilities.js';
16
16
  import { registerCacheTools } from './tools/cache.js';
17
+ import { logRegisteredTools } from './tools/diagnostics.js';
18
+ import { CheckinCache, defaultCachePath } from './cache/db.js';
17
19
  // Build the env-based client once and inject it into each registrar. The
18
20
  // constructor defers its config error, so the server still boots (and answers
19
21
  // the host's install-time tools/list probe) when credentials are absent.
20
22
  const client = new UntappdClient();
23
+ // The stdio server backs the cache with a local `node:sqlite` file, opened lazily
24
+ // on first cache-tool use so the server still boots when no path is configured.
25
+ let nodeCache;
26
+ const nodeCacheProvider = () => (nodeCache ??= CheckinCache.open(defaultCachePath()));
21
27
  await runMcp({
22
28
  name: 'untappd-mcp',
23
29
  version: VERSION,
@@ -34,9 +40,11 @@ await runMcp({
34
40
  (s) => registerWishlistTools(s, client),
35
41
  (s) => registerCheckinTools(s, client),
36
42
  (s) => registerUtilityTools(s, client),
37
- // Cache tools live only on the Node/stdio server: they need a local SQLite
38
- // file, which the filesystem-less Cloudflare Worker (src/worker.ts) can't
39
- // provide, so they are intentionally NOT registered there.
40
- (s) => registerCacheTools(s, client),
43
+ // The stdio server backs the cache with a local `node:sqlite` file; the
44
+ // remote Cloudflare connector (src/worker.ts) backs the same cache tools
45
+ // with a per-user Durable Object instead.
46
+ (s) => registerCacheTools(s, client, nodeCacheProvider),
47
+ // Keep last: logs the full registered toolset (count + names) at startup.
48
+ (s) => logRegisteredTools(s, 'stdio'),
41
49
  ],
42
50
  });
@@ -1,6 +1,5 @@
1
1
  import { z } from 'zod';
2
2
  import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
- import { CheckinCache, defaultCachePath } from '../cache/db.js';
4
3
  import { syncCheckins } from '../cache/sync.js';
5
4
  const UsernameArg = z
6
5
  .string()
@@ -23,9 +22,9 @@ function resolveUser(username, loginName) {
23
22
  * is, and a plain-language warning when a "not had" answer might be a false
24
23
  * negative.
25
24
  */
26
- function freshness(cache, username) {
27
- const state = cache.getState(username);
28
- const cached = cache.cachedCount(username);
25
+ async function freshness(cache, username) {
26
+ const state = await cache.getState(username);
27
+ const cached = await cache.cachedCount(username);
29
28
  const backfillComplete = state?.backfill_complete ?? false;
30
29
  const percent = state?.total_checkins && state.total_checkins > 0
31
30
  ? Math.min(100, Math.round((cached / state.total_checkins) * 100))
@@ -49,24 +48,24 @@ function freshness(cache, username) {
49
48
  catchup_in_progress: catchupInProgress,
50
49
  backfill_percent: percent,
51
50
  cached_checkins: cached,
52
- distinct_beers: cache.distinctBeerCount(username),
51
+ distinct_beers: await cache.distinctBeerCount(username),
53
52
  ...(caveat ? { caveat } : {}),
54
53
  };
55
54
  }
56
55
  /**
57
- * Register the local check-in cache tools. `cacheProvider` is injectable for
58
- * tests; in production it lazily opens the env-configured on-disk SQLite file on
59
- * first use (so the server still boots when no cache path/credentials are set).
56
+ * Register the check-in cache tools. `cacheProvider` supplies the backing store
57
+ * (a `node:sqlite` file on the stdio server, a Durable Object on the remote
58
+ * connector) and is required so this module imports no platform-specific code.
60
59
  */
61
- export function registerCacheTools(server, client, cacheProvider = defaultCacheProvider()) {
60
+ export function registerCacheTools(server, client, cacheProvider) {
62
61
  server.registerTool('untappd_sync_checkins', {
63
- title: 'Sync Untappd check-ins into the local cache',
64
- description: "Fetch a user's check-ins into a local SQLite cache so history can be queried without paging the API. " +
62
+ title: 'Sync Untappd check-ins into the cache',
63
+ description: "Fetch a user's check-ins into a persistent cache so history can be queried without paging the API. " +
65
64
  'Incremental (stops once it reaches already-cached check-ins) and resumable: if the backfill is not yet ' +
66
65
  'complete it pages backwards up to max_pages per call and persists progress after every page, so run it ' +
67
- 'repeatedly until backfill_complete is true. Syncing another user requires their account be public or a ' +
66
+ 'repeatedly until another_run_needed is false. Syncing another user requires their account be public or a ' +
68
67
  'friend. Omit username for your own account.',
69
- annotations: toolAnnotations({ title: 'Sync Untappd check-ins into the local cache', readOnly: false, idempotent: false, openWorld: true }),
68
+ annotations: toolAnnotations({ title: 'Sync Untappd check-ins into the cache', readOnly: false, idempotent: false, openWorld: true }),
70
69
  inputSchema: {
71
70
  username: UsernameArg,
72
71
  max_pages: z
@@ -83,12 +82,12 @@ export function registerCacheTools(server, client, cacheProvider = defaultCacheP
83
82
  return textResult(summary);
84
83
  });
85
84
  server.registerTool('untappd_cache_has_had', {
86
- title: 'Check if a user has had a beer (from local cache)',
87
- description: 'Answer "has this user ever checked in this beer?" from the LOCAL cache only — NO API call. Match by exact ' +
85
+ title: 'Check if a user has had a beer (from the cache)',
86
+ description: 'Answer "has this user ever checked in this beer?" from the cache only — NO API call. Match by exact ' +
88
87
  'bid, or by a case-insensitive substring of the beer name. Returns whether they had it, how many times, the ' +
89
88
  "best rating, the last date, and the matching check-ins. Always reports cache freshness so you can caveat " +
90
89
  'incomplete data. Requires bid or beer_name. Run untappd_sync_checkins first.',
91
- annotations: toolAnnotations({ title: 'Check if a user has had a beer (from local cache)', readOnly: true, idempotent: true, openWorld: false }),
90
+ annotations: toolAnnotations({ title: 'Check if a user has had a beer (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
92
91
  inputSchema: {
93
92
  username: UsernameArg,
94
93
  bid: z.number().int().positive().optional().describe('Exact Untappd beer id to look for'),
@@ -100,15 +99,15 @@ export function registerCacheTools(server, client, cacheProvider = defaultCacheP
100
99
  }
101
100
  const user = resolveUser(username, client.loginName);
102
101
  const cache = cacheProvider();
103
- const result = cache.hasHad(user, { bid, beerName: beer_name });
104
- return textResult({ username: user, query: { bid, beer_name }, ...result, freshness: freshness(cache, user) });
102
+ const result = await cache.hasHad(user, { bid, beerName: beer_name });
103
+ return textResult({ username: user, query: { bid, beer_name }, ...result, freshness: await freshness(cache, user) });
105
104
  });
106
105
  server.registerTool('untappd_cache_has_had_many', {
107
- title: 'Batch-check many beers against the local cache',
106
+ title: 'Batch-check many beers against the cache',
108
107
  description: 'Cross-check a list of beer ids against a user\'s cached history in ONE call — NO API call. Returns had/not-had ' +
109
108
  'per bid (with count and last date when had). Ideal for checking a whole venue menu at once. Run ' +
110
109
  'untappd_sync_checkins first; the freshness block flags if the cache is incomplete.',
111
- annotations: toolAnnotations({ title: 'Batch-check many beers against the local cache', readOnly: true, idempotent: true, openWorld: false }),
110
+ annotations: toolAnnotations({ title: 'Batch-check many beers against the cache', readOnly: true, idempotent: true, openWorld: false }),
112
111
  inputSchema: {
113
112
  username: UsernameArg,
114
113
  bids: z.array(z.number().int().positive()).min(1).max(500).describe('Beer ids to check (1–500)'),
@@ -124,7 +123,7 @@ export function registerCacheTools(server, client, cacheProvider = defaultCacheP
124
123
  if (seen.has(bid))
125
124
  continue;
126
125
  seen.add(bid);
127
- const r = cache.hasHad(user, { bid });
126
+ const r = await cache.hasHad(user, { bid });
128
127
  if (r.had)
129
128
  hadCount++;
130
129
  results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating });
@@ -135,13 +134,13 @@ export function registerCacheTools(server, client, cacheProvider = defaultCacheP
135
134
  had: hadCount,
136
135
  not_had: seen.size - hadCount,
137
136
  results,
138
- freshness: freshness(cache, user),
137
+ freshness: await freshness(cache, user),
139
138
  });
140
139
  });
141
140
  server.registerTool('untappd_cache_query', {
142
141
  title: 'Query cached check-ins with filters',
143
142
  description: "Query a user's cached check-ins by brewery, style, minimum rating, venue, and/or date range, with sorting " +
144
- 'and a limit — from the LOCAL cache only, NO API call. Returns the matching check-ins plus cache freshness. ' +
143
+ 'and a limit — from the cache only, NO API call. Returns the matching check-ins plus cache freshness. ' +
145
144
  'Run untappd_sync_checkins first.',
146
145
  annotations: toolAnnotations({ title: 'Query cached check-ins with filters', readOnly: true, idempotent: true, openWorld: false }),
147
146
  inputSchema: {
@@ -163,12 +162,7 @@ export function registerCacheTools(server, client, cacheProvider = defaultCacheP
163
162
  }, async ({ username, ...filters }) => {
164
163
  const user = resolveUser(username, client.loginName);
165
164
  const cache = cacheProvider();
166
- const rows = cache.query(user, filters);
167
- return textResult({ username: user, count: rows.length, filters, results: rows, freshness: freshness(cache, user) });
165
+ const rows = await cache.query(user, filters);
166
+ return textResult({ username: user, count: rows.length, filters, results: rows, freshness: await freshness(cache, user) });
168
167
  });
169
168
  }
170
- /** Lazily open (once) the env-configured on-disk cache. */
171
- function defaultCacheProvider() {
172
- let cache;
173
- return () => (cache ??= CheckinCache.open(defaultCachePath()));
174
- }
@@ -0,0 +1,33 @@
1
+ // Small helpers so the live build's exact toolset is visible from the client
2
+ // (via untappd_healthcheck) and from server logs (a startup line). This is how
3
+ // you confirm a deploy actually shipped the tools you expect — e.g. that the
4
+ // remote connector is exposing the cache tools, not a stale 37-tool build.
5
+ /** The names of every tool registered on `server`, sorted. Defensive if the SDK internal shape drifts. */
6
+ export function registeredToolNames(server) {
7
+ const reg = server._registeredTools;
8
+ return reg ? Object.keys(reg).sort() : [];
9
+ }
10
+ /**
11
+ * FNV-1a 32-bit hash of the sorted tool-name list — a stable, dependency-free
12
+ * fingerprint of the exposed toolset (works in both Node and Workers), so two
13
+ * builds can be compared at a glance without diffing the full list.
14
+ */
15
+ export function toolsHash(names) {
16
+ let h = 0x811c9dc5;
17
+ const s = names.join(',');
18
+ for (let i = 0; i < s.length; i++) {
19
+ h ^= s.charCodeAt(i);
20
+ h = Math.imul(h, 0x01000193);
21
+ }
22
+ return (h >>> 0).toString(16).padStart(8, '0');
23
+ }
24
+ /** Version + tool count + tool-name-hash + names, for the healthcheck payload. */
25
+ export function toolInventory(server) {
26
+ const names = registeredToolNames(server);
27
+ return { tool_count: names.length, tools_hash: toolsHash(names), tools: names };
28
+ }
29
+ /** Emit a one-line startup log of the registered tools (stderr on Node, logs on Workers). */
30
+ export function logRegisteredTools(server, context) {
31
+ const names = registeredToolNames(server);
32
+ console.error(`[untappd-mcp] ${context}: ${names.length} tools registered [${toolsHash(names)}]: ${names.join(', ')}`);
33
+ }
@@ -1,16 +1,24 @@
1
1
  import { textResult, toolAnnotations } from '@chrischall/mcp-utils';
2
+ import { VERSION } from '../version.js';
3
+ import { toolInventory } from './diagnostics.js';
2
4
  export function registerUtilityTools(server, client) {
3
5
  server.registerTool('untappd_healthcheck', {
4
6
  title: 'Untappd healthcheck',
5
7
  description: 'Verify Untappd connectivity and that credentials are configured and can log in. Performs a lightweight ' +
6
- 'authenticated request (your recent feed) and reports whether it succeeded. Read-only.',
8
+ 'authenticated request (your recent feed) and reports whether it succeeded, plus the running server version ' +
9
+ 'and the exact set of tools this build exposes (count, a stable hash, and their names) so you can confirm ' +
10
+ 'which build is live. Read-only.',
7
11
  annotations: toolAnnotations({ title: 'Untappd healthcheck', readOnly: true, idempotent: true, openWorld: true }),
8
12
  inputSchema: {},
9
13
  }, async () => {
14
+ // Always report build/tool diagnostics, even when credentials are absent,
15
+ // so the live build and its toolset can be confirmed from the client.
16
+ const build = { server_version: VERSION, ...toolInventory(server) };
10
17
  if (!client.configured) {
11
18
  return textResult({
12
19
  ok: false,
13
20
  configured: false,
21
+ ...build,
14
22
  note: 'Untappd credentials are not set. Configure UNTAPPD_USERNAME, UNTAPPD_PASSWORD, UNTAPPD_CLIENT_ID, and UNTAPPD_CLIENT_SECRET.',
15
23
  });
16
24
  }
@@ -20,6 +28,7 @@ export function registerUtilityTools(server, client) {
20
28
  configured: true,
21
29
  account: client.loginName,
22
30
  feed_reachable: feed?.checkins !== undefined,
31
+ ...build,
23
32
  note: 'Logged in to Untappd and fetched the friend feed successfully.',
24
33
  });
25
34
  });
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 = '1.3.1'; // x-release-please-version
6
+ export const VERSION = '1.4.0'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "mcpName": "io.github.chrischall/untappd-mcp",
5
5
  "description": "Untappd MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
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": "1.3.1",
9
+ "version": "1.4.0",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "1.3.1",
14
+ "version": "1.4.0",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },