@devbux/subq-snap-mcp 0.0.0-stage → 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +345 -2
  3. package/dist/index.js +594 -0
  4. package/package.json +1 -6
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 devbux
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,346 @@
1
- # Temporary Holding Version
1
+ # subq-snap-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A standard [Model Context Protocol](https://modelcontextprotocol.io) (MCP)
4
+ server that exposes **SubQ's Snap decision model** as typed tools —
5
+ publishable to npm and runnable anywhere with `npx` or `bunx`.
6
+
7
+ ```bash
8
+ npx -y @devbux/subq-snap-mcp
9
+ ```
10
+
11
+ ## What is Snap?
12
+
13
+ Snap is SubQ's **decision model** (currently `subq-snap-1.1`, public
14
+ preview). It never generates prose. Instead, you give it:
15
+
16
+ - a **`state`** — a situation described as text or structured JSON,
17
+ - up to **4 images** to evaluate alongside the state, and
18
+ - a set of **typed questions** about that state,
19
+
20
+ and Snap returns **typed answers with probabilities**:
21
+
22
+ | Question type | You define | Snap returns |
23
+ | --- | --- | --- |
24
+ | `choice` | 2–255 labelled options (each with an optional description) | the chosen option, a confidence value, and a probability for every option |
25
+ | `score` | an ordered scale of 2–10 level descriptions | a probability-weighted score (it can fall between levels), the level legend, confidence, and per-level probabilities |
26
+ | `noul` | a proposition (optionally with true/false descriptions) | a probability (0..1) that the proposition is true |
27
+
28
+ Because the output is structured and probabilistic rather than free text,
29
+ Snap is well suited to routing, triage, moderation, risk scoring,
30
+ prioritisation, and any other "decide, don't chat" step inside an agent
31
+ workflow. One call can ask up to 128 questions, and every question sees
32
+ the same state and every image.
33
+
34
+ Scope: this server wraps SubQ's **Decisions API** and **Models API**. The
35
+ separate `subq-preview` chat model (Chat Completions / Responses APIs,
36
+ structured outputs, function calling) is not a decision model and is out
37
+ of scope here.
38
+
39
+ ## Requirements
40
+
41
+ - Node.js **>= 18** (or Bun) to run the server.
42
+ - A SubQ API key (see [Getting an API key](#getting-an-api-key)).
43
+
44
+ ## Install & run
45
+
46
+ The server speaks MCP over **stdio**, so you normally don't run it by hand —
47
+ your MCP client launches it. The commands below are how clients (or you, for a
48
+ quick smoke test) start it.
49
+
50
+ ### npx (Node)
51
+
52
+ ```bash
53
+ SUBQ_API_KEY=your-key npx -y @devbux/subq-snap-mcp
54
+ ```
55
+
56
+ ### bunx (Bun)
57
+
58
+ ```bash
59
+ SUBQ_API_KEY=your-key bunx @devbux/subq-snap-mcp
60
+ ```
61
+
62
+ ### Global install (npm)
63
+
64
+ ```bash
65
+ npm install -g @devbux/subq-snap-mcp
66
+ SUBQ_API_KEY=your-key subq-snap-mcp
67
+ ```
68
+
69
+ ### Global install (Bun)
70
+
71
+ ```bash
72
+ bun add -g @devbux/subq-snap-mcp
73
+ SUBQ_API_KEY=your-key subq-snap-mcp
74
+ ```
75
+
76
+ > The server starts even when `SUBQ_API_KEY` is not set, so clients can
77
+ > still list its tools; each tool call then returns a clear error naming the
78
+ > missing variable instead of crashing.
79
+
80
+ ## Getting an API key
81
+
82
+ Create an API key in your SubQ account (see [docs.subq.ai](https://docs.subq.ai)),
83
+ then expose it to the server as the `SUBQ_API_KEY` environment variable.
84
+ Never commit your key to source control.
85
+
86
+ ## MCP client configuration
87
+
88
+ ### Generic (`mcpServers` JSON)
89
+
90
+ Most MCP clients (Claude Desktop, and many others) accept a configuration
91
+ like this — see also [`examples/mcp-config.json`](examples/mcp-config.json):
92
+
93
+ ```json
94
+ {
95
+ "mcpServers": {
96
+ "subq": {
97
+ "command": "npx",
98
+ "args": ["-y", "@devbux/subq-snap-mcp"],
99
+ "env": {
100
+ "SUBQ_API_KEY": "YOUR_SUBQ_API_KEY"
101
+ }
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ Using Bun instead:
108
+
109
+ ```json
110
+ {
111
+ "mcpServers": {
112
+ "subq": {
113
+ "command": "bunx",
114
+ "args": ["@devbux/subq-snap-mcp"],
115
+ "env": {
116
+ "SUBQ_API_KEY": "YOUR_SUBQ_API_KEY"
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ ### Claude Code
124
+
125
+ ```bash
126
+ claude mcp add subq \
127
+ -e SUBQ_API_KEY=YOUR_SUBQ_API_KEY \
128
+ -- npx -y @devbux/subq-snap-mcp
129
+ ```
130
+
131
+ ### Cursor / VS Code
132
+
133
+ Cursor and VS Code use the same `mcpServers` JSON shape above (Cursor:
134
+ `.cursor/mcp.json` or Settings → MCP; VS Code: `.vscode/mcp.json` uses a
135
+ top-level `"servers"` key with the same per-server fields). Paste the generic
136
+ config and replace the placeholder key.
137
+
138
+ ## Configuration
139
+
140
+ | Environment variable | Default | Description |
141
+ | --- | --- | --- |
142
+ | `SUBQ_API_KEY` | _(unset)_ | Bearer API key for the SubQ API. Required for tool calls; the server still starts without it. |
143
+ | `SUBQ_BASE_URL` | `https://api.subq.ai` | Base URL of the SubQ API. |
144
+ | `SUBQ_SNAP_MODEL` | `subq-snap-1.1` | Snap model id to request. Can be overridden per call with the tools' optional `model` argument. Use `snap_list_models` to see what your key can access. |
145
+
146
+ ## Tools
147
+
148
+ All tools return the SubQ API response as JSON text content. Errors (missing
149
+ API key, HTTP 400/401/402/403/422/429/5xx, network failures, timeouts) are
150
+ returned as MCP tool errors (`isError`) with a clear message that includes
151
+ the HTTP status and the API's own error detail — never your API key.
152
+
153
+ | Tool | Inputs | Output |
154
+ | --- | --- | --- |
155
+ | `snap_decide` | `state` (string \| JSON object \| JSON array), `questions` (map of name → typed question, 1–128), optional `images`, `model` | All answers in one call: `{ model, answers, usage }` |
156
+ | `snap_choice` | `state`, `instructions`, `options` (object: label → description or `null`, 2–255), optional `images`, `model` | One Choice answer under the name `answer` |
157
+ | `snap_score` | `state`, `instructions`, `levels` (array of level descriptions, 2–10, ordered low → high), optional `images`, `model` | One Score answer under the name `answer` |
158
+ | `snap_noul` | `state`, `instructions`, optional `criteria` (`{ "true": ..., "false": ... }` descriptions), optional `images`, `model` | One Noul answer (probability 0..1) under the name `answer` |
159
+ | `snap_list_models` | _(none)_ | `{ object: "list", data: [{ id, name, object, created, owned_by }] }` |
160
+
161
+ ### Question shapes for `snap_decide`
162
+
163
+ ```jsonc
164
+ {
165
+ "type": "choice",
166
+ "instructions": "Which team should own this chat?",
167
+ "criteria": { "nurse_line": "Needs a nurse today", "front_desk": null }
168
+ }
169
+ ```
170
+
171
+ ```jsonc
172
+ {
173
+ "type": "score",
174
+ "instructions": "How soon should someone respond?",
175
+ "criteria": ["Within a week", "Within a day", "Within an hour"]
176
+ }
177
+ ```
178
+
179
+ ```jsonc
180
+ {
181
+ "type": "noul",
182
+ "instructions": "Should a nurse see this chat first?"
183
+ // "criteria" is optional for noul and omitted when not provided
184
+ }
185
+ ```
186
+
187
+ `instructions` and `criteria` accept structured JSON (objects/arrays) as
188
+ well as strings, so a criterion can carry the data it is judged against.
189
+
190
+ ## Images
191
+
192
+ Any decision tool accepts an optional `images` array. Each image is an
193
+ object with a `mediaType` (`image/png`, `image/jpeg`, or `image/webp`) and
194
+ the file bytes as one line of `base64` (no `data:` prefix — URLs and
195
+ `data:` URLs are rejected by the API). Every question in the request sees
196
+ every image, alongside the state.
197
+
198
+ ```json
199
+ {
200
+ "state": "The number row stopped working.",
201
+ "images": [{ "mediaType": "image/jpeg", "base64": "<base64 of the photo>" }],
202
+ "questions": {
203
+ "shows_keyboard": {
204
+ "type": "noul",
205
+ "instructions": "Does the photo show a keyboard?"
206
+ }
207
+ }
208
+ }
209
+ ```
210
+
211
+ Limits from the SubQ docs:
212
+
213
+ - At most **4 images** per request; each at most **4 MiB** decoded, and
214
+ **8 MiB** decoded in total. The request body can be up to 13 MiB.
215
+ - The image bytes must match the declared `mediaType`, or the API fails
216
+ the request with `422`. This server checks the count, the base64 shape,
217
+ and the decoded sizes before sending, so those mistakes come back as
218
+ clear local errors.
219
+ - Each image uses roughly 64–256 image tokens (not the base64 text),
220
+ counted toward the model's **384K-token** context window together with
221
+ the request text.
222
+ - Video is not supported; sample up to 4 frames and send them as images.
223
+ - Note: SubQ stores decisions together with their images, under SubQ's
224
+ data-retention policy.
225
+
226
+ ## Worked example
227
+
228
+ An employee reports a broken keyboard and attaches a photo. In **one**
229
+ `snap_decide` call you can check the photo, classify the device, and grade
230
+ its wear:
231
+
232
+ **Request** (tool `snap_decide`):
233
+
234
+ ```json
235
+ {
236
+ "state": "The number row stopped working.",
237
+ "images": [{ "mediaType": "image/jpeg", "base64": "<base64 of the photo>" }],
238
+ "questions": {
239
+ "shows_keyboard": {
240
+ "type": "noul",
241
+ "instructions": "Does the photo show a keyboard?",
242
+ "criteria": {
243
+ "true": "A keyboard is the main object",
244
+ "false": "No keyboard is visible"
245
+ }
246
+ },
247
+ "layout": {
248
+ "type": "choice",
249
+ "instructions": "Which keyboard layout is in the photo?",
250
+ "criteria": {
251
+ "full_size": "Desktop keyboard with a numeric keypad",
252
+ "tenkeyless": "Desktop keyboard with no numeric keypad",
253
+ "laptop": "Built into a laptop",
254
+ "not_a_keyboard": null
255
+ }
256
+ },
257
+ "wear": {
258
+ "type": "score",
259
+ "instructions": "How worn does the keyboard look?",
260
+ "criteria": ["Looks new", "Everyday wear", "Replace it"]
261
+ }
262
+ }
263
+ }
264
+ ```
265
+
266
+ **Response** (shape; exact values come from the live model):
267
+
268
+ ```json
269
+ {
270
+ "model": "subq-snap-1.1",
271
+ "answers": {
272
+ "shows_keyboard": { "type": "noul", "noul": 0.98 },
273
+ "layout": {
274
+ "type": "choice",
275
+ "choice": "tenkeyless",
276
+ "probabilities": { "full_size": 0.04, "tenkeyless": 0.9, "laptop": 0.05, "not_a_keyboard": 0.01 },
277
+ "confidence": 0.9
278
+ },
279
+ "wear": {
280
+ "type": "score",
281
+ "score": 1.35,
282
+ "legend": { "0": "Looks new", "1": "Everyday wear", "2": "Replace it" },
283
+ "probabilities": { "0": 0.15, "1": 0.55, "2": 0.3 },
284
+ "confidence": 0.72
285
+ }
286
+ },
287
+ "usage": { "input_tokens": 210, "output_tokens": 0 }
288
+ }
289
+ ```
290
+
291
+ Pricing note: you pay only for input tokens (`output_tokens` is always 0);
292
+ Snap is $0.04 per million input tokens at the time of writing.
293
+
294
+ ## Development
295
+
296
+ ```bash
297
+ git clone https://github.com/devbux/subq-snap-mcp.git
298
+ cd subq-snap-mcp
299
+
300
+ npm install
301
+ npm run dev # run the server from source with tsx (stdio)
302
+ npm run typecheck # tsc --noEmit
303
+ npm test # vitest (fake fetch — no live network calls)
304
+ npm run build # bundle to dist/index.js with tsup
305
+ npm start # run the built server
306
+ ```
307
+
308
+ Project layout:
309
+
310
+ ```text
311
+ src/
312
+ index.ts stdio bootstrap
313
+ server.ts MCP server + the five snap_* tools (zod-validated)
314
+ config.ts environment parsing (SUBQ_*)
315
+ snap/
316
+ client.ts typed SnapClient for POST /v1/systemone and GET /v1/models
317
+ types.ts request/response types for the SubQ API
318
+ test/
319
+ client.test.ts SnapClient + config unit tests
320
+ ```
321
+
322
+ ## Publishing
323
+
324
+ Releases are published to npm by the **Publish to npm** GitHub Action
325
+ (`.github/workflows/publish.yml`):
326
+
327
+ 1. Bump the version in `package.json`, update `CHANGELOG.md`, and push.
328
+ 2. Create a **GitHub Release** for the new version tag (or run the workflow
329
+ manually with `workflow_dispatch`).
330
+ 3. The workflow runs typecheck, tests, and build, then publishes with
331
+ `npm publish --provenance --access public`.
332
+
333
+ Notes:
334
+
335
+ - The workflow expects an `NPM_TOKEN` repository secret (an npm automation
336
+ token for an account/organisation that owns the `@devbux` scope) **in
337
+ this repository's settings**.
338
+ - Provenance is enabled (`id-token: write` permission + `--provenance`), so
339
+ each published version is linked to its GitHub Actions build.
340
+ - This is a **scoped** package (`@devbux/subq-snap-mcp`); `publishConfig.access`
341
+ is set to `public` in `package.json` so releases are publicly installable.
342
+ - `prepublishOnly` also runs typecheck, tests, and build for manual publishes.
343
+
344
+ ## License
345
+
346
+ [MIT](LICENSE) © 2026 devbux
package/dist/index.js ADDED
@@ -0,0 +1,594 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+
6
+ // src/config.ts
7
+ var DEFAULT_BASE_URL = "https://api.subq.ai";
8
+ var DEFAULT_MODEL = "subq-snap-1.1";
9
+ function loadConfig(env = process.env) {
10
+ const apiKey = env.SUBQ_API_KEY?.trim();
11
+ const baseUrlRaw = env.SUBQ_BASE_URL?.trim();
12
+ const model = env.SUBQ_SNAP_MODEL?.trim();
13
+ return {
14
+ ...apiKey ? { apiKey } : {},
15
+ baseUrl: (baseUrlRaw ? baseUrlRaw : DEFAULT_BASE_URL).replace(/\/+$/, ""),
16
+ model: model ? model : DEFAULT_MODEL
17
+ };
18
+ }
19
+
20
+ // src/server.ts
21
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
22
+ import { z } from "zod";
23
+
24
+ // src/snap/client.ts
25
+ var SnapError = class extends Error {
26
+ status;
27
+ constructor(message, status) {
28
+ super(message);
29
+ this.name = "SnapError";
30
+ if (status !== void 0) {
31
+ this.status = status;
32
+ }
33
+ }
34
+ };
35
+ var DEFAULT_BASE_URL2 = "https://api.subq.ai";
36
+ var DEFAULT_MODEL2 = "subq-snap-1.1";
37
+ var DEFAULT_TIMEOUT_MS = 3e4;
38
+ var MAX_IMAGES = 4;
39
+ var MAX_IMAGE_BYTES = 4 * 1024 * 1024;
40
+ var MAX_TOTAL_IMAGE_BYTES = 8 * 1024 * 1024;
41
+ function isValidBase64(value) {
42
+ if (value.length === 0 || value.length % 4 !== 0) return false;
43
+ if (/\s/.test(value)) return false;
44
+ const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
45
+ const body = padding > 0 ? value.slice(0, -padding) : value;
46
+ if (body.includes("=")) return false;
47
+ for (let i = 0; i < body.length; i += 8192) {
48
+ if (!/^[A-Za-z0-9+/]+$/.test(body.slice(i, i + 8192))) return false;
49
+ }
50
+ return true;
51
+ }
52
+ var SnapClient = class {
53
+ apiKey;
54
+ baseUrl;
55
+ model;
56
+ fetchImpl;
57
+ timeoutMs;
58
+ constructor(options = {}) {
59
+ if (options.apiKey === void 0 && options.baseUrl === void 0 && options.model === void 0 && options.fetchImpl === void 0 && options.timeoutMs === void 0) {
60
+ const config = loadConfig();
61
+ options = { ...options, ...config };
62
+ }
63
+ this.apiKey = options.apiKey;
64
+ this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL2).replace(/\/+$/, "");
65
+ this.model = options.model ?? DEFAULT_MODEL2;
66
+ this.fetchImpl = options.fetchImpl ?? fetch;
67
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
68
+ }
69
+ /**
70
+ * Ask Snap one or more typed questions about a state.
71
+ * Calls `POST /v1/systemone`.
72
+ */
73
+ async decide(request) {
74
+ const questions = {};
75
+ for (const [name, question] of Object.entries(request.questions)) {
76
+ if (question.type === "noul" && question.criteria === void 0) {
77
+ const { criteria: _omitted, ...rest } = question;
78
+ questions[name] = rest;
79
+ } else {
80
+ questions[name] = question;
81
+ }
82
+ }
83
+ const images = request.images;
84
+ if (images !== void 0 && images.length > 0) {
85
+ validateImages(images);
86
+ }
87
+ const body = {
88
+ model: request.model ?? this.model,
89
+ state: request.state,
90
+ questions,
91
+ ...images !== void 0 && images.length > 0 ? { images } : {}
92
+ };
93
+ const data = await this.requestJson("/v1/systemone", {
94
+ method: "POST",
95
+ body: JSON.stringify(body)
96
+ });
97
+ if (typeof data !== "object" || data === null || typeof data.answers !== "object" || data.answers === null) {
98
+ throw new SnapError(
99
+ "Unexpected response from the SubQ API: missing 'answers' object."
100
+ );
101
+ }
102
+ return data;
103
+ }
104
+ /** List the models available to this API key. Calls `GET /v1/models`. */
105
+ async listModels() {
106
+ const data = await this.requestJson("/v1/models", {
107
+ method: "GET"
108
+ });
109
+ if (typeof data !== "object" || data === null || !Array.isArray(data.data)) {
110
+ throw new SnapError(
111
+ "Unexpected response from the SubQ API: missing 'data' array."
112
+ );
113
+ }
114
+ return data;
115
+ }
116
+ requireApiKey() {
117
+ if (!this.apiKey) {
118
+ throw new SnapError(
119
+ "SUBQ_API_KEY is not set. Set the SUBQ_API_KEY environment variable to your SubQ API key and restart the server."
120
+ );
121
+ }
122
+ return this.apiKey;
123
+ }
124
+ async requestJson(path, init) {
125
+ const apiKey = this.requireApiKey();
126
+ const url = `${this.baseUrl}${path}`;
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
129
+ let response;
130
+ try {
131
+ response = await this.fetchImpl(url, {
132
+ method: init.method,
133
+ headers: {
134
+ Authorization: `Bearer ${apiKey}`,
135
+ "Content-Type": "application/json"
136
+ },
137
+ ...init.body !== void 0 ? { body: init.body } : {},
138
+ signal: controller.signal
139
+ });
140
+ } catch (err) {
141
+ if (err instanceof Error && err.name === "AbortError") {
142
+ throw new SnapError(
143
+ `Request to the SubQ API timed out after ${this.timeoutMs} ms.`
144
+ );
145
+ }
146
+ throw new SnapError(
147
+ `Network error while contacting the SubQ API: ${err instanceof Error ? err.message : String(err)}`
148
+ );
149
+ } finally {
150
+ clearTimeout(timer);
151
+ }
152
+ const text = await response.text();
153
+ let parsed;
154
+ try {
155
+ parsed = text.length > 0 ? JSON.parse(text) : void 0;
156
+ } catch {
157
+ parsed = void 0;
158
+ }
159
+ if (!response.ok) {
160
+ throw new SnapError(
161
+ httpErrorMessage(response.status, parsed),
162
+ response.status
163
+ );
164
+ }
165
+ return parsed;
166
+ }
167
+ };
168
+ function validateImages(images) {
169
+ if (images.length > MAX_IMAGES) {
170
+ throw new SnapError(
171
+ `Too many images: the Decisions API accepts at most ${MAX_IMAGES} per request.`
172
+ );
173
+ }
174
+ let total = 0;
175
+ images.forEach((image, index) => {
176
+ if (!isValidBase64(image.base64)) {
177
+ throw new SnapError(
178
+ `images/${index}: base64 must be one line of standard base64 with padding and no data: prefix.`
179
+ );
180
+ }
181
+ const bytes = Buffer.byteLength(image.base64, "base64");
182
+ if (bytes > MAX_IMAGE_BYTES) {
183
+ throw new SnapError(
184
+ `images/${index}: image over 4 MiB (decoded size ${bytes} bytes).`
185
+ );
186
+ }
187
+ total += bytes;
188
+ });
189
+ if (total > MAX_TOTAL_IMAGE_BYTES) {
190
+ throw new SnapError(
191
+ `Images total ${total} bytes decoded, over the 8 MiB limit.`
192
+ );
193
+ }
194
+ }
195
+ function httpErrorMessage(status, body) {
196
+ let detail = "";
197
+ if (typeof body === "object" && body !== null) {
198
+ const raw = body.detail;
199
+ if (typeof raw === "string") {
200
+ detail = raw;
201
+ } else if (typeof raw === "object" && raw !== null) {
202
+ const type = raw.error_type;
203
+ const message = raw.message;
204
+ const parts = [
205
+ typeof type === "string" ? type : "",
206
+ typeof message === "string" ? message : ""
207
+ ].filter((part) => part.length > 0);
208
+ detail = parts.join(": ");
209
+ }
210
+ }
211
+ let hint;
212
+ switch (status) {
213
+ case 400:
214
+ hint = "the request was rejected (over the model's token limit, or the model rejected it)";
215
+ break;
216
+ case 401:
217
+ hint = "authentication failed: check that SUBQ_API_KEY is set to a valid SubQ API key";
218
+ break;
219
+ case 402:
220
+ hint = "insufficient credits on the SubQ account for this API key";
221
+ break;
222
+ case 403:
223
+ hint = "this API key cannot use the requested model";
224
+ break;
225
+ case 422:
226
+ hint = "the request was invalid";
227
+ break;
228
+ case 429:
229
+ hint = "rate limit exceeded: slow down and retry later";
230
+ break;
231
+ case 503:
232
+ hint = "the SubQ service is temporarily unavailable";
233
+ break;
234
+ default:
235
+ hint = status >= 500 ? "the SubQ API had a server error" : "the request failed";
236
+ }
237
+ return `SubQ API error (HTTP ${status}): ${hint}` + (detail ? ` \u2014 ${detail}` : "");
238
+ }
239
+
240
+ // src/server.ts
241
+ var SINGLE_QUESTION_NAME = "answer";
242
+ var stateSchema = z.union([
243
+ z.string(),
244
+ z.record(z.string(), z.unknown()),
245
+ z.array(z.unknown())
246
+ ]);
247
+ var entrySchema = z.union([
248
+ z.string(),
249
+ z.record(z.string(), z.unknown()),
250
+ z.array(z.unknown()),
251
+ z.null()
252
+ ]);
253
+ var instructionsSchema = z.union([
254
+ z.string().min(1, "instructions must not be empty"),
255
+ z.record(z.string(), z.unknown()),
256
+ z.array(z.unknown()),
257
+ z.null()
258
+ ]);
259
+ function isEntryValue(value) {
260
+ return value === null || typeof value === "string" || typeof value === "object";
261
+ }
262
+ function isPlainObject(value) {
263
+ return typeof value === "object" && value !== null && !Array.isArray(value);
264
+ }
265
+ var imagesSchema = z.array(
266
+ z.object({
267
+ mediaType: z.enum(["image/png", "image/jpeg", "image/webp"]),
268
+ base64: z.string().min(1).refine(isValidBase64, {
269
+ message: "base64 must be one line of standard base64 with no data: prefix"
270
+ })
271
+ })
272
+ ).max(4, "at most 4 images per request").describe(
273
+ "Up to 4 images for this request. PNG, JPEG, or WebP, base64-encoded with no data: prefix; at most 4 MiB decoded each, 8 MiB in total. Every question in the request sees every image."
274
+ );
275
+ var modelSchema = z.string().min(1).optional().describe("Optional Snap model id override (default: subq-snap-1.1).");
276
+ var questionSchema = z.object({
277
+ type: z.enum(["choice", "score", "noul"]),
278
+ instructions: z.unknown(),
279
+ criteria: z.unknown().optional()
280
+ }).superRefine((question, ctx) => {
281
+ const { type, criteria, instructions } = question;
282
+ if (!isEntryValue(instructions) || typeof instructions === "string" && instructions.length === 0) {
283
+ ctx.addIssue({
284
+ code: z.ZodIssueCode.custom,
285
+ message: "instructions must be a non-empty string, a structured object or array, or null",
286
+ path: ["instructions"]
287
+ });
288
+ return;
289
+ }
290
+ if (type === "choice") {
291
+ if (!isPlainObject(criteria)) {
292
+ ctx.addIssue({
293
+ code: z.ZodIssueCode.custom,
294
+ message: "choice questions require criteria: an object mapping each option label to its description",
295
+ path: ["criteria"]
296
+ });
297
+ return;
298
+ }
299
+ const count = Object.keys(criteria).length;
300
+ if (count < 2 || count > 255) {
301
+ ctx.addIssue({
302
+ code: z.ZodIssueCode.custom,
303
+ message: `choice questions require 2-255 options, got ${count}`,
304
+ path: ["criteria"]
305
+ });
306
+ }
307
+ for (const [label, value] of Object.entries(criteria)) {
308
+ if (!isEntryValue(value)) {
309
+ ctx.addIssue({
310
+ code: z.ZodIssueCode.custom,
311
+ message: `choice option "${label}" must map to a description: a string, a structured object or array, or null`,
312
+ path: ["criteria", label]
313
+ });
314
+ }
315
+ }
316
+ } else if (type === "score") {
317
+ if (!Array.isArray(criteria) || criteria.length < 2 || criteria.length > 10 || !criteria.every((level) => isEntryValue(level))) {
318
+ ctx.addIssue({
319
+ code: z.ZodIssueCode.custom,
320
+ message: "score questions require criteria: an array of 2-10 ordered level descriptions (strings or structured objects/arrays)",
321
+ path: ["criteria"]
322
+ });
323
+ }
324
+ } else if (type === "noul") {
325
+ if (criteria !== void 0) {
326
+ if (!isPlainObject(criteria)) {
327
+ ctx.addIssue({
328
+ code: z.ZodIssueCode.custom,
329
+ message: "noul criteria must be an object with true/false descriptions",
330
+ path: ["criteria"]
331
+ });
332
+ } else {
333
+ for (const [label, value] of Object.entries(criteria)) {
334
+ if (!isEntryValue(value)) {
335
+ ctx.addIssue({
336
+ code: z.ZodIssueCode.custom,
337
+ message: `noul criteria "${label}" must map to a description: a string, a structured object or array, or null`,
338
+ path: ["criteria", label]
339
+ });
340
+ }
341
+ }
342
+ }
343
+ }
344
+ }
345
+ });
346
+ function toSnapQuestion(question) {
347
+ switch (question.type) {
348
+ case "choice":
349
+ return {
350
+ type: "choice",
351
+ instructions: question.instructions,
352
+ criteria: question.criteria
353
+ };
354
+ case "score":
355
+ return {
356
+ type: "score",
357
+ instructions: question.instructions,
358
+ criteria: question.criteria
359
+ };
360
+ case "noul":
361
+ return question.criteria === void 0 ? { type: "noul", instructions: question.instructions } : {
362
+ type: "noul",
363
+ instructions: question.instructions,
364
+ criteria: question.criteria
365
+ };
366
+ }
367
+ }
368
+ function textResult(value) {
369
+ return {
370
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }]
371
+ };
372
+ }
373
+ function errorResult(err) {
374
+ const message = err instanceof SnapError ? err.message : err instanceof Error ? err.message : String(err);
375
+ return {
376
+ isError: true,
377
+ content: [{ type: "text", text: message }]
378
+ };
379
+ }
380
+ function createServer(opts) {
381
+ const client = opts?.config !== void 0 ? new SnapClient({
382
+ ...opts.config.apiKey !== void 0 ? { apiKey: opts.config.apiKey } : {},
383
+ baseUrl: opts.config.baseUrl,
384
+ model: opts.config.model
385
+ }) : new SnapClient();
386
+ const server = new McpServer({
387
+ name: "subq-snap-mcp",
388
+ version: "0.1.0"
389
+ });
390
+ server.registerTool(
391
+ "snap_decide",
392
+ {
393
+ title: "Snap: decide (typed questions over a state)",
394
+ description: "Ask SubQ's Snap decision model one or more typed questions (choice, score and/or noul) about a state in a single call. Returns typed answers with probabilities. The state may be text or structured JSON, and up to 4 images may accompany it; every question sees the state and every image.",
395
+ inputSchema: {
396
+ state: stateSchema.describe(
397
+ "The situation to decide about: text, a JSON object, or a JSON array."
398
+ ),
399
+ questions: z.record(z.string(), questionSchema).superRefine((questions, ctx) => {
400
+ const names = Object.keys(questions);
401
+ if (names.length < 1 || names.length > 128) {
402
+ ctx.addIssue({
403
+ code: z.ZodIssueCode.custom,
404
+ message: `a request needs 1-128 questions, got ${names.length}`
405
+ });
406
+ }
407
+ for (const name of names) {
408
+ if (name.length > 100) {
409
+ ctx.addIssue({
410
+ code: z.ZodIssueCode.custom,
411
+ message: `question name "${name.slice(0, 24)}..." is over 100 characters`,
412
+ path: [name]
413
+ });
414
+ }
415
+ }
416
+ }).describe(
417
+ "Map of question name (up to 100 characters) to typed question; 1-128 questions. Each question has a type (choice | score | noul), instructions, and criteria: choice = object of option label -> description (string, structured object/array, or null; 2-255 options); score = array of 2-10 ordered level descriptions; noul = optional object of true/false descriptions. Instructions may be text or structured JSON."
418
+ ),
419
+ images: imagesSchema.optional(),
420
+ model: modelSchema
421
+ }
422
+ },
423
+ async ({ state, questions, images, model }) => {
424
+ try {
425
+ const typed = {};
426
+ for (const [name, question] of Object.entries(questions)) {
427
+ typed[name] = toSnapQuestion(question);
428
+ }
429
+ const result = await client.decide({
430
+ state,
431
+ questions: typed,
432
+ ...images ? { images } : {},
433
+ ...model ? { model } : {}
434
+ });
435
+ return textResult(result);
436
+ } catch (err) {
437
+ return errorResult(err);
438
+ }
439
+ }
440
+ );
441
+ server.registerTool(
442
+ "snap_choice",
443
+ {
444
+ title: "Snap: choice question",
445
+ description: `Ask SubQ's Snap decision model a single Choice question: pick one of 2-255 labelled options for a state. Returns the chosen option, a confidence value, and a probability for every option, under the question name "answer".`,
446
+ inputSchema: {
447
+ state: stateSchema.describe(
448
+ "The situation to decide about: text, a JSON object, or a JSON array."
449
+ ),
450
+ instructions: instructionsSchema.describe(
451
+ "What Snap should decide: plain-language text, or structured JSON."
452
+ ),
453
+ options: z.record(z.string(), entrySchema).refine(
454
+ (options) => {
455
+ const count = Object.keys(options).length;
456
+ return count >= 2 && count <= 255;
457
+ },
458
+ { message: "choice questions require 2-255 options" }
459
+ ).describe(
460
+ "Object mapping each option label to a description (string, structured object/array, or null; 2-255 options)."
461
+ ),
462
+ images: imagesSchema.optional(),
463
+ model: modelSchema
464
+ }
465
+ },
466
+ async ({ state, instructions, options, images, model }) => {
467
+ try {
468
+ const result = await client.decide({
469
+ state,
470
+ questions: {
471
+ [SINGLE_QUESTION_NAME]: {
472
+ type: "choice",
473
+ instructions,
474
+ criteria: options
475
+ }
476
+ },
477
+ ...images ? { images } : {},
478
+ ...model ? { model } : {}
479
+ });
480
+ return textResult(result);
481
+ } catch (err) {
482
+ return errorResult(err);
483
+ }
484
+ }
485
+ );
486
+ server.registerTool(
487
+ "snap_score",
488
+ {
489
+ title: "Snap: score question",
490
+ description: `Ask SubQ's Snap decision model a single Score question: rate a state on an ordered scale of 2-10 levels you define. Returns a probability-weighted score (it can fall between levels), the level legend, per-level probabilities, and a confidence value, under the question name "answer".`,
491
+ inputSchema: {
492
+ state: stateSchema.describe(
493
+ "The situation to rate: text, a JSON object, or a JSON array."
494
+ ),
495
+ instructions: instructionsSchema.describe(
496
+ "What Snap should rate: plain-language text, or structured JSON."
497
+ ),
498
+ levels: z.array(entrySchema).min(2, "score questions require at least 2 levels").max(10, "score questions allow at most 10 levels").describe(
499
+ "Ordered level descriptions, lowest to highest (2-10). Each level may be a string or a structured entry."
500
+ ),
501
+ images: imagesSchema.optional(),
502
+ model: modelSchema
503
+ }
504
+ },
505
+ async ({ state, instructions, levels, images, model }) => {
506
+ try {
507
+ const result = await client.decide({
508
+ state,
509
+ questions: {
510
+ [SINGLE_QUESTION_NAME]: {
511
+ type: "score",
512
+ instructions,
513
+ criteria: levels
514
+ }
515
+ },
516
+ ...images ? { images } : {},
517
+ ...model ? { model } : {}
518
+ });
519
+ return textResult(result);
520
+ } catch (err) {
521
+ return errorResult(err);
522
+ }
523
+ }
524
+ );
525
+ server.registerTool(
526
+ "snap_noul",
527
+ {
528
+ title: "Snap: noul question (yes/no probability)",
529
+ description: `Ask SubQ's Snap decision model a single Noul question: is a proposition about a state true? Returns a calibrated probability (0..1) under the question name "answer".`,
530
+ inputSchema: {
531
+ state: stateSchema.describe(
532
+ "The situation to assess: text, a JSON object, or a JSON array."
533
+ ),
534
+ instructions: instructionsSchema.describe(
535
+ "The proposition Snap should assess: plain-language text, or structured JSON."
536
+ ),
537
+ criteria: z.record(z.string(), entrySchema).optional().describe(
538
+ 'Optional descriptions keyed by "true" / "false" explaining what each outcome means. Values may be strings, structured objects/arrays, or null.'
539
+ ),
540
+ images: imagesSchema.optional(),
541
+ model: modelSchema
542
+ }
543
+ },
544
+ async ({ state, instructions, criteria, images, model }) => {
545
+ try {
546
+ const result = await client.decide({
547
+ state,
548
+ questions: {
549
+ [SINGLE_QUESTION_NAME]: {
550
+ type: "noul",
551
+ instructions,
552
+ ...criteria !== void 0 ? { criteria } : {}
553
+ }
554
+ },
555
+ ...images ? { images } : {},
556
+ ...model ? { model } : {}
557
+ });
558
+ return textResult(result);
559
+ } catch (err) {
560
+ return errorResult(err);
561
+ }
562
+ }
563
+ );
564
+ server.registerTool(
565
+ "snap_list_models",
566
+ {
567
+ title: "Snap: list available models",
568
+ description: "List the model IDs available to the configured SubQ API key (GET /v1/models, OpenAI-compatible shape). Use a returned id with the endpoint that supports that model; the Decisions tools in this server need a decision model such as subq-snap-1.1.",
569
+ inputSchema: {}
570
+ },
571
+ async () => {
572
+ try {
573
+ const result = await client.listModels();
574
+ return textResult(result);
575
+ } catch (err) {
576
+ return errorResult(err);
577
+ }
578
+ }
579
+ );
580
+ return server;
581
+ }
582
+
583
+ // src/index.ts
584
+ async function main() {
585
+ const config = loadConfig();
586
+ const server = createServer({ config });
587
+ const transport = new StdioServerTransport();
588
+ await server.connect(transport);
589
+ console.error("subq-snap-mcp: server running on stdio");
590
+ }
591
+ main().catch((err) => {
592
+ console.error("subq-snap-mcp: fatal error starting server:", err);
593
+ process.exit(1);
594
+ });
package/package.json CHANGED
@@ -1,6 +1 @@
1
- {
2
- "name": "@devbux/subq-snap-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {"author":"devbux","bin":{"subq-snap-mcp":"dist/index.js"},"bugs":{"url":"https://github.com/devbux/subq-snap-mcp/issues"},"dependencies":{"@modelcontextprotocol/sdk":"^1.32.1","zod":"^3.25.0"},"description":"A Model Context Protocol server for SubQ's Snap decision model, with typed Choice, Score and Noul tools and image inputs.","devDependencies":{"@types/node":"^22.0.0","tsup":"^8.5.0","tsx":"^4.23.0","typescript":"^5.9.0","vitest":"^3.2.0"},"engines":{"node":">=18"},"files":["dist","README.md","LICENSE"],"homepage":"https://github.com/devbux/subq-snap-mcp#readme","keywords":["mcp","model-context-protocol","subq","snap","ai","decision-model","typescript"],"license":"MIT","main":"dist/index.js","name":"@devbux/subq-snap-mcp","publishConfig":{"access":"public"},"repository":{"type":"git","url":"git+https://github.com/devbux/subq-snap-mcp.git"},"scripts":{"build":"tsup","dev":"tsx src/index.ts","prepublishOnly":"npm run typecheck && npm test && npm run build","start":"node dist/index.js","test":"vitest run","typecheck":"tsc --noEmit"},"type":"module","version":"0.1.0"}