snowbll-mcp 0.1.0 → 0.2.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/README.md +61 -183
- package/dist/api.js +138 -0
- package/dist/bin/snowbll-mcp.js +30 -0
- package/dist/bin/snowbll.js +180 -0
- package/dist/config.js +51 -0
- package/dist/server.js +135 -0
- package/dist/setup.js +58 -0
- package/package.json +16 -13
- package/dist/client.js +0 -107
- package/dist/client.js.map +0 -1
- package/dist/communityClient.js +0 -96
- package/dist/communityClient.js.map +0 -1
- package/dist/crossCheckClient.js +0 -83
- package/dist/crossCheckClient.js.map +0 -1
- package/dist/http.js +0 -70
- package/dist/http.js.map +0 -1
- package/dist/index.js +0 -391
- package/dist/index.js.map +0 -1
- package/dist/mockData.js +0 -471
- package/dist/mockData.js.map +0 -1
- package/dist/prompts.js +0 -48
- package/dist/prompts.js.map +0 -1
- package/dist/resources.js +0 -54
- package/dist/resources.js.map +0 -1
- package/dist/schemas.js +0 -174
- package/dist/schemas.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,88 +1,43 @@
|
|
|
1
|
-
# Snowbll MCP
|
|
1
|
+
# Snowbll MCP + CLI
|
|
2
2
|
|
|
3
|
-
**Snowbll
|
|
3
|
+
**Search the Snowbll game catalog, read your gaming persona, and follow Forge campaigns — from any MCP-capable AI agent or your terminal.**
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Everything in this package talks to the **live Snowbll API** (`https://www.snowbll.com`). There is no mock data.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
that exposes Snowbll's gaming-behaviour intelligence as tools any AI agent can
|
|
9
|
-
call — Claude, Cursor, Windsurf, OpenAI Agents SDK, LangChain, CrewAI, AutoGen,
|
|
10
|
-
the Vercel AI SDK, and custom TypeScript/Python agents.
|
|
7
|
+
Status: the search and Forge tools hit live public endpoints; the MCP servers, API keys, and the keyed v1 endpoints are in **developer preview** — names, fields, auth, and quotas may change before launch.
|
|
11
8
|
|
|
12
|
-
|
|
13
|
-
gaming persona, session patterns, completion behavior, purchase behavior,
|
|
14
|
-
genre mismatches, game-fit prediction, and likely next best game.
|
|
9
|
+
Two binaries ship in one package:
|
|
15
10
|
|
|
16
|
-
|
|
11
|
+
| Binary | What it is |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| `snowbll-mcp` | [Model Context Protocol](https://modelcontextprotocol.io) server over stdio — launched by agents (Claude, Cursor, Hermes, …) |
|
|
14
|
+
| `snowbll` | CLI for humans — search, persona, Forge, key management |
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
AI Agent
|
|
20
|
-
→ Snowbll MCP Server (stdio)
|
|
21
|
-
→ Snowbll API or mock data
|
|
22
|
-
→ Gaming Behaviour Intelligence
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Snowbll MCP is **not** the agent. It is a **tool provider**. The agent decides
|
|
26
|
-
when to call a tool; Snowbll MCP returns structured behaviour intelligence as
|
|
27
|
-
JSON. The first version ships with realistic **mock data** so you can build and
|
|
28
|
-
test integrations immediately, with no backend required.
|
|
16
|
+
There is also a **hosted remote MCP server (developer preview)** at `https://www.snowbll.com/api/mcp` (Streamable HTTP) for clients that connect to URLs instead of launching processes — ChatGPT connectors, Claude custom connectors, Hermes remote MCP.
|
|
29
17
|
|
|
30
|
-
##
|
|
18
|
+
## Tools
|
|
31
19
|
|
|
32
|
-
|
|
33
|
-
npm install
|
|
34
|
-
npm run build
|
|
35
|
-
```
|
|
20
|
+
The MCP surface is deliberately read-only: tools recommend, explain, and retrieve. Nothing here buys games, backs campaigns, or moves money.
|
|
36
21
|
|
|
37
|
-
|
|
22
|
+
| Tool | What it does | Needs API key? |
|
|
23
|
+
| --- | --- | --- |
|
|
24
|
+
| `search_games` | Natural-language catalog search with ranked candidates + reasons | No |
|
|
25
|
+
| `get_game` | Full metadata for one game, incl. human-verification status | No |
|
|
26
|
+
| `get_persona` | The key owner's gaming persona (consent-scoped) | Yes — data visible from the **Observer** rank up; free accounts get a locked marker |
|
|
27
|
+
| `list_forge_campaigns` | Forge campaigns with live funding totals | No |
|
|
28
|
+
| `get_forge_campaign` | One campaign: tiers, stretch goals, progress | No |
|
|
38
29
|
|
|
39
|
-
|
|
30
|
+
Honesty model: `verification` is `verified` only after third-party human testing; quality fields (`graphicsFidelity`, `economyComplexity`) are `null` until a human rated them. AI recommends, humans judge.
|
|
40
31
|
|
|
41
|
-
|
|
42
|
-
npm run dev
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
You should see, on stderr:
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
[snowbll-mcp] running on stdio — data mode: mock
|
|
49
|
-
```
|
|
32
|
+
## Quick start (agents)
|
|
50
33
|
|
|
51
|
-
|
|
52
|
-
(Claude Desktop, Cursor, etc.) rather than used interactively. To smoke-test the
|
|
53
|
-
built binary directly:
|
|
34
|
+
### Claude Code
|
|
54
35
|
|
|
55
36
|
```bash
|
|
56
|
-
|
|
57
|
-
node dist/index.js
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Connect it to Claude / Cursor (local development)
|
|
61
|
-
|
|
62
|
-
After `npm run build`, point your MCP client at the built file. Use an absolute
|
|
63
|
-
path to `dist/index.js`.
|
|
64
|
-
|
|
65
|
-
```json
|
|
66
|
-
{
|
|
67
|
-
"mcpServers": {
|
|
68
|
-
"snowbll": {
|
|
69
|
-
"command": "node",
|
|
70
|
-
"args": ["C:/PATH/TO/snowbll-mcp/dist/index.js"],
|
|
71
|
-
"env": {
|
|
72
|
-
"SNOWBLL_USE_MOCK": "true"
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
}
|
|
37
|
+
claude mcp add snowbll -- npx -y snowbll-mcp
|
|
77
38
|
```
|
|
78
39
|
|
|
79
|
-
|
|
80
|
-
(Settings → Developer → Edit Config), then restart Claude Desktop.
|
|
81
|
-
- **Cursor / Windsurf:** add it to the editor's MCP settings (`mcp.json`).
|
|
82
|
-
|
|
83
|
-
### Future: install from npm
|
|
84
|
-
|
|
85
|
-
Once Snowbll MCP is published and you have an API key, no local build is needed:
|
|
40
|
+
### Claude Desktop / Cursor / Windsurf / Hermes (stdio)
|
|
86
41
|
|
|
87
42
|
```json
|
|
88
43
|
{
|
|
@@ -90,144 +45,67 @@ Once Snowbll MCP is published and you have an API key, no local build is needed:
|
|
|
90
45
|
"snowbll": {
|
|
91
46
|
"command": "npx",
|
|
92
47
|
"args": ["-y", "snowbll-mcp"],
|
|
93
|
-
"env": {
|
|
94
|
-
"SNOWBLL_API_KEY": "snb_YOUR_API_KEY"
|
|
95
|
-
}
|
|
48
|
+
"env": { "SNOWBLL_API_KEY": "sb_YOUR_API_KEY" }
|
|
96
49
|
}
|
|
97
50
|
}
|
|
98
51
|
}
|
|
99
52
|
```
|
|
100
53
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
| Variable | Default | Description |
|
|
104
|
-
| ------------------ | ----------------------------- | --------------------------------------------------------------------------- |
|
|
105
|
-
| `SNOWBLL_USE_MOCK` | `true` | Use local mock intelligence. Set to `false` to call the real Snowbll API. |
|
|
106
|
-
| `SNOWBLL_API_URL` | `https://api.snowbll.com/v1` | Base URL of the Snowbll API (live mode only). |
|
|
107
|
-
| `SNOWBLL_API_KEY` | _(none)_ | Required when `SNOWBLL_USE_MOCK=false`. Format `snb_...`. |
|
|
108
|
-
| `SNOWBLL_API_TIMEOUT_MS` | `10000` | Per-request timeout (live mode). Hung requests abort and surface an error. |
|
|
109
|
-
| `SNOWBLL_API_RETRIES` | `2` | Retries after the first attempt for transient errors (timeout, 429, 5xx). |
|
|
110
|
-
|
|
111
|
-
Mock mode is the default: the server runs with zero configuration and never
|
|
112
|
-
needs an API key. In live mode (`SNOWBLL_USE_MOCK=false`), a missing
|
|
113
|
-
`SNOWBLL_API_KEY` produces a clear, actionable error. Live requests time out and
|
|
114
|
-
retry transient failures (429/5xx) with exponential backoff; non-retryable 4xx
|
|
115
|
-
errors fail fast.
|
|
116
|
-
|
|
117
|
-
## Tests
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
npm test
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
Runs the suite via Node's built-in test runner (`tsx test/all.test.ts`): mock
|
|
124
|
-
intelligence logic, output-schema round-trips, client mock/live routing, and the
|
|
125
|
-
HTTP layer's timeout/retry behaviour (against a local server). No network.
|
|
54
|
+
Omit the `env` block to run keyless (search, game lookups, and Forge tools still work — only `get_persona` needs a key).
|
|
126
55
|
|
|
127
|
-
|
|
56
|
+
### ChatGPT (remote connector — no install)
|
|
128
57
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
3. `get_persona_cards` / `get_playstyle_dna` — evidence-backed detail.
|
|
58
|
+
1. Settings → Connectors (requires a plan with connectors/developer mode).
|
|
59
|
+
2. Add a custom connector with URL `https://www.snowbll.com/api/mcp`, authentication **None**.
|
|
60
|
+
3. Ask: *"Use Snowbll to find an economy sim like Lemonade Tycoon with a deep economy."*
|
|
133
61
|
|
|
134
|
-
|
|
135
|
-
- `explain_taste_pattern` with a question like _"Why do I abandon open-world RPGs?"_
|
|
136
|
-
- `detect_genre_mismatch` to surface where stated taste diverges from observed behaviour.
|
|
62
|
+
The remote server also implements OpenAI's `search` / `fetch` connector contract, so it works in ChatGPT deep research too.
|
|
137
63
|
|
|
138
|
-
|
|
139
|
-
- `predict_game_fit` for one title.
|
|
140
|
-
- `compare_games_for_player` to rank 2–5 candidates.
|
|
64
|
+
### Claude (claude.ai custom connector / remote)
|
|
141
65
|
|
|
142
|
-
|
|
143
|
-
- `recommend_games` with an objective (`likely_to_finish`, `hidden_gems`, `deep_systems`, `comfort_games`, `short_sessions`, `high_progression`).
|
|
66
|
+
Add a custom connector with URL `https://www.snowbll.com/api/mcp`. Public tools need no auth.
|
|
144
67
|
|
|
145
|
-
|
|
68
|
+
### Verify any setup
|
|
146
69
|
|
|
147
|
-
|
|
148
|
-
| ---------------------------- | ------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
149
|
-
| `get_player_overview` | _none_ | Totals, persona match, current objective, identity summary. |
|
|
150
|
-
| `get_playstyle_dna` | _none_ | Playstyle traits with scores and tiers, plus the player's unique edge. |
|
|
151
|
-
| `get_persona_cards` | _none_ | Persona cards with rarity, level, progress, and evidence. |
|
|
152
|
-
| `get_activity_patterns` | _none_ | Active hours, session-length distribution, platform split, play rhythm. |
|
|
153
|
-
| `get_completion_behavior` | _none_ | Completion rate, strong/weak categories, drop-off signals. |
|
|
154
|
-
| `get_purchase_behavior` | `includeSensitiveSignals?` | Purchase aggregates and buy-vs-play mismatch. |
|
|
155
|
-
| `detect_genre_mismatch` | `includePurchaseSignals?`, `includeCompletionSignals?` | Where claimed/purchased taste diverges from observed behaviour. |
|
|
156
|
-
| `analyze_player_behaviour` | `depth?`, `includeEvidence?` | Full behavioural analysis with evidence and interpretation. |
|
|
157
|
-
| `explain_taste_pattern` | `topic` | Explanation, supporting signals, and actionable advice for a topic. |
|
|
158
|
-
| `predict_game_fit` | `gameTitle`, `platform?` | Fit score, finish likelihood, drop-off risk, and reasoning for one game. |
|
|
159
|
-
| `compare_games_for_player` | `games[2..5]` | Ranked comparison with a best pick. |
|
|
160
|
-
| `recommend_games` | `objective?`, `limit?` | Behaviour-fit game recommendations for a chosen objective. |
|
|
161
|
-
| `crosscheck_recommendations` | `objective?`, `limit?` | Games validated by TWO engines (consensus + referee) with an agreement score. |
|
|
70
|
+
Ask the agent:
|
|
162
71
|
|
|
163
|
-
|
|
72
|
+
> Use search_games to find: "economy sim like Lemonade Tycoon with a deep economy"
|
|
164
73
|
|
|
165
|
-
|
|
74
|
+
A working setup returns a short candidate list, each with `reasons`.
|
|
166
75
|
|
|
167
|
-
|
|
168
|
-
| -------------------------------- | ------------------------------------------------------- |
|
|
169
|
-
| `snowbll://player/profile` | Combined overview + playstyle DNA + personas + activity |
|
|
170
|
-
| `snowbll://player/overview` | High-level identity and current objective |
|
|
171
|
-
| `snowbll://player/playstyle-dna` | Playstyle traits with scores and tiers |
|
|
172
|
-
| `snowbll://player/personas` | Persona cards |
|
|
76
|
+
## Quick start (humans)
|
|
173
77
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
> **Structured output:** the behaviour-intelligence tools return validated
|
|
186
|
-
> `structuredContent` (machine-readable JSON) alongside the text result.
|
|
187
|
-
|
|
188
|
-
## Privacy
|
|
189
|
-
|
|
190
|
-
By default, Snowbll MCP tools return **aggregate behaviour intelligence** — not
|
|
191
|
-
raw session logs, raw purchase data, private posts, or friend data.
|
|
192
|
-
`get_purchase_behavior` only exposes raw, purchase-level detail when explicitly
|
|
193
|
-
asked via `includeSensitiveSignals: true`; otherwise it returns aggregates only.
|
|
78
|
+
```bash
|
|
79
|
+
npm install -g snowbll-mcp
|
|
80
|
+
|
|
81
|
+
snowbll search "cozy farming game with deep crafting"
|
|
82
|
+
snowbll forge # campaigns on The Forge
|
|
83
|
+
snowbll login # mint + store an API key (email+password account)
|
|
84
|
+
snowbll whoami
|
|
85
|
+
snowbll persona # your gaming persona
|
|
86
|
+
snowbll setup # wizard + ready-to-paste agent configs
|
|
87
|
+
```
|
|
194
88
|
|
|
195
|
-
##
|
|
89
|
+
## API keys (developer preview)
|
|
196
90
|
|
|
197
|
-
`
|
|
198
|
-
Each tool maps to one `client.call(endpoint, payload, mockFn)`:
|
|
91
|
+
Keys (`sb_…`) belong to Snowbll accounts and are hashed at rest — the plaintext is shown once.
|
|
199
92
|
|
|
200
|
-
-
|
|
201
|
-
-
|
|
202
|
-
|
|
93
|
+
- `snowbll login` — sign in with an email+password Snowbll account; mints and stores a key in `~/.snowbll/config.json`.
|
|
94
|
+
- Accounts created with Google/Discord have no password: ask the Snowbll team for a preview key, then `snowbll setup` to paste it.
|
|
95
|
+
- `snowbll keys list|create|revoke` manages keys; `SNOWBLL_API_KEY` env always wins over the stored key.
|
|
203
96
|
|
|
204
|
-
|
|
205
|
-
shapes in `src/mockData.ts` (exported as TypeScript interfaces) and set
|
|
206
|
-
`SNOWBLL_USE_MOCK=false`. No changes to `index.ts` are required.
|
|
97
|
+
Keep keys out of browser code, public repos, and shared transcripts.
|
|
207
98
|
|
|
208
|
-
##
|
|
99
|
+
## Configuration
|
|
209
100
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
README.md
|
|
215
|
-
src/
|
|
216
|
-
index.ts # MCP server: registers tools, resources & prompts over stdio
|
|
217
|
-
client.ts # game data layer: mock-by-default, future real-API client
|
|
218
|
-
communityClient.ts # community/forum data layer (Community Recommendation Engine)
|
|
219
|
-
mockData.ts # typed mock dataset + helpers (game response shapes)
|
|
220
|
-
schemas.ts # zod output schemas (structured output) + type-sync guard
|
|
221
|
-
resources.ts # MCP resources (player snapshots)
|
|
222
|
-
prompts.ts # MCP prompts (slash-command templates)
|
|
223
|
-
http.ts # HTTP helper: timeout, retry, typed errors
|
|
224
|
-
test/ # node:test suite (npm test)
|
|
225
|
-
```
|
|
101
|
+
| Env var | Default | Purpose |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `SNOWBLL_API_KEY` | – | Developer-preview API key |
|
|
104
|
+
| `SNOWBLL_API_BASE` | `https://www.snowbll.com` | API origin (point at a local dev server while developing) |
|
|
226
105
|
|
|
227
|
-
##
|
|
106
|
+
## REST instead?
|
|
228
107
|
|
|
229
|
-
|
|
230
|
-
stdio MCP transport.
|
|
108
|
+
Everything the tools do is plain HTTP — `POST https://www.snowbll.com/api/search` is live with no key. Full docs: <https://www.snowbll.com/docs>.
|
|
231
109
|
|
|
232
110
|
## License
|
|
233
111
|
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { getApiBase, getApiKey, getSupabaseAnonKey, getSupabaseUrl } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* Thin client for the live Snowbll API.
|
|
4
|
+
*
|
|
5
|
+
* Two surfaces, as documented at https://www.snowbll.com/docs:
|
|
6
|
+
* - public endpoints (no key): POST /api/search, GET /api/forge/campaigns[/:id]
|
|
7
|
+
* - keyed v1 (developer preview): /api/v1/* with `Authorization: Bearer sb_...`
|
|
8
|
+
*
|
|
9
|
+
* Methods prefer the keyed surface when a key is configured and fall back to
|
|
10
|
+
* the public one when the data is public anyway, so search and Forge browsing
|
|
11
|
+
* work with zero setup.
|
|
12
|
+
*/
|
|
13
|
+
export class SnowbllApiError extends Error {
|
|
14
|
+
status;
|
|
15
|
+
constructor(status, message) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.status = status;
|
|
18
|
+
this.name = "SnowbllApiError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function request(path, options = {}) {
|
|
22
|
+
const { method = "GET", body, key, timeoutMs = 30_000 } = options;
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
25
|
+
let res;
|
|
26
|
+
try {
|
|
27
|
+
res = await fetch(`${getApiBase()}${path}`, {
|
|
28
|
+
method,
|
|
29
|
+
headers: {
|
|
30
|
+
"content-type": "application/json",
|
|
31
|
+
...(key ? { authorization: `Bearer ${key}` } : {}),
|
|
32
|
+
},
|
|
33
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
if (controller.signal.aborted)
|
|
40
|
+
throw new SnowbllApiError(0, `Request timed out after ${timeoutMs}ms.`);
|
|
41
|
+
throw new SnowbllApiError(0, `Network error: ${err instanceof Error ? err.message : String(err)}`);
|
|
42
|
+
}
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
let json = null;
|
|
45
|
+
try {
|
|
46
|
+
json = await res.json();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Non-JSON body — fall through to the status check.
|
|
50
|
+
}
|
|
51
|
+
if (!res.ok) {
|
|
52
|
+
const msg = json && typeof json === "object" && typeof json.error === "string"
|
|
53
|
+
? json.error
|
|
54
|
+
: `HTTP ${res.status}`;
|
|
55
|
+
throw new SnowbllApiError(res.status, msg);
|
|
56
|
+
}
|
|
57
|
+
return json;
|
|
58
|
+
}
|
|
59
|
+
const NEEDS_KEY = "This needs an API key. Run `snowbll login` (email+password account) or `snowbll setup` to paste one — see https://www.snowbll.com/docs.";
|
|
60
|
+
function requireKey() {
|
|
61
|
+
const key = getApiKey();
|
|
62
|
+
if (!key)
|
|
63
|
+
throw new SnowbllApiError(401, NEEDS_KEY);
|
|
64
|
+
return key;
|
|
65
|
+
}
|
|
66
|
+
// ─── Catalog ────────────────────────────────────────────────────────────────
|
|
67
|
+
export async function searchGames(query, limit = 5) {
|
|
68
|
+
const key = getApiKey();
|
|
69
|
+
if (key)
|
|
70
|
+
return request("/api/v1/search", { method: "POST", body: { query, limit }, key });
|
|
71
|
+
// The public search endpoint ignores `limit` (returns up to 5) — trim client-side
|
|
72
|
+
// so keyless callers still get what they asked for.
|
|
73
|
+
const data = (await request("/api/search", { method: "POST", body: { query } }));
|
|
74
|
+
if (Array.isArray(data.results) && limit < data.results.length) {
|
|
75
|
+
return { ...data, results: data.results.slice(0, Math.max(1, Math.floor(limit))) };
|
|
76
|
+
}
|
|
77
|
+
return data;
|
|
78
|
+
}
|
|
79
|
+
export function getGame(gameId) {
|
|
80
|
+
const id = encodeURIComponent(gameId);
|
|
81
|
+
const key = getApiKey();
|
|
82
|
+
// Game detail is public, recommendation-grade data: use the keyed v1 endpoint
|
|
83
|
+
// when a key is set, else the public keyless one — so get_game works with zero
|
|
84
|
+
// setup, matching the hosted MCP server (only get_persona is a paid tool).
|
|
85
|
+
if (key)
|
|
86
|
+
return request(`/api/v1/games/${id}`, { key });
|
|
87
|
+
return request(`/api/games/${id}`);
|
|
88
|
+
}
|
|
89
|
+
export function listGames(limit = 20, offset = 0) {
|
|
90
|
+
return request(`/api/v1/games?limit=${limit}&offset=${offset}`, { key: requireKey() });
|
|
91
|
+
}
|
|
92
|
+
// ─── The Forge ──────────────────────────────────────────────────────────────
|
|
93
|
+
export function listForgeCampaigns(status) {
|
|
94
|
+
const qs = status ? `?status=${encodeURIComponent(status)}` : "";
|
|
95
|
+
const key = getApiKey();
|
|
96
|
+
if (key)
|
|
97
|
+
return request(`/api/v1/forge/campaigns${qs}`, { key });
|
|
98
|
+
return request(`/api/forge/campaigns${qs}`);
|
|
99
|
+
}
|
|
100
|
+
export function getForgeCampaign(campaignId) {
|
|
101
|
+
const id = encodeURIComponent(campaignId);
|
|
102
|
+
const key = getApiKey();
|
|
103
|
+
if (key)
|
|
104
|
+
return request(`/api/v1/forge/campaigns/${id}`, { key });
|
|
105
|
+
return request(`/api/forge/campaigns/${id}`);
|
|
106
|
+
}
|
|
107
|
+
// ─── Persona + account ──────────────────────────────────────────────────────
|
|
108
|
+
export function getPersona(playerId = "me") {
|
|
109
|
+
return request(`/api/v1/personas/${encodeURIComponent(playerId)}`, { key: requireKey() });
|
|
110
|
+
}
|
|
111
|
+
export function whoami() {
|
|
112
|
+
return request("/api/v1/me", { key: requireKey() });
|
|
113
|
+
}
|
|
114
|
+
// ─── Key management (Supabase account auth, not API-key auth) ───────────────
|
|
115
|
+
/** Sign in with a Snowbll email+password account; returns a Supabase access token. */
|
|
116
|
+
export async function supabaseLogin(email, password) {
|
|
117
|
+
const res = await fetch(`${getSupabaseUrl()}/auth/v1/token?grant_type=password`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers: { "content-type": "application/json", apikey: getSupabaseAnonKey() },
|
|
120
|
+
body: JSON.stringify({ email, password }),
|
|
121
|
+
});
|
|
122
|
+
const json = (await res.json().catch(() => ({})));
|
|
123
|
+
if (!res.ok || !json.access_token) {
|
|
124
|
+
throw new SnowbllApiError(res.status, json.error_description ||
|
|
125
|
+
json.msg ||
|
|
126
|
+
"Sign-in failed. Accounts created with Google/Discord have no password — ask the Snowbll team for a preview key instead.");
|
|
127
|
+
}
|
|
128
|
+
return json.access_token;
|
|
129
|
+
}
|
|
130
|
+
export function createKey(accessToken, name) {
|
|
131
|
+
return request("/api/v1/keys", { method: "POST", body: { name }, key: accessToken });
|
|
132
|
+
}
|
|
133
|
+
export function listKeys(accessToken) {
|
|
134
|
+
return request("/api/v1/keys", { key: accessToken });
|
|
135
|
+
}
|
|
136
|
+
export function revokeKey(accessToken, id) {
|
|
137
|
+
return request(`/api/v1/keys/${encodeURIComponent(id)}`, { method: "DELETE", key: accessToken });
|
|
138
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
import { runStdioServer } from "../server.js";
|
|
4
|
+
import { runSetup } from "../setup.js";
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
const { version } = require("../../package.json");
|
|
7
|
+
/**
|
|
8
|
+
* `npx snowbll-mcp` — run the MCP server on stdio (what agents launch)
|
|
9
|
+
* `npx snowbll-mcp setup` — interactive setup wizard
|
|
10
|
+
*/
|
|
11
|
+
const arg = process.argv[2];
|
|
12
|
+
if (arg === "setup") {
|
|
13
|
+
runSetup().catch((err) => {
|
|
14
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
else if (arg === "--version" || arg === "-v") {
|
|
19
|
+
console.log(version);
|
|
20
|
+
}
|
|
21
|
+
else if (arg && arg !== "serve") {
|
|
22
|
+
console.error(`Unknown command '${arg}'. Usage: snowbll-mcp [setup|serve|--version]`);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
runStdioServer(version).catch((err) => {
|
|
27
|
+
console.error(`[snowbll-mcp] fatal: ${err instanceof Error ? err.message : String(err)}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { createKey, getForgeCampaign, getGame, getPersona, listForgeCampaigns, listGames, listKeys, revokeKey, searchGames, SnowbllApiError, supabaseLogin, whoami, } from "../api.js";
|
|
5
|
+
import { clearConfig, getApiBase, writeConfig } from "../config.js";
|
|
6
|
+
import { runStdioServer } from "../server.js";
|
|
7
|
+
import { runSetup } from "../setup.js";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const { version } = require("../../package.json");
|
|
10
|
+
/** The `snowbll` CLI — the live Snowbll API from your terminal. */
|
|
11
|
+
const HELP = `snowbll v${version} — Snowbll from your terminal (live API: ${getApiBase()})
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
snowbll search "<query>" [--limit N] [--json] Natural-language game search
|
|
15
|
+
snowbll game <id> [--json] One game's full metadata
|
|
16
|
+
snowbll games [--limit N] [--offset N] [--json] Plain catalog listing (key)
|
|
17
|
+
snowbll forge [id] [--status live] [--json] Forge campaigns / one campaign
|
|
18
|
+
snowbll persona [--json] Your gaming persona (key)
|
|
19
|
+
snowbll whoami [--json] Who the configured key belongs to
|
|
20
|
+
snowbll login Sign in, mint + store an API key
|
|
21
|
+
snowbll logout Forget the stored key
|
|
22
|
+
snowbll keys list|create [name]|revoke <id> Manage your API keys (sign-in)
|
|
23
|
+
snowbll setup Setup wizard + agent MCP configs
|
|
24
|
+
snowbll mcp Run the MCP server on stdio
|
|
25
|
+
snowbll help | --version
|
|
26
|
+
|
|
27
|
+
Search, game, and forge work without a key; the rest use the developer-preview
|
|
28
|
+
keyed API (run \`snowbll login\` or set SNOWBLL_API_KEY). Docs: https://www.snowbll.com/docs`;
|
|
29
|
+
function parseFlags(args) {
|
|
30
|
+
const flags = { json: false, rest: [] };
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
const a = args[i];
|
|
33
|
+
if (a === "--json")
|
|
34
|
+
flags.json = true;
|
|
35
|
+
else if (a === "--limit")
|
|
36
|
+
flags.limit = Number(args[++i]);
|
|
37
|
+
else if (a === "--offset")
|
|
38
|
+
flags.offset = Number(args[++i]);
|
|
39
|
+
else if (a === "--status")
|
|
40
|
+
flags.status = args[++i];
|
|
41
|
+
else
|
|
42
|
+
flags.rest.push(a);
|
|
43
|
+
}
|
|
44
|
+
return flags;
|
|
45
|
+
}
|
|
46
|
+
const out = (value) => console.log(JSON.stringify(value, null, 2));
|
|
47
|
+
function printSearch(data) {
|
|
48
|
+
const results = data.results ?? [];
|
|
49
|
+
if (!results.length) {
|
|
50
|
+
console.log("No matches.");
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
console.log(`${results.length} match(es) — parsed by ${data.parsedBy ?? "?"}\n`);
|
|
54
|
+
for (const [i, r] of results.entries()) {
|
|
55
|
+
console.log(`${i + 1}. ${r.game.title} [${r.game.genre}] ${r.game.price} — ${r.game.verification}`);
|
|
56
|
+
console.log(` id: ${r.game.id}`);
|
|
57
|
+
if (r.game.url)
|
|
58
|
+
console.log(` ${r.game.url}`);
|
|
59
|
+
for (const reason of r.reasons)
|
|
60
|
+
console.log(` • ${reason}`);
|
|
61
|
+
console.log("");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
async function promptCredentials() {
|
|
65
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
66
|
+
const email = (await rl.question("Snowbll account email: ")).trim();
|
|
67
|
+
const password = await rl.question("Password (input is visible): ");
|
|
68
|
+
rl.close();
|
|
69
|
+
if (!email || !password)
|
|
70
|
+
throw new SnowbllApiError(400, "Email and password are both required.");
|
|
71
|
+
return { email, password };
|
|
72
|
+
}
|
|
73
|
+
async function main() {
|
|
74
|
+
const [command, ...args] = process.argv.slice(2);
|
|
75
|
+
const flags = parseFlags(args);
|
|
76
|
+
switch (command) {
|
|
77
|
+
case "search": {
|
|
78
|
+
const query = flags.rest.join(" ").trim();
|
|
79
|
+
if (!query)
|
|
80
|
+
throw new SnowbllApiError(400, 'Usage: snowbll search "<query>"');
|
|
81
|
+
const data = await searchGames(query, flags.limit ?? 5);
|
|
82
|
+
if (flags.json)
|
|
83
|
+
out(data);
|
|
84
|
+
else
|
|
85
|
+
printSearch(data);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
case "game": {
|
|
89
|
+
const id = flags.rest[0];
|
|
90
|
+
if (!id)
|
|
91
|
+
throw new SnowbllApiError(400, "Usage: snowbll game <id>");
|
|
92
|
+
out(await getGame(id));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
case "games": {
|
|
96
|
+
out(await listGames(flags.limit ?? 20, flags.offset ?? 0));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
case "forge": {
|
|
100
|
+
const id = flags.rest[0];
|
|
101
|
+
out(id ? await getForgeCampaign(id) : await listForgeCampaigns(flags.status));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
case "persona": {
|
|
105
|
+
out(await getPersona(flags.rest[0] ?? "me"));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
case "whoami": {
|
|
109
|
+
out(await whoami());
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
case "login": {
|
|
113
|
+
const { email, password } = await promptCredentials();
|
|
114
|
+
const token = await supabaseLogin(email, password);
|
|
115
|
+
const machine = process.env.COMPUTERNAME || process.env.HOSTNAME || "cli";
|
|
116
|
+
const minted = await createKey(token, `cli-${machine}`.slice(0, 60));
|
|
117
|
+
writeConfig({ apiKey: minted.key });
|
|
118
|
+
console.log(`Signed in. API key ${minted.key_prefix}… minted and saved to ~/.snowbll/config.json`);
|
|
119
|
+
console.log("Verify with: snowbll whoami");
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
case "logout": {
|
|
123
|
+
clearConfig();
|
|
124
|
+
console.log("Stored key forgotten. (Keys stay valid server-side — revoke with `snowbll keys revoke <id>`.)");
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
case "keys": {
|
|
128
|
+
const [action, value] = flags.rest;
|
|
129
|
+
if (!action || !["list", "create", "revoke"].includes(action)) {
|
|
130
|
+
throw new SnowbllApiError(400, "Usage: snowbll keys list|create [name]|revoke <id>");
|
|
131
|
+
}
|
|
132
|
+
const { email, password } = await promptCredentials();
|
|
133
|
+
const token = await supabaseLogin(email, password);
|
|
134
|
+
if (action === "list")
|
|
135
|
+
out(await listKeys(token));
|
|
136
|
+
else if (action === "create") {
|
|
137
|
+
const minted = await createKey(token, value || "default");
|
|
138
|
+
console.log(`New key (shown once): ${minted.key}`);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
if (!value)
|
|
142
|
+
throw new SnowbllApiError(400, "Usage: snowbll keys revoke <id>");
|
|
143
|
+
out(await revokeKey(token, value));
|
|
144
|
+
}
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
case "setup": {
|
|
148
|
+
await runSetup();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
case "mcp": {
|
|
152
|
+
await runStdioServer(version);
|
|
153
|
+
// Keep the process alive — the MCP transport owns stdio from here.
|
|
154
|
+
return new Promise(() => { });
|
|
155
|
+
}
|
|
156
|
+
case "--version":
|
|
157
|
+
case "-v": {
|
|
158
|
+
console.log(version);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
case undefined:
|
|
162
|
+
case "help":
|
|
163
|
+
case "--help":
|
|
164
|
+
case "-h": {
|
|
165
|
+
console.log(HELP);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
default:
|
|
169
|
+
throw new SnowbllApiError(400, `Unknown command '${command}'. Run \`snowbll help\`.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
main().catch((err) => {
|
|
173
|
+
if (err instanceof SnowbllApiError) {
|
|
174
|
+
console.error(`Error${err.status ? ` (${err.status})` : ""}: ${err.message}`);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
178
|
+
}
|
|
179
|
+
process.exit(1);
|
|
180
|
+
});
|