untappd-mcp 1.3.0 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +54 -0
- package/SKILL.md +29 -0
- package/dist/bundle.js +712 -48
- package/dist/cache/db.js +65 -0
- package/dist/cache/store.js +333 -0
- package/dist/cache/sync.js +202 -0
- package/dist/index.js +13 -0
- package/dist/tools/cache.js +168 -0
- package/dist/tools/diagnostics.js +33 -0
- package/dist/tools/utilities.js +10 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { textResult, toolAnnotations, createHelpfulError } from '@chrischall/mcp-utils';
|
|
3
|
+
import { syncCheckins } from '../cache/sync.js';
|
|
4
|
+
const UsernameArg = z
|
|
5
|
+
.string()
|
|
6
|
+
.min(1)
|
|
7
|
+
.optional()
|
|
8
|
+
.describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
|
|
9
|
+
/** Resolve the target username (raw, un-encoded) — explicit arg else configured login. */
|
|
10
|
+
function resolveUser(username, loginName) {
|
|
11
|
+
const u = username ?? loginName;
|
|
12
|
+
if (!u) {
|
|
13
|
+
throw createHelpfulError('No username given and no configured account to fall back to.', {
|
|
14
|
+
hint: 'Pass `username`, or set UNTAPPD_USERNAME so cache tools default to your own account.',
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
return u;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Freshness block attached to every cache READ result so the model can caveat
|
|
21
|
+
* incomplete data: whether the cache has been synced, how complete the backfill
|
|
22
|
+
* is, and a plain-language warning when a "not had" answer might be a false
|
|
23
|
+
* negative.
|
|
24
|
+
*/
|
|
25
|
+
async function freshness(cache, username) {
|
|
26
|
+
const state = await cache.getState(username);
|
|
27
|
+
const cached = await cache.cachedCount(username);
|
|
28
|
+
const backfillComplete = state?.backfill_complete ?? false;
|
|
29
|
+
const percent = state?.total_checkins && state.total_checkins > 0
|
|
30
|
+
? Math.min(100, Math.round((cached / state.total_checkins) * 100))
|
|
31
|
+
: backfillComplete
|
|
32
|
+
? 100
|
|
33
|
+
: null;
|
|
34
|
+
const catchupInProgress = (state?.catchup_max_id ?? null) !== null;
|
|
35
|
+
let caveat;
|
|
36
|
+
if (!state || state.last_synced_at === null) {
|
|
37
|
+
caveat = 'This user has never been synced — the cache is EMPTY. Run untappd_sync_checkins first; results here are not authoritative.';
|
|
38
|
+
}
|
|
39
|
+
else if (catchupInProgress) {
|
|
40
|
+
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.';
|
|
41
|
+
}
|
|
42
|
+
else if (!backfillComplete) {
|
|
43
|
+
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.`;
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
last_synced_at: state?.last_synced_at ?? null,
|
|
47
|
+
backfill_complete: backfillComplete,
|
|
48
|
+
catchup_in_progress: catchupInProgress,
|
|
49
|
+
backfill_percent: percent,
|
|
50
|
+
cached_checkins: cached,
|
|
51
|
+
distinct_beers: await cache.distinctBeerCount(username),
|
|
52
|
+
...(caveat ? { caveat } : {}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
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.
|
|
59
|
+
*/
|
|
60
|
+
export function registerCacheTools(server, client, cacheProvider) {
|
|
61
|
+
server.registerTool('untappd_sync_checkins', {
|
|
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. " +
|
|
64
|
+
'Incremental (stops once it reaches already-cached check-ins) and resumable: if the backfill is not yet ' +
|
|
65
|
+
'complete it pages backwards up to max_pages per call and persists progress after every page, so run it ' +
|
|
66
|
+
'repeatedly until another_run_needed is false. Syncing another user requires their account be public or a ' +
|
|
67
|
+
'friend. Omit username for your own account.',
|
|
68
|
+
annotations: toolAnnotations({ title: 'Sync Untappd check-ins into the cache', readOnly: false, idempotent: false, openWorld: true }),
|
|
69
|
+
inputSchema: {
|
|
70
|
+
username: UsernameArg,
|
|
71
|
+
max_pages: z
|
|
72
|
+
.number()
|
|
73
|
+
.int()
|
|
74
|
+
.min(1)
|
|
75
|
+
.max(50)
|
|
76
|
+
.optional()
|
|
77
|
+
.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.'),
|
|
78
|
+
},
|
|
79
|
+
}, async ({ username, max_pages }) => {
|
|
80
|
+
const user = resolveUser(username, client.loginName);
|
|
81
|
+
const summary = await syncCheckins(client, cacheProvider(), user, max_pages ?? 10);
|
|
82
|
+
return textResult(summary);
|
|
83
|
+
});
|
|
84
|
+
server.registerTool('untappd_cache_has_had', {
|
|
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 ' +
|
|
87
|
+
'bid, or by a case-insensitive substring of the beer name. Returns whether they had it, how many times, the ' +
|
|
88
|
+
"best rating, the last date, and the matching check-ins. Always reports cache freshness so you can caveat " +
|
|
89
|
+
'incomplete data. Requires bid or beer_name. Run untappd_sync_checkins first.',
|
|
90
|
+
annotations: toolAnnotations({ title: 'Check if a user has had a beer (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
|
|
91
|
+
inputSchema: {
|
|
92
|
+
username: UsernameArg,
|
|
93
|
+
bid: z.number().int().positive().optional().describe('Exact Untappd beer id to look for'),
|
|
94
|
+
beer_name: z.string().min(1).optional().describe('Case-insensitive substring match on the beer name'),
|
|
95
|
+
},
|
|
96
|
+
}, async ({ username, bid, beer_name }) => {
|
|
97
|
+
if (bid === undefined && beer_name === undefined) {
|
|
98
|
+
throw createHelpfulError('Provide either bid or beer_name.', { hint: 'bid is exact; beer_name is a case-insensitive substring match.' });
|
|
99
|
+
}
|
|
100
|
+
const user = resolveUser(username, client.loginName);
|
|
101
|
+
const cache = cacheProvider();
|
|
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) });
|
|
104
|
+
});
|
|
105
|
+
server.registerTool('untappd_cache_has_had_many', {
|
|
106
|
+
title: 'Batch-check many beers against the cache',
|
|
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 ' +
|
|
108
|
+
'per bid (with count and last date when had). Ideal for checking a whole venue menu at once. Run ' +
|
|
109
|
+
'untappd_sync_checkins first; the freshness block flags if the cache is incomplete.',
|
|
110
|
+
annotations: toolAnnotations({ title: 'Batch-check many beers against the cache', readOnly: true, idempotent: true, openWorld: false }),
|
|
111
|
+
inputSchema: {
|
|
112
|
+
username: UsernameArg,
|
|
113
|
+
bids: z.array(z.number().int().positive()).min(1).max(500).describe('Beer ids to check (1–500)'),
|
|
114
|
+
},
|
|
115
|
+
}, async ({ username, bids }) => {
|
|
116
|
+
const user = resolveUser(username, client.loginName);
|
|
117
|
+
const cache = cacheProvider();
|
|
118
|
+
// De-dupe the input so a repeated bid is looked up once.
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
const results = [];
|
|
121
|
+
let hadCount = 0;
|
|
122
|
+
for (const bid of bids) {
|
|
123
|
+
if (seen.has(bid))
|
|
124
|
+
continue;
|
|
125
|
+
seen.add(bid);
|
|
126
|
+
const r = await cache.hasHad(user, { bid });
|
|
127
|
+
if (r.had)
|
|
128
|
+
hadCount++;
|
|
129
|
+
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating });
|
|
130
|
+
}
|
|
131
|
+
return textResult({
|
|
132
|
+
username: user,
|
|
133
|
+
checked: seen.size,
|
|
134
|
+
had: hadCount,
|
|
135
|
+
not_had: seen.size - hadCount,
|
|
136
|
+
results,
|
|
137
|
+
freshness: await freshness(cache, user),
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
server.registerTool('untappd_cache_query', {
|
|
141
|
+
title: 'Query cached check-ins with filters',
|
|
142
|
+
description: "Query a user's cached check-ins by brewery, style, minimum rating, venue, and/or date range, with sorting " +
|
|
143
|
+
'and a limit — from the cache only, NO API call. Returns the matching check-ins plus cache freshness. ' +
|
|
144
|
+
'Run untappd_sync_checkins first.',
|
|
145
|
+
annotations: toolAnnotations({ title: 'Query cached check-ins with filters', readOnly: true, idempotent: true, openWorld: false }),
|
|
146
|
+
inputSchema: {
|
|
147
|
+
username: UsernameArg,
|
|
148
|
+
brewery_id: z.number().int().positive().optional().describe('Exact brewery id'),
|
|
149
|
+
brewery: z.string().min(1).optional().describe('Case-insensitive substring match on brewery name'),
|
|
150
|
+
style: z.string().min(1).optional().describe('Case-insensitive substring match on beer style (e.g. "IPA")'),
|
|
151
|
+
min_rating: z.number().min(0).max(5).optional().describe('Only check-ins you rated at least this (0–5)'),
|
|
152
|
+
venue: z.string().min(1).optional().describe('Case-insensitive substring match on venue name'),
|
|
153
|
+
venue_id: z.number().int().positive().optional().describe('Exact venue id'),
|
|
154
|
+
date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Only check-ins on/after this date (YYYY-MM-DD, UTC)'),
|
|
155
|
+
date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Only check-ins on/before this date (YYYY-MM-DD, UTC)'),
|
|
156
|
+
sort: z
|
|
157
|
+
.enum(['recent', 'oldest', 'highest_rated', 'lowest_rated'])
|
|
158
|
+
.optional()
|
|
159
|
+
.describe('Sort order (default recent first)'),
|
|
160
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max rows (1–200, default 25)'),
|
|
161
|
+
},
|
|
162
|
+
}, async ({ username, ...filters }) => {
|
|
163
|
+
const user = resolveUser(username, client.loginName);
|
|
164
|
+
const cache = cacheProvider();
|
|
165
|
+
const rows = await cache.query(user, filters);
|
|
166
|
+
return textResult({ username: user, count: rows.length, filters, results: rows, freshness: await freshness(cache, user) });
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/tools/utilities.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
"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.
|
|
9
|
+
"version": "1.4.0",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "untappd-mcp",
|
|
14
|
-
"version": "1.
|
|
14
|
+
"version": "1.4.0",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
},
|