untappd-mcp 1.2.0 → 1.3.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.
@@ -0,0 +1,174 @@
1
+ import { z } from 'zod';
2
+ import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
3
+ import { CheckinCache, defaultCachePath } from '../cache/db.js';
4
+ import { syncCheckins } from '../cache/sync.js';
5
+ const UsernameArg = z
6
+ .string()
7
+ .min(1)
8
+ .optional()
9
+ .describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
10
+ /** Resolve the target username (raw, un-encoded) — explicit arg else configured login. */
11
+ function resolveUser(username, loginName) {
12
+ const u = username ?? loginName;
13
+ if (!u) {
14
+ throw createHelpfulError('No username given and no configured account to fall back to.', {
15
+ hint: 'Pass `username`, or set UNTAPPD_USERNAME so cache tools default to your own account.',
16
+ });
17
+ }
18
+ return u;
19
+ }
20
+ /**
21
+ * Freshness block attached to every cache READ result so the model can caveat
22
+ * incomplete data: whether the cache has been synced, how complete the backfill
23
+ * is, and a plain-language warning when a "not had" answer might be a false
24
+ * negative.
25
+ */
26
+ function freshness(cache, username) {
27
+ const state = cache.getState(username);
28
+ const cached = cache.cachedCount(username);
29
+ const backfillComplete = state?.backfill_complete ?? false;
30
+ const percent = state?.total_checkins && state.total_checkins > 0
31
+ ? Math.min(100, Math.round((cached / state.total_checkins) * 100))
32
+ : backfillComplete
33
+ ? 100
34
+ : null;
35
+ const catchupInProgress = (state?.catchup_max_id ?? null) !== null;
36
+ let caveat;
37
+ if (!state || state.last_synced_at === null) {
38
+ caveat = 'This user has never been synced — the cache is EMPTY. Run untappd_sync_checkins first; results here are not authoritative.';
39
+ }
40
+ else if (catchupInProgress) {
41
+ caveat = 'A batch of the newest check-ins is still being caught up (last sync ran out of pages). Recent check-ins may be missing — run untappd_sync_checkins again to finish.';
42
+ }
43
+ else if (!backfillComplete) {
44
+ caveat = `Backfill is incomplete${percent !== null ? ` (~${percent}% of history cached)` : ''}. A "not found" result may be a false negative for older check-ins — run untappd_sync_checkins again to keep backfilling.`;
45
+ }
46
+ return {
47
+ last_synced_at: state?.last_synced_at ?? null,
48
+ backfill_complete: backfillComplete,
49
+ catchup_in_progress: catchupInProgress,
50
+ backfill_percent: percent,
51
+ cached_checkins: cached,
52
+ distinct_beers: cache.distinctBeerCount(username),
53
+ ...(caveat ? { caveat } : {}),
54
+ };
55
+ }
56
+ /**
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).
60
+ */
61
+ export function registerCacheTools(server, client, cacheProvider = defaultCacheProvider()) {
62
+ 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. " +
65
+ 'Incremental (stops once it reaches already-cached check-ins) and resumable: if the backfill is not yet ' +
66
+ '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 ' +
68
+ '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 }),
70
+ inputSchema: {
71
+ username: UsernameArg,
72
+ max_pages: z
73
+ .number()
74
+ .int()
75
+ .min(1)
76
+ .max(50)
77
+ .optional()
78
+ .describe('Total pages (50 check-ins each) to fetch this run, incremental + backfill (default 10). Keep modest to respect the ~100 calls/hour rate limit.'),
79
+ },
80
+ }, async ({ username, max_pages }) => {
81
+ const user = resolveUser(username, client.loginName);
82
+ const summary = await syncCheckins(client, cacheProvider(), user, max_pages ?? 10);
83
+ return textResult(summary);
84
+ });
85
+ 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 ' +
88
+ 'bid, or by a case-insensitive substring of the beer name. Returns whether they had it, how many times, the ' +
89
+ "best rating, the last date, and the matching check-ins. Always reports cache freshness so you can caveat " +
90
+ '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 }),
92
+ inputSchema: {
93
+ username: UsernameArg,
94
+ bid: z.number().int().positive().optional().describe('Exact Untappd beer id to look for'),
95
+ beer_name: z.string().min(1).optional().describe('Case-insensitive substring match on the beer name'),
96
+ },
97
+ }, async ({ username, bid, beer_name }) => {
98
+ if (bid === undefined && beer_name === undefined) {
99
+ throw createHelpfulError('Provide either bid or beer_name.', { hint: 'bid is exact; beer_name is a case-insensitive substring match.' });
100
+ }
101
+ const user = resolveUser(username, client.loginName);
102
+ 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) });
105
+ });
106
+ server.registerTool('untappd_cache_has_had_many', {
107
+ title: 'Batch-check many beers against the local cache',
108
+ 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
+ 'per bid (with count and last date when had). Ideal for checking a whole venue menu at once. Run ' +
110
+ '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 }),
112
+ inputSchema: {
113
+ username: UsernameArg,
114
+ bids: z.array(z.number().int().positive()).min(1).max(500).describe('Beer ids to check (1–500)'),
115
+ },
116
+ }, async ({ username, bids }) => {
117
+ const user = resolveUser(username, client.loginName);
118
+ const cache = cacheProvider();
119
+ // De-dupe the input so a repeated bid is looked up once.
120
+ const seen = new Set();
121
+ const results = [];
122
+ let hadCount = 0;
123
+ for (const bid of bids) {
124
+ if (seen.has(bid))
125
+ continue;
126
+ seen.add(bid);
127
+ const r = cache.hasHad(user, { bid });
128
+ if (r.had)
129
+ hadCount++;
130
+ results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating });
131
+ }
132
+ return textResult({
133
+ username: user,
134
+ checked: seen.size,
135
+ had: hadCount,
136
+ not_had: seen.size - hadCount,
137
+ results,
138
+ freshness: freshness(cache, user),
139
+ });
140
+ });
141
+ server.registerTool('untappd_cache_query', {
142
+ title: 'Query cached check-ins with filters',
143
+ 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. ' +
145
+ 'Run untappd_sync_checkins first.',
146
+ annotations: toolAnnotations({ title: 'Query cached check-ins with filters', readOnly: true, idempotent: true, openWorld: false }),
147
+ inputSchema: {
148
+ username: UsernameArg,
149
+ brewery_id: z.number().int().positive().optional().describe('Exact brewery id'),
150
+ brewery: z.string().min(1).optional().describe('Case-insensitive substring match on brewery name'),
151
+ style: z.string().min(1).optional().describe('Case-insensitive substring match on beer style (e.g. "IPA")'),
152
+ min_rating: z.number().min(0).max(5).optional().describe('Only check-ins you rated at least this (0–5)'),
153
+ venue: z.string().min(1).optional().describe('Case-insensitive substring match on venue name'),
154
+ venue_id: z.number().int().positive().optional().describe('Exact venue id'),
155
+ date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Only check-ins on/after this date (YYYY-MM-DD, UTC)'),
156
+ date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Only check-ins on/before this date (YYYY-MM-DD, UTC)'),
157
+ sort: z
158
+ .enum(['recent', 'oldest', 'highest_rated', 'lowest_rated'])
159
+ .optional()
160
+ .describe('Sort order (default recent first)'),
161
+ limit: z.number().int().min(1).max(200).optional().describe('Max rows (1–200, default 25)'),
162
+ },
163
+ }, async ({ username, ...filters }) => {
164
+ const user = resolveUser(username, client.loginName);
165
+ 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) });
168
+ });
169
+ }
170
+ /** Lazily open (once) the env-configured on-disk cache. */
171
+ function defaultCacheProvider() {
172
+ let cache;
173
+ return () => (cache ??= CheckinCache.open(defaultCachePath()));
174
+ }
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.2.0'; // x-release-please-version
6
+ export const VERSION = '1.3.1'; // x-release-please-version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "untappd-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "mcpName": "io.github.chrischall/untappd-mcp",
5
5
  "description": "Untappd MCP server for Claude — developed and maintained by AI (Claude Code)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
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.2.0",
9
+ "version": "1.3.1",
10
10
  "packages": [
11
11
  {
12
12
  "registryType": "npm",
13
13
  "identifier": "untappd-mcp",
14
- "version": "1.2.0",
14
+ "version": "1.3.1",
15
15
  "transport": {
16
16
  "type": "stdio"
17
17
  },