@devbux/subq-snap-mcp 0.0.0-stage → 0.1.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.
- package/LICENSE +21 -0
- package/README.md +345 -2
- package/dist/index.js +594 -0
- package/package.json +57 -4
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
|
-
#
|
|
1
|
+
# subq-snap-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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,59 @@
|
|
|
1
1
|
{
|
|
2
|
+
"author": "devbux",
|
|
3
|
+
"bin": {
|
|
4
|
+
"subq-snap-mcp": "dist/index.js"
|
|
5
|
+
},
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/devbux/subq-snap-mcp/issues"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {
|
|
10
|
+
"@modelcontextprotocol/sdk": "^1.32.1",
|
|
11
|
+
"zod": "^3.25.0"
|
|
12
|
+
},
|
|
13
|
+
"description": "A Model Context Protocol server for SubQ's Snap decision model, with typed Choice, Score and Noul tools and image inputs.",
|
|
14
|
+
"devDependencies": {
|
|
15
|
+
"@types/node": "^22.0.0",
|
|
16
|
+
"tsup": "^8.5.0",
|
|
17
|
+
"tsx": "^4.23.0",
|
|
18
|
+
"typescript": "^5.9.0",
|
|
19
|
+
"vitest": "^3.2.0"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=18"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"homepage": "https://github.com/devbux/subq-snap-mcp#readme",
|
|
30
|
+
"keywords": [
|
|
31
|
+
"mcp",
|
|
32
|
+
"model-context-protocol",
|
|
33
|
+
"subq",
|
|
34
|
+
"snap",
|
|
35
|
+
"ai",
|
|
36
|
+
"decision-model",
|
|
37
|
+
"typescript"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"main": "dist/index.js",
|
|
2
41
|
"name": "@devbux/subq-snap-mcp",
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/devbux/subq-snap-mcp.git"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsup",
|
|
51
|
+
"dev": "tsx src/index.ts",
|
|
52
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
53
|
+
"start": "node dist/index.js",
|
|
54
|
+
"test": "vitest run",
|
|
55
|
+
"typecheck": "tsc --noEmit"
|
|
56
|
+
},
|
|
57
|
+
"type": "module",
|
|
58
|
+
"version": "0.1.1"
|
|
59
|
+
}
|