benjivers/jev-mcp Public Public: anyone can read this; nobody can change it.

workbenchthis machine

feat: MCP server exposing TypeSafe Jev as typed judgment tools

Four tools over stdio: jev_classify (Choice), jev_score (Score),
jev_check (Noul), and jev_ask, which batches several questions about one
state into a single request. jev_models confirms the key and lists what
it can reach.

Design rules, each of which came from a defect found on the wire:

  - The caller owns the option set. jev_classify requires options, so the
    model can pick the wrong one but can never invent one. A selector
    cannot choose a candidate the enumerator dropped.
  - Probabilities are always returned, never just the winning label.
  - The key comes from the environment or a 0600 file, never a tool
    argument. An MCP server inherits the client's environment rather than
    your shell's, so a key exported from a shell profile never arrives.
  - Nothing is silently dropped, overwritten or truncated. An option the
    caller names "none" is preserved and the no-match key is reported
    back; oversized state is rejected rather than trimmed.
  - Only JSON-RPC reaches stdout. The SDK's default logger writes to
    stdout, which corrupts a stdio transport, so all levels go to stderr.

The offline suite drives the real built server over stdio against a local
stand-in for the API and asserts on the request bodies actually sent, so
a regression in what reaches the model fails the build.
rashed parvez committed cbaceb5 Browse files
13 files changed +3010 −0
.github/workflows/ci.yml added +34
@@ -0,0 +1,34 @@
1 name: CI
2
3 on:
4 push:
5 branches: [main]
6 pull_request:
7
8 jobs:
9 test:
10 runs-on: ubuntu-latest
11 strategy:
12 fail-fast: false
13 matrix:
14 node-version: [20, 22, 24]
15 steps:
16 - uses: actions/checkout@v4
17 - uses: actions/setup-node@v4
18 with:
19 node-version: ${{ matrix.node-version }}
20 # Dependency install scripts stay blocked; this package needs none.
21 - run: npm ci --ignore-scripts
22 - run: npm run typecheck
23 - run: npm run build
24 - run: npm test
25
26 audit:
27 runs-on: ubuntu-latest
28 steps:
29 - uses: actions/checkout@v4
30 - uses: actions/setup-node@v4
31 with:
32 node-version: 22
33 - run: npm ci --ignore-scripts
34 - run: npm audit --audit-level=high
.gitignore added +3
@@ -0,0 +1,3 @@
1 node_modules/
2 dist/
3 .env
LICENSE added +21
@@ -0,0 +1,21 @@
1 MIT License
2
3 Copyright (c) 2026 rashed parvez
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.
README.md added +167
@@ -0,0 +1,167 @@
1 # jev-mcp
2
3 An MCP server that exposes [TypeSafe Jev](https://docs.typesafe.ai) as typed judgment tools.
4
5 Jev is a System One model. It returns a typed answer and a calibrated probability
6 distribution, never prose. These tools surface that faithfully rather than hiding
7 it behind a label.
8
9 ## Install
10
11 Requires Node 20.12+ and a TypeSafe API key from
12 [console.typesafe.ai](https://console.typesafe.ai/settings/keys).
13
14 ```bash
15 npm install
16 npm run build
17 ```
18
19 Register with Claude Code:
20
21 ```bash
22 claude mcp add --scope user jev -- node /absolute/path/to/jev-mcp/dist/index.js
23 ```
24
25 The key is read from `TYPESAFE_API_KEY` in the server's environment. It is never a
26 tool argument, so it cannot land in a transcript or in a model's context. Put it in
27 `~/.zshenv` rather than in any repo, and make sure it is **exported**:
28
29 ```sh
30 export TYPESAFE_API_KEY="ts_..."
31 ```
32
33 Without `export` the variable exists only in the shell that read it. Child processes,
34 including every MCP server Claude Code spawns, never see it, and every tool call fails
35 with a missing-key error.
36
37 ## Tools
38
39 | Tool | Primitive | Use when |
40 | --- | --- | --- |
41 | `jev_classify` | Choice | The answer is one of a fixed set you define |
42 | `jev_score` | Score | The answer is a degree on an ordered scale |
43 | `jev_check` | Noul | The answer is yes or no, and you want the probability |
44 | `jev_ask` | all three | You have several questions about the same state |
45 | `jev_models` | — | Confirm the key works and find a model id |
46
47 Every tool returns the full probability distribution alongside the answer, plus
48 `confidence` for Choice and Score. Results come back as MCP structured content, so a
49 client gets typed data rather than a JSON string to re-parse.
50
51 ### Prefer `jev_ask`
52
53 Jev prefills the state once and scores every question in a single forward pass, so
54 extra questions add almost no latency or cost. TypeSafe's own
55 [measurement](https://docs.typesafe.ai/cookbooks/parallel_questions) on a
56 document-dominated workload puts one batched call at 12.2x cheaper and 10.0x faster
57 than one call per question, with no change in the answers.
58
59 Questions in one request cannot see each other's answers. State any speculative
60 premise explicitly and let your own code decide which answers apply.
61
62 ## Design rules
63
64 1. **The caller owns the option set.** `jev_classify` requires options from you, so
65 the model can pick the wrong one but can never invent one. A selector cannot
66 choose a candidate the enumerator dropped. This is the single most common way
67 these integrations fail.
68 2. **Probabilities are always returned.** Not just the winner.
69 3. **The key lives in the environment.** Never in an argument.
70 4. **Nothing is silently dropped, overwritten, or truncated.** A request that cannot
71 be honoured exactly fails with a reason instead of quietly changing meaning.
72 5. **Only JSON-RPC reaches stdout.** Logs go to stderr, always.
73
74 ### The no-match option
75
76 Each selecting tool adds a `none` option by default so the model can decline rather
77 than being forced to pick. Turn it off with `add_none: false` when one option must
78 always apply.
79
80 If you already use the name `none` for an option of your own, the added option takes
81 a different key instead of overwriting yours, and the response reports which key
82 carries the no-match meaning in `none_option`. Your option and its probability always
83 survive intact.
84
85 ### Confidence gating
86
87 Choice and Score answers include an `action` of `act`, `review`, or `abstain`, derived
88 from `confidence` and the thresholds you pass in `act_above` and `review_above`
89 (defaults 0.8 and 0.5). `jev_check` returns a `verdict` of `yes`, `no`, or `uncertain`
90 from `yes_at_or_above` and `no_at_or_below` (defaults 0.7 and 0.3).
91
92 These defaults are starting points, not universal rules. Calibrate them on your own
93 data and on what it costs to be wrong; a destructive action deserves a higher bar than
94 a read-only one. Confidence describes how concentrated the distribution is. It is not
95 a claim that the answer is correct.
96
97 A Noul near 0.5 means yes and no are close to equally likely, not that the answer is
98 "medium", which is why the middle band reports `uncertain` rather than rounding.
99
100 ### Errors
101
102 Failures come back with `isError` and a classified body: `kind`, `retryable`, and
103 where available `status`, `requestId`, and a `hint`. A rejected key (`authentication`,
104 never retryable) is distinguishable from a rate limit (`rate_limit`, retryable) and
105 from a malformed question (`invalid_request`). The SDK already retries 408, 429, and
106 5xx with backoff before an error surfaces here.
107
108 ### Limits
109
110 A Choice question accepts at most 255 options, which is an API limit; past that,
111 search in two passes, one question picking a window and a second ranking within it.
112 Question count and state size are local caps that bound cost on a single call, and
113 both are configurable. Oversized state is rejected rather than truncated, because
114 truncating silently changes the material the judgment rests on.
115
116 ## Configuration
117
118 | Variable | Effect |
119 | --- | --- |
120 | `TYPESAFE_API_KEY` | Required. `JEV_API_KEY` also works. |
121 | `JEV_MODEL` | Model id. Defaults to `jev-latest`. |
122 | `JEV_TIMEOUT_MS` | Per-attempt timeout. Defaults to 15000. |
123 | `JEV_MAX_QUESTIONS` | Questions per `jev_ask`. Defaults to 64. |
124 | `JEV_MAX_STATE_CHARS` | Largest state accepted. Defaults to 200000. |
125 | `TYPESAFE_LOG_LEVEL` | SDK verbosity. Safe at any level; all output goes to stderr. |
126
127 An unusable value for any numeric setting falls back to the default and warns on
128 stderr, rather than becoming `NaN` and disabling the limit it was meant to enforce.
129
130 ## Troubleshooting
131
132 **Every call reports a missing key.** Some MCP clients filter the environment before
133 spawning servers, which drops `TYPESAFE_API_KEY`. Confirm the variable is exported,
134 then pass it explicitly in the client's server config if it still does not arrive.
135 Run `jev_models` to check the key in isolation.
136
137 **The server connects and then dies.** On a stdio transport, anything written to
138 stdout that is not JSON-RPC breaks the connection. This server routes all logging to
139 stderr, so `TYPESAFE_LOG_LEVEL=debug` is safe to turn on while debugging.
140
141 ## When not to reach for this
142
143 Jev earns its place on a decision that repeats thousands of times inside software,
144 where code can enumerate the options first and you need a number to threshold on.
145
146 For a one-off judgment during a conversation, an ordinary model answer is usually
147 better, because it comes with reasoning you can argue with. Jev gives you a number
148 and a label. That is a feature at scale and a limitation in dialogue.
149
150 If deterministic code already decides the case correctly, keep the deterministic
151 code. Typed output guarantees the interface, not the truth. Measure before adopting.
152
153 ## Development
154
155 ```bash
156 npm run typecheck
157 npm test # offline: unit tests plus regression tests against a local stand-in
158 npm run test:e2e # live, requires TYPESAFE_API_KEY
159 ```
160
161 The offline suite runs the real built server over stdio against a local stand-in for
162 the TypeSafe API and asserts on the request bodies it actually sends, so a regression
163 in what reaches the model fails the build.
164
165 ## License
166
167 MIT
package-lock.json added +1255
@@ -0,0 +1,1255 @@
1 {
2 "name": "jev-mcp",
3 "version": "0.1.0",
4 "lockfileVersion": 3,
5 "requires": true,
6 "packages": {
7 "": {
8 "name": "jev-mcp",
9 "version": "0.1.0",
10 "license": "MIT",
11 "dependencies": {
12 "@modelcontextprotocol/sdk": "^1.30.0",
13 "@typesafe-ai/sdk": "^0.6.0",
14 "zod": "^3.25.0"
15 },
16 "bin": {
17 "jev-mcp": "dist/index.js"
18 },
19 "devDependencies": {
20 "@types/node": "^22.0.0",
21 "typescript": "^5.6.0"
22 },
23 "engines": {
24 "node": ">=20.12"
25 }
26 },
27 "node_modules/@hono/node-server": {
28 "version": "2.1.1",
29 "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz",
30 "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==",
31 "license": "MIT",
32 "engines": {
33 "node": ">=20"
34 },
35 "peerDependencies": {
36 "hono": "^4"
37 }
38 },
39 "node_modules/@modelcontextprotocol/sdk": {
40 "version": "1.30.0",
41 "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz",
42 "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==",
43 "license": "MIT",
44 "dependencies": {
45 "@hono/node-server": "^1.19.9 || ^2.0.5",
46 "ajv": "^8.17.1",
47 "ajv-formats": "^3.0.1",
48 "content-type": "^1.0.5",
49 "cors": "^2.8.5",
50 "cross-spawn": "^7.0.5",
51 "eventsource": "^3.0.2",
52 "eventsource-parser": "^3.0.0",
53 "express": "^5.2.1",
54 "express-rate-limit": "^8.2.1",
55 "hono": "^4.11.4",
56 "jose": "^6.1.3",
57 "json-schema-typed": "^8.0.2",
58 "pkce-challenge": "^5.0.0",
59 "raw-body": "^3.0.0",
60 "zod": "^3.25 || ^4.0",
61 "zod-to-json-schema": "^3.25.1"
62 },
63 "engines": {
64 "node": ">=18"
65 },
66 "peerDependencies": {
67 "@cfworker/json-schema": "^4.1.1",
68 "zod": "^3.25 || ^4.0"
69 },
70 "peerDependenciesMeta": {
71 "@cfworker/json-schema": {
72 "optional": true
73 },
74 "zod": {
75 "optional": false
76 }
77 }
78 },
79 "node_modules/@types/node": {
80 "version": "22.20.3",
81 "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.3.tgz",
82 "integrity": "sha512-DZmzkmwHzXrLPAXPyKNDzlIwMMUZCVacoD25ywdy5YTKGbOx/2ld+Q38Im2zJ0vBuZP5Prd3VZutKZyXwkOS8A==",
83 "dev": true,
84 "license": "MIT",
85 "dependencies": {
86 "undici-types": "~6.21.0"
87 }
88 },
89 "node_modules/@typesafe-ai/sdk": {
90 "version": "0.6.0",
91 "resolved": "https://registry.npmjs.org/@typesafe-ai/sdk/-/sdk-0.6.0.tgz",
92 "integrity": "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw==",
93 "license": "MIT",
94 "engines": {
95 "node": ">=20"
96 }
97 },
98 "node_modules/accepts": {
99 "version": "2.0.0",
100 "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
101 "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
102 "license": "MIT",
103 "dependencies": {
104 "mime-types": "^3.0.0",
105 "negotiator": "^1.0.0"
106 },
107 "engines": {
108 "node": ">= 0.6"
109 }
110 },
111 "node_modules/ajv": {
112 "version": "8.20.0",
113 "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
114 "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
115 "license": "MIT",
116 "dependencies": {
117 "fast-deep-equal": "^3.1.3",
118 "fast-uri": "^3.0.1",
119 "json-schema-traverse": "^1.0.0",
120 "require-from-string": "^2.0.2"
121 },
122 "funding": {
123 "type": "github",
124 "url": "https://github.com/sponsors/epoberezkin"
125 }
126 },
127 "node_modules/ajv-formats": {
128 "version": "3.0.1",
129 "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
130 "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
131 "license": "MIT",
132 "dependencies": {
133 "ajv": "^8.0.0"
134 },
135 "peerDependencies": {
136 "ajv": "^8.0.0"
137 },
138 "peerDependenciesMeta": {
139 "ajv": {
140 "optional": true
141 }
142 }
143 },
144 "node_modules/body-parser": {
145 "version": "2.3.0",
146 "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
147 "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
148 "license": "MIT",
149 "dependencies": {
150 "bytes": "^3.1.2",
151 "content-type": "^2.0.0",
152 "debug": "^4.4.3",
153 "http-errors": "^2.0.1",
154 "iconv-lite": "^0.7.2",
155 "on-finished": "^2.4.1",
156 "qs": "^6.15.2",
157 "raw-body": "^3.0.2",
158 "type-is": "^2.1.0"
159 },
160 "engines": {
161 "node": ">=18"
162 },
163 "funding": {
164 "type": "opencollective",
165 "url": "https://opencollective.com/express"
166 }
167 },
168 "node_modules/body-parser/node_modules/content-type": {
169 "version": "2.1.0",
170 "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
171 "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
172 "license": "MIT",
173 "engines": {
174 "node": ">=18"
175 },
176 "funding": {
177 "type": "opencollective",
178 "url": "https://opencollective.com/express"
179 }
180 },
181 "node_modules/bytes": {
182 "version": "3.1.2",
183 "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
184 "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
185 "license": "MIT",
186 "engines": {
187 "node": ">= 0.8"
188 }
189 },
190 "node_modules/call-bind-apply-helpers": {
191 "version": "1.0.2",
192 "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
193 "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
194 "license": "MIT",
195 "dependencies": {
196 "es-errors": "^1.3.0",
197 "function-bind": "^1.1.2"
198 },
199 "engines": {
200 "node": ">= 0.4"
201 }
202 },
203 "node_modules/call-bound": {
204 "version": "1.0.4",
205 "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
206 "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
207 "license": "MIT",
208 "dependencies": {
209 "call-bind-apply-helpers": "^1.0.2",
210 "get-intrinsic": "^1.3.0"
211 },
212 "engines": {
213 "node": ">= 0.4"
214 },
215 "funding": {
216 "url": "https://github.com/sponsors/ljharb"
217 }
218 },
219 "node_modules/content-disposition": {
220 "version": "1.1.0",
221 "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
222 "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
223 "license": "MIT",
224 "engines": {
225 "node": ">=18"
226 },
227 "funding": {
228 "type": "opencollective",
229 "url": "https://opencollective.com/express"
230 }
231 },
232 "node_modules/content-type": {
233 "version": "1.0.5",
234 "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
235 "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
236 "license": "MIT",
237 "engines": {
238 "node": ">= 0.6"
239 }
240 },
241 "node_modules/cookie": {
242 "version": "0.7.2",
243 "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
244 "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
245 "license": "MIT",
246 "engines": {
247 "node": ">= 0.6"
248 }
249 },
250 "node_modules/cookie-signature": {
251 "version": "1.2.2",
252 "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
253 "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
254 "license": "MIT",
255 "engines": {
256 "node": ">=6.6.0"
257 }
258 },
259 "node_modules/cors": {
260 "version": "2.8.6",
261 "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
262 "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
263 "license": "MIT",
264 "dependencies": {
265 "object-assign": "^4",
266 "vary": "^1"
267 },
268 "engines": {
269 "node": ">= 0.10"
270 },
271 "funding": {
272 "type": "opencollective",
273 "url": "https://opencollective.com/express"
274 }
275 },
276 "node_modules/cross-spawn": {
277 "version": "7.0.6",
278 "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
279 "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
280 "license": "MIT",
281 "dependencies": {
282 "path-key": "^3.1.0",
283 "shebang-command": "^2.0.0",
284 "which": "^2.0.1"
285 },
286 "engines": {
287 "node": ">= 8"
288 }
289 },
290 "node_modules/debug": {
291 "version": "4.4.3",
292 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
293 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
294 "license": "MIT",
295 "dependencies": {
296 "ms": "^2.1.3"
297 },
298 "engines": {
299 "node": ">=6.0"
300 },
301 "peerDependenciesMeta": {
302 "supports-color": {
303 "optional": true
304 }
305 }
306 },
307 "node_modules/depd": {
308 "version": "2.0.0",
309 "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
310 "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
311 "license": "MIT",
312 "engines": {
313 "node": ">= 0.8"
314 }
315 },
316 "node_modules/dunder-proto": {
317 "version": "1.0.1",
318 "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
319 "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
320 "license": "MIT",
321 "dependencies": {
322 "call-bind-apply-helpers": "^1.0.1",
323 "es-errors": "^1.3.0",
324 "gopd": "^1.2.0"
325 },
326 "engines": {
327 "node": ">= 0.4"
328 }
329 },
330 "node_modules/ee-first": {
331 "version": "1.1.1",
332 "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
333 "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
334 "license": "MIT"
335 },
336 "node_modules/encodeurl": {
337 "version": "2.0.0",
338 "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
339 "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
340 "license": "MIT",
341 "engines": {
342 "node": ">= 0.8"
343 }
344 },
345 "node_modules/es-define-property": {
346 "version": "1.0.1",
347 "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
348 "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
349 "license": "MIT",
350 "engines": {
351 "node": ">= 0.4"
352 }
353 },
354 "node_modules/es-errors": {
355 "version": "1.3.0",
356 "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
357 "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
358 "license": "MIT",
359 "engines": {
360 "node": ">= 0.4"
361 }
362 },
363 "node_modules/es-object-atoms": {
364 "version": "1.1.2",
365 "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
366 "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
367 "license": "MIT",
368 "dependencies": {
369 "es-errors": "^1.3.0"
370 },
371 "engines": {
372 "node": ">= 0.4"
373 }
374 },
375 "node_modules/escape-html": {
376 "version": "1.0.3",
377 "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
378 "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
379 "license": "MIT"
380 },
381 "node_modules/etag": {
382 "version": "1.8.1",
383 "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
384 "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
385 "license": "MIT",
386 "engines": {
387 "node": ">= 0.6"
388 }
389 },
390 "node_modules/eventsource": {
391 "version": "3.0.7",
392 "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
393 "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
394 "license": "MIT",
395 "dependencies": {
396 "eventsource-parser": "^3.0.1"
397 },
398 "engines": {
399 "node": ">=18.0.0"
400 }
401 },
402 "node_modules/eventsource-parser": {
403 "version": "3.1.1",
404 "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz",
405 "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==",
406 "license": "MIT",
407 "engines": {
408 "node": ">=18.0.0"
409 }
410 },
411 "node_modules/express": {
412 "version": "5.2.1",
413 "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
414 "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
415 "license": "MIT",
416 "dependencies": {
417 "accepts": "^2.0.0",
418 "body-parser": "^2.2.1",
419 "content-disposition": "^1.0.0",
420 "content-type": "^1.0.5",
421 "cookie": "^0.7.1",
422 "cookie-signature": "^1.2.1",
423 "debug": "^4.4.0",
424 "depd": "^2.0.0",
425 "encodeurl": "^2.0.0",
426 "escape-html": "^1.0.3",
427 "etag": "^1.8.1",
428 "finalhandler": "^2.1.0",
429 "fresh": "^2.0.0",
430 "http-errors": "^2.0.0",
431 "merge-descriptors": "^2.0.0",
432 "mime-types": "^3.0.0",
433 "on-finished": "^2.4.1",
434 "once": "^1.4.0",
435 "parseurl": "^1.3.3",
436 "proxy-addr": "^2.0.7",
437 "qs": "^6.14.0",
438 "range-parser": "^1.2.1",
439 "router": "^2.2.0",
440 "send": "^1.1.0",
441 "serve-static": "^2.2.0",
442 "statuses": "^2.0.1",
443 "type-is": "^2.0.1",
444 "vary": "^1.1.2"
445 },
446 "engines": {
447 "node": ">= 18"
448 },
449 "funding": {
450 "type": "opencollective",
451 "url": "https://opencollective.com/express"
452 }
453 },
454 "node_modules/express-rate-limit": {
455 "version": "8.7.0",
456 "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz",
457 "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==",
458 "license": "MIT",
459 "dependencies": {
460 "debug": "^4.4.3",
461 "ip-address": "^10.2.0"
462 },
463 "engines": {
464 "node": ">= 16"
465 },
466 "funding": {
467 "url": "https://github.com/sponsors/express-rate-limit"
468 },
469 "peerDependencies": {
470 "express": ">= 4.11"
471 }
472 },
473 "node_modules/fast-deep-equal": {
474 "version": "3.1.3",
475 "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
476 "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
477 "license": "MIT"
478 },
479 "node_modules/fast-uri": {
480 "version": "3.1.8",
481 "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
482 "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
483 "funding": [
484 {
485 "type": "github",
486 "url": "https://github.com/sponsors/fastify"
487 },
488 {
489 "type": "opencollective",
490 "url": "https://opencollective.com/fastify"
491 }
492 ],
493 "license": "BSD-3-Clause"
494 },
495 "node_modules/finalhandler": {
496 "version": "2.1.1",
497 "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
498 "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
499 "license": "MIT",
500 "dependencies": {
501 "debug": "^4.4.0",
502 "encodeurl": "^2.0.0",
503 "escape-html": "^1.0.3",
504 "on-finished": "^2.4.1",
505 "parseurl": "^1.3.3",
506 "statuses": "^2.0.1"
507 },
508 "engines": {
509 "node": ">= 18.0.0"
510 },
511 "funding": {
512 "type": "opencollective",
513 "url": "https://opencollective.com/express"
514 }
515 },
516 "node_modules/forwarded": {
517 "version": "0.2.0",
518 "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
519 "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
520 "license": "MIT",
521 "engines": {
522 "node": ">= 0.6"
523 }
524 },
525 "node_modules/fresh": {
526 "version": "2.0.0",
527 "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
528 "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
529 "license": "MIT",
530 "engines": {
531 "node": ">= 0.8"
532 }
533 },
534 "node_modules/function-bind": {
535 "version": "1.1.2",
536 "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
537 "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
538 "license": "MIT",
539 "funding": {
540 "url": "https://github.com/sponsors/ljharb"
541 }
542 },
543 "node_modules/get-intrinsic": {
544 "version": "1.3.0",
545 "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
546 "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
547 "license": "MIT",
548 "dependencies": {
549 "call-bind-apply-helpers": "^1.0.2",
550 "es-define-property": "^1.0.1",
551 "es-errors": "^1.3.0",
552 "es-object-atoms": "^1.1.1",
553 "function-bind": "^1.1.2",
554 "get-proto": "^1.0.1",
555 "gopd": "^1.2.0",
556 "has-symbols": "^1.1.0",
557 "hasown": "^2.0.2",
558 "math-intrinsics": "^1.1.0"
559 },
560 "engines": {
561 "node": ">= 0.4"
562 },
563 "funding": {
564 "url": "https://github.com/sponsors/ljharb"
565 }
566 },
567 "node_modules/get-proto": {
568 "version": "1.0.1",
569 "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
570 "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
571 "license": "MIT",
572 "dependencies": {
573 "dunder-proto": "^1.0.1",
574 "es-object-atoms": "^1.0.0"
575 },
576 "engines": {
577 "node": ">= 0.4"
578 }
579 },
580 "node_modules/gopd": {
581 "version": "1.2.0",
582 "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
583 "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
584 "license": "MIT",
585 "engines": {
586 "node": ">= 0.4"
587 },
588 "funding": {
589 "url": "https://github.com/sponsors/ljharb"
590 }
591 },
592 "node_modules/has-symbols": {
593 "version": "1.1.0",
594 "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
595 "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
596 "license": "MIT",
597 "engines": {
598 "node": ">= 0.4"
599 },
600 "funding": {
601 "url": "https://github.com/sponsors/ljharb"
602 }
603 },
604 "node_modules/hasown": {
605 "version": "2.0.4",
606 "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
607 "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
608 "license": "MIT",
609 "dependencies": {
610 "function-bind": "^1.1.2"
611 },
612 "engines": {
613 "node": ">= 0.4"
614 }
615 },
616 "node_modules/hono": {
617 "version": "4.13.8",
618 "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.8.tgz",
619 "integrity": "sha512-/Gng7NfoykZl2pjukW5Z6+8Yxm3BPRf86GTbQnt0SbySkvax4fyL4H3HhY1cCpBGmiW9XDRFzRV+CXK2W8QudQ==",
620 "license": "MIT",
621 "engines": {
622 "node": ">=16.9.0"
623 }
624 },
625 "node_modules/http-errors": {
626 "version": "2.0.1",
627 "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
628 "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
629 "license": "MIT",
630 "dependencies": {
631 "depd": "~2.0.0",
632 "inherits": "~2.0.4",
633 "setprototypeof": "~1.2.0",
634 "statuses": "~2.0.2",
635 "toidentifier": "~1.0.1"
636 },
637 "engines": {
638 "node": ">= 0.8"
639 },
640 "funding": {
641 "type": "opencollective",
642 "url": "https://opencollective.com/express"
643 }
644 },
645 "node_modules/iconv-lite": {
646 "version": "0.7.3",
647 "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
648 "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
649 "license": "MIT",
650 "dependencies": {
651 "safer-buffer": ">= 2.1.2 < 3.0.0"
652 },
653 "engines": {
654 "node": ">=0.10.0"
655 },
656 "funding": {
657 "type": "opencollective",
658 "url": "https://opencollective.com/express"
659 }
660 },
661 "node_modules/inherits": {
662 "version": "2.0.4",
663 "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
664 "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
665 "license": "ISC"
666 },
667 "node_modules/ip-address": {
668 "version": "10.7.2",
669 "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
670 "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
671 "license": "MIT",
672 "engines": {
673 "node": ">= 12"
674 }
675 },
676 "node_modules/ipaddr.js": {
677 "version": "1.9.1",
678 "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
679 "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
680 "license": "MIT",
681 "engines": {
682 "node": ">= 0.10"
683 }
684 },
685 "node_modules/is-promise": {
686 "version": "4.0.0",
687 "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
688 "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
689 "license": "MIT"
690 },
691 "node_modules/isexe": {
692 "version": "2.0.0",
693 "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
694 "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
695 "license": "ISC"
696 },
697 "node_modules/jose": {
698 "version": "6.2.12",
699 "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz",
700 "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==",
701 "license": "MIT",
702 "funding": {
703 "url": "https://github.com/sponsors/panva"
704 }
705 },
706 "node_modules/json-schema-traverse": {
707 "version": "1.0.0",
708 "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
709 "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
710 "license": "MIT"
711 },
712 "node_modules/json-schema-typed": {
713 "version": "8.0.2",
714 "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
715 "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
716 "license": "BSD-2-Clause"
717 },
718 "node_modules/math-intrinsics": {
719 "version": "1.1.0",
720 "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
721 "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
722 "license": "MIT",
723 "engines": {
724 "node": ">= 0.4"
725 }
726 },
727 "node_modules/media-typer": {
728 "version": "1.1.1",
729 "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz",
730 "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==",
731 "license": "MIT",
732 "engines": {
733 "node": ">= 0.8"
734 },
735 "funding": {
736 "type": "opencollective",
737 "url": "https://opencollective.com/express"
738 }
739 },
740 "node_modules/merge-descriptors": {
741 "version": "2.0.0",
742 "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
743 "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
744 "license": "MIT",
745 "engines": {
746 "node": ">=18"
747 },
748 "funding": {
749 "url": "https://github.com/sponsors/sindresorhus"
750 }
751 },
752 "node_modules/mime-db": {
753 "version": "1.54.0",
754 "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
755 "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
756 "license": "MIT",
757 "engines": {
758 "node": ">= 0.6"
759 }
760 },
761 "node_modules/mime-types": {
762 "version": "3.0.2",
763 "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
764 "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
765 "license": "MIT",
766 "dependencies": {
767 "mime-db": "^1.54.0"
768 },
769 "engines": {
770 "node": ">=18"
771 },
772 "funding": {
773 "type": "opencollective",
774 "url": "https://opencollective.com/express"
775 }
776 },
777 "node_modules/ms": {
778 "version": "2.1.3",
779 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
780 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
781 "license": "MIT"
782 },
783 "node_modules/negotiator": {
784 "version": "1.1.0",
785 "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz",
786 "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==",
787 "license": "MIT",
788 "dependencies": {
789 "content-type": "^2.1.0"
790 },
791 "engines": {
792 "node": ">=18"
793 },
794 "funding": {
795 "type": "opencollective",
796 "url": "https://opencollective.com/express"
797 }
798 },
799 "node_modules/negotiator/node_modules/content-type": {
800 "version": "2.1.0",
801 "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
802 "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
803 "license": "MIT",
804 "engines": {
805 "node": ">=18"
806 },
807 "funding": {
808 "type": "opencollective",
809 "url": "https://opencollective.com/express"
810 }
811 },
812 "node_modules/object-assign": {
813 "version": "4.1.1",
814 "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
815 "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
816 "license": "MIT",
817 "engines": {
818 "node": ">=0.10.0"
819 }
820 },
821 "node_modules/object-inspect": {
822 "version": "1.13.4",
823 "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
824 "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
825 "license": "MIT",
826 "engines": {
827 "node": ">= 0.4"
828 },
829 "funding": {
830 "url": "https://github.com/sponsors/ljharb"
831 }
832 },
833 "node_modules/on-finished": {
834 "version": "2.4.1",
835 "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
836 "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
837 "license": "MIT",
838 "dependencies": {
839 "ee-first": "1.1.1"
840 },
841 "engines": {
842 "node": ">= 0.8"
843 }
844 },
845 "node_modules/once": {
846 "version": "1.4.0",
847 "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
848 "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
849 "license": "ISC",
850 "dependencies": {
851 "wrappy": "1"
852 }
853 },
854 "node_modules/parseurl": {
855 "version": "1.3.3",
856 "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
857 "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
858 "license": "MIT",
859 "engines": {
860 "node": ">= 0.8"
861 }
862 },
863 "node_modules/path-key": {
864 "version": "3.1.1",
865 "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
866 "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
867 "license": "MIT",
868 "engines": {
869 "node": ">=8"
870 }
871 },
872 "node_modules/path-to-regexp": {
873 "version": "8.4.2",
874 "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
875 "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
876 "license": "MIT",
877 "funding": {
878 "type": "opencollective",
879 "url": "https://opencollective.com/express"
880 }
881 },
882 "node_modules/pkce-challenge": {
883 "version": "5.0.1",
884 "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
885 "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
886 "license": "MIT",
887 "engines": {
888 "node": ">=16.20.0"
889 }
890 },
891 "node_modules/proxy-addr": {
892 "version": "2.0.8",
893 "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
894 "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
895 "license": "MIT",
896 "dependencies": {
897 "forwarded": "0.2.0",
898 "ipaddr.js": "1.9.1"
899 },
900 "engines": {
901 "node": ">= 0.10"
902 },
903 "funding": {
904 "type": "opencollective",
905 "url": "https://opencollective.com/express"
906 }
907 },
908 "node_modules/qs": {
909 "version": "6.16.0",
910 "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
911 "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
912 "license": "BSD-3-Clause",
913 "dependencies": {
914 "es-define-property": "^1.0.1",
915 "side-channel": "^1.1.1"
916 },
917 "engines": {
918 "node": ">=0.6"
919 },
920 "funding": {
921 "url": "https://github.com/sponsors/ljharb"
922 }
923 },
924 "node_modules/range-parser": {
925 "version": "1.3.0",
926 "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
927 "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
928 "license": "MIT",
929 "engines": {
930 "node": ">= 0.6"
931 },
932 "funding": {
933 "type": "opencollective",
934 "url": "https://opencollective.com/express"
935 }
936 },
937 "node_modules/raw-body": {
938 "version": "3.0.2",
939 "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
940 "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
941 "license": "MIT",
942 "dependencies": {
943 "bytes": "~3.1.2",
944 "http-errors": "~2.0.1",
945 "iconv-lite": "~0.7.0",
946 "unpipe": "~1.0.0"
947 },
948 "engines": {
949 "node": ">= 0.10"
950 }
951 },
952 "node_modules/require-from-string": {
953 "version": "2.0.2",
954 "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
955 "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
956 "license": "MIT",
957 "engines": {
958 "node": ">=0.10.0"
959 }
960 },
961 "node_modules/router": {
962 "version": "2.2.0",
963 "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
964 "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
965 "license": "MIT",
966 "dependencies": {
967 "debug": "^4.4.0",
968 "depd": "^2.0.0",
969 "is-promise": "^4.0.0",
970 "parseurl": "^1.3.3",
971 "path-to-regexp": "^8.0.0"
972 },
973 "engines": {
974 "node": ">= 18"
975 }
976 },
977 "node_modules/safer-buffer": {
978 "version": "2.1.2",
979 "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
980 "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
981 "license": "MIT"
982 },
983 "node_modules/send": {
984 "version": "1.2.1",
985 "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
986 "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
987 "license": "MIT",
988 "dependencies": {
989 "debug": "^4.4.3",
990 "encodeurl": "^2.0.0",
991 "escape-html": "^1.0.3",
992 "etag": "^1.8.1",
993 "fresh": "^2.0.0",
994 "http-errors": "^2.0.1",
995 "mime-types": "^3.0.2",
996 "ms": "^2.1.3",
997 "on-finished": "^2.4.1",
998 "range-parser": "^1.2.1",
999 "statuses": "^2.0.2"
1000 },
1001 "engines": {
1002 "node": ">= 18"
1003 },
1004 "funding": {
1005 "type": "opencollective",
1006 "url": "https://opencollective.com/express"
1007 }
1008 },
1009 "node_modules/serve-static": {
1010 "version": "2.2.1",
1011 "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
1012 "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
1013 "license": "MIT",
1014 "dependencies": {
1015 "encodeurl": "^2.0.0",
1016 "escape-html": "^1.0.3",
1017 "parseurl": "^1.3.3",
1018 "send": "^1.2.0"
1019 },
1020 "engines": {
1021 "node": ">= 18"
1022 },
1023 "funding": {
1024 "type": "opencollective",
1025 "url": "https://opencollective.com/express"
1026 }
1027 },
1028 "node_modules/setprototypeof": {
1029 "version": "1.2.0",
1030 "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
1031 "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
1032 "license": "ISC"
1033 },
1034 "node_modules/shebang-command": {
1035 "version": "2.0.0",
1036 "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
1037 "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
1038 "license": "MIT",
1039 "dependencies": {
1040 "shebang-regex": "^3.0.0"
1041 },
1042 "engines": {
1043 "node": ">=8"
1044 }
1045 },
1046 "node_modules/shebang-regex": {
1047 "version": "3.0.0",
1048 "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
1049 "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
1050 "license": "MIT",
1051 "engines": {
1052 "node": ">=8"
1053 }
1054 },
1055 "node_modules/side-channel": {
1056 "version": "1.1.1",
1057 "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
1058 "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
1059 "license": "MIT",
1060 "dependencies": {
1061 "es-errors": "^1.3.0",
1062 "object-inspect": "^1.13.4",
1063 "side-channel-list": "^1.0.1",
1064 "side-channel-map": "^1.0.1",
1065 "side-channel-weakmap": "^1.0.2"
1066 },
1067 "engines": {
1068 "node": ">= 0.4"
1069 },
1070 "funding": {
1071 "url": "https://github.com/sponsors/ljharb"
1072 }
1073 },
1074 "node_modules/side-channel-list": {
1075 "version": "1.0.1",
1076 "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
1077 "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
1078 "license": "MIT",
1079 "dependencies": {
1080 "es-errors": "^1.3.0",
1081 "object-inspect": "^1.13.4"
1082 },
1083 "engines": {
1084 "node": ">= 0.4"
1085 },
1086 "funding": {
1087 "url": "https://github.com/sponsors/ljharb"
1088 }
1089 },
1090 "node_modules/side-channel-map": {
1091 "version": "1.0.1",
1092 "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
1093 "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
1094 "license": "MIT",
1095 "dependencies": {
1096 "call-bound": "^1.0.2",
1097 "es-errors": "^1.3.0",
1098 "get-intrinsic": "^1.2.5",
1099 "object-inspect": "^1.13.3"
1100 },
1101 "engines": {
1102 "node": ">= 0.4"
1103 },
1104 "funding": {
1105 "url": "https://github.com/sponsors/ljharb"
1106 }
1107 },
1108 "node_modules/side-channel-weakmap": {
1109 "version": "1.0.2",
1110 "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
1111 "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
1112 "license": "MIT",
1113 "dependencies": {
1114 "call-bound": "^1.0.2",
1115 "es-errors": "^1.3.0",
1116 "get-intrinsic": "^1.2.5",
1117 "object-inspect": "^1.13.3",
1118 "side-channel-map": "^1.0.1"
1119 },
1120 "engines": {
1121 "node": ">= 0.4"
1122 },
1123 "funding": {
1124 "url": "https://github.com/sponsors/ljharb"
1125 }
1126 },
1127 "node_modules/statuses": {
1128 "version": "2.0.2",
1129 "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
1130 "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
1131 "license": "MIT",
1132 "engines": {
1133 "node": ">= 0.8"
1134 }
1135 },
1136 "node_modules/toidentifier": {
1137 "version": "1.0.1",
1138 "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
1139 "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
1140 "license": "MIT",
1141 "engines": {
1142 "node": ">=0.6"
1143 }
1144 },
1145 "node_modules/type-is": {
1146 "version": "2.1.0",
1147 "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
1148 "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
1149 "license": "MIT",
1150 "dependencies": {
1151 "content-type": "^2.0.0",
1152 "media-typer": "^1.1.0",
1153 "mime-types": "^3.0.0"
1154 },
1155 "engines": {
1156 "node": ">= 18"
1157 },
1158 "funding": {
1159 "type": "opencollective",
1160 "url": "https://opencollective.com/express"
1161 }
1162 },
1163 "node_modules/type-is/node_modules/content-type": {
1164 "version": "2.1.0",
1165 "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz",
1166 "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==",
1167 "license": "MIT",
1168 "engines": {
1169 "node": ">=18"
1170 },
1171 "funding": {
1172 "type": "opencollective",
1173 "url": "https://opencollective.com/express"
1174 }
1175 },
1176 "node_modules/typescript": {
1177 "version": "5.9.3",
1178 "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
1179 "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
1180 "dev": true,
1181 "license": "Apache-2.0",
1182 "bin": {
1183 "tsc": "bin/tsc",
1184 "tsserver": "bin/tsserver"
1185 },
1186 "engines": {
1187 "node": ">=14.17"
1188 }
1189 },
1190 "node_modules/undici-types": {
1191 "version": "6.21.0",
1192 "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
1193 "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
1194 "dev": true,
1195 "license": "MIT"
1196 },
1197 "node_modules/unpipe": {
1198 "version": "1.0.0",
1199 "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
1200 "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
1201 "license": "MIT",
1202 "engines": {
1203 "node": ">= 0.8"
1204 }
1205 },
1206 "node_modules/vary": {
1207 "version": "1.1.2",
1208 "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
1209 "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
1210 "license": "MIT",
1211 "engines": {
1212 "node": ">= 0.8"
1213 }
1214 },
1215 "node_modules/which": {
1216 "version": "2.0.2",
1217 "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
1218 "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
1219 "license": "ISC",
1220 "dependencies": {
1221 "isexe": "^2.0.0"
1222 },
1223 "bin": {
1224 "node-which": "bin/node-which"
1225 },
1226 "engines": {
1227 "node": ">= 8"
1228 }
1229 },
1230 "node_modules/wrappy": {
1231 "version": "1.0.2",
1232 "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
1233 "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
1234 "license": "ISC"
1235 },
1236 "node_modules/zod": {
1237 "version": "3.25.76",
1238 "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
1239 "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
1240 "license": "MIT",
1241 "funding": {
1242 "url": "https://github.com/sponsors/colinhacks"
1243 }
1244 },
1245 "node_modules/zod-to-json-schema": {
1246 "version": "3.25.2",
1247 "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
1248 "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
1249 "license": "ISC",
1250 "peerDependencies": {
1251 "zod": "^3.25.28 || ^4"
1252 }
1253 }
1254 }
1255 }
package.json added +45
@@ -0,0 +1,45 @@
1 {
2 "name": "jev-mcp",
3 "version": "0.2.0",
4 "description": "MCP server exposing TypeSafe Jev typed judgments: classify, score, check, a batched multi-question ask, and model discovery.",
5 "type": "module",
6 "license": "MIT",
7 "bin": {
8 "jev-mcp": "./dist/index.js"
9 },
10 "files": [
11 "dist",
12 "README.md",
13 "LICENSE"
14 ],
15 "keywords": [
16 "mcp",
17 "model-context-protocol",
18 "typesafe",
19 "jev",
20 "system-one",
21 "classification",
22 "confidence",
23 "ai"
24 ],
25 "scripts": {
26 "build": "tsc",
27 "prepare": "npm run build",
28 "start": "node dist/index.js",
29 "typecheck": "tsc --noEmit",
30 "test": "node --test test/*.test.mjs",
31 "test:e2e": "node --test test/e2e.test.mjs"
32 },
33 "dependencies": {
34 "@modelcontextprotocol/sdk": "^1.30.0",
35 "@typesafe-ai/sdk": "^0.6.0",
36 "zod": "^3.25.0"
37 },
38 "devDependencies": {
39 "@types/node": "^22.0.0",
40 "typescript": "^5.6.0"
41 },
42 "engines": {
43 "node": ">=20.12"
44 }
45 }
src/index.ts added +471
@@ -0,0 +1,471 @@
1 #!/usr/bin/env node
2 /**
3 * jev-mcp — MCP server exposing TypeSafe Jev as typed judgment tools.
4 *
5 * Jev is a System One model: it returns a typed answer plus a calibrated
6 * probability distribution, never prose. These tools surface that faithfully.
7 *
8 * Rules that shape the whole surface:
9 *
10 * 1. The caller owns the option set. Every selecting tool requires options
11 * supplied by the caller, so the model can pick the wrong one but can never
12 * invent one. A selector cannot choose a candidate the enumerator dropped.
13 * 2. Probabilities are always returned, never just the label.
14 * 3. The API key comes from the environment only, never a tool argument.
15 * 4. Nothing is silently dropped, overwritten, or truncated. A request that
16 * cannot be honoured exactly fails with a reason.
17 * 5. Nothing but JSON-RPC is ever written to stdout.
18 */
19
20 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
22 import { choice, noul, score, TypeSafeClient } from "@typesafe-ai/sdk";
23 import type { EntryType, Question, ScoreCriteria } from "@typesafe-ai/sdk";
24 import { z } from "zod";
25 import { readFileSync } from "node:fs";
26 import { homedir } from "node:os";
27 import { join } from "node:path";
28 import {
29 assertStateWithinLimit,
30 assertUniqueIds,
31 buildChoiceCriteria,
32 DEFAULT_MAX_QUESTIONS,
33 DEFAULT_MAX_SCORE_LEVELS,
34 DEFAULT_MAX_STATE_CHARS,
35 DEFAULT_TIMEOUT_MS,
36 describeError,
37 gateConfidence,
38 gateProbability,
39 readPositiveInt,
40 } from "./lib.js";
41
42 const VERSION = "0.2.0";
43
44 // ── Configuration ───────────────────────────────────────────────────────────
45
46 const MODEL = process.env.JEV_MODEL ?? "jev-latest";
47
48 /**
49 * Every log line goes to stderr.
50 *
51 * The TypeSafe SDK's default logger uses `console.info` and `console.debug`,
52 * which write to stdout. On a stdio transport stdout carries JSON-RPC, so a
53 * single verbose log line corrupts the stream and kills the connection.
54 * Routing all four levels to stderr makes any TYPESAFE_LOG_LEVEL safe.
55 */
56 const stderrLogger = {
57 debug: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
58 info: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
59 warn: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
60 error: (message: string, ...args: unknown[]) => console.error("[typesafe-sdk]", message, ...args),
61 };
62
63 const timeout = readPositiveInt(process.env.JEV_TIMEOUT_MS, DEFAULT_TIMEOUT_MS, "JEV_TIMEOUT_MS");
64 const maxQuestions = readPositiveInt(process.env.JEV_MAX_QUESTIONS, DEFAULT_MAX_QUESTIONS, "JEV_MAX_QUESTIONS");
65 const maxStateChars = readPositiveInt(process.env.JEV_MAX_STATE_CHARS, DEFAULT_MAX_STATE_CHARS, "JEV_MAX_STATE_CHARS");
66 for (const setting of [timeout, maxQuestions, maxStateChars]) {
67 if (setting.warning) console.error(`[jev-mcp] ${setting.warning}`);
68 }
69
70 // ── Shared schema pieces ────────────────────────────────────────────────────
71
72 /**
73 * State accepted by Jev: plain text, or structured data for records and logs.
74 *
75 * `any` rather than `unknown` on purpose: the SDK constrains state to JsonValue
76 * and `unknown` members are not assignable to it. The JSON Schema is identical.
77 */
78 const StateSchema = z
79 .union([z.string(), z.record(z.any()), z.array(z.any())])
80 .describe("The content to evaluate. A plain string for text, or an object/array for structured data such as a record, a diff, or a chat log.");
81
82 /** Instructions accept JSON structure, which helps when a question has parts. */
83 const InstructionSchema = z
84 .union([z.string().min(1), z.record(z.any()), z.array(z.any())])
85 .describe("The judgment to make. A string, or an object/array when the question has several labelled parts. This is the only instruction Jev sees, so state it in full.");
86
87 /** Option and level descriptions accept the same JSON structure. */
88 const DescriptionSchema = z.union([z.string(), z.record(z.any()), z.array(z.any()), z.null()]);
89
90 const UsageSchema = z.object({ input_tokens: z.number(), output_tokens: z.number() });
91 const GateSchema = z.enum(["act", "review", "abstain"]);
92
93 const ActAbove = z
94 .number()
95 .min(0)
96 .max(1)
97 .optional()
98 .describe("Confidence at or above which the answer is marked 'act'. Default 0.8. Calibrate on your own data and the cost of being wrong.");
99 const ReviewAbove = z
100 .number()
101 .min(0)
102 .max(1)
103 .optional()
104 .describe("Confidence at or above which the answer is marked 'review' rather than 'abstain'. Default 0.5.");
105
106 // ── Client ──────────────────────────────────────────────────────────────────
107
108 let client: TypeSafeClient | undefined;
109
110 /**
111 * Fallback source for the key.
112 *
113 * An MCP server is spawned with the client's own environment, not your shell's,
114 * so anything exported from `~/.zshenv` never arrives. Confirmed on the wire: a
115 * PreToolUse hook under the same client received `JEV_GUARD`, which is injected
116 * through settings `env`, but not `TYPESAFE_API_KEY`, which lives only in the
117 * shell profile.
118 *
119 * A 0600 file reaches every spawn path while keeping the secret out of
120 * `~/.claude.json`, out of argv, and out of any repository.
121 */
122 const KEY_FILE = process.env.JEV_KEY_FILE ?? join(homedir(), ".config", "typesafe", "key");
123
124 function readKeyFile(): string | undefined {
125 try {
126 const contents = readFileSync(KEY_FILE, "utf8").trim();
127 return contents.length > 0 ? contents : undefined;
128 } catch {
129 return undefined;
130 }
131 }
132
133 /**
134 * Built lazily: the constructor throws without a key, and a missing key should
135 * produce one clear tool error rather than stop the server from starting.
136 */
137 function getClient(): TypeSafeClient {
138 const apiKey = process.env.TYPESAFE_API_KEY ?? process.env.JEV_API_KEY ?? readKeyFile();
139 if (!apiKey) {
140 throw new Error(
141 `No API key. Set TYPESAFE_API_KEY in the environment of the MCP client, or create ${KEY_FILE} with mode 0600. Never pass it as a tool argument.`,
142 );
143 }
144 client ??= new TypeSafeClient({ apiKey, timeout: timeout.value, logger: stderrLogger });
145 return client;
146 }
147
148 // ── Result helpers ──────────────────────────────────────────────────────────
149
150 /**
151 * Tool results carry both a text block and structured content: the text keeps
152 * older clients working, the structured payload is validated against the tool's
153 * output schema so a caller gets typed data instead of a JSON string to parse.
154 */
155 function ok<T extends Record<string, unknown>>(payload: T) {
156 return {
157 content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }],
158 structuredContent: payload,
159 };
160 }
161
162 function fail(error: unknown) {
163 const described = describeError(error);
164 return {
165 isError: true,
166 content: [{ type: "text" as const, text: JSON.stringify({ error: described }, null, 2) }],
167 };
168 }
169
170 const server = new McpServer({ name: "jev", version: VERSION });
171
172 // ── classify: one of a defined set ─────────────────────────────────────────
173
174 server.registerTool(
175 "jev_classify",
176 {
177 title: "Classify into one of your options",
178 description:
179 "Pick exactly one option from a set you define. Returns the chosen option, the probability of every option, a confidence value, and a recommended action gated on confidence. " +
180 "Use when the answer is one of a fixed set. The options must be supplied by you: Jev selects among them and cannot invent a new one. Up to 255 options.",
181 inputSchema: {
182 state: StateSchema,
183 question: InstructionSchema,
184 options: z
185 .record(DescriptionSchema)
186 .describe("Map of option name to a description that separates it from the others. Both the name and the description are sent to the model, so keep names short and distinct. A description may be an object or array when structure clarifies it, or null to leave it undescribed."),
187 add_none: z
188 .boolean()
189 .optional()
190 .describe("Add a no-match option meaning none of yours fits. Defaults to true. Turn off only when one option must always apply. If you already use the name 'none', the added option takes a different key and it is reported back as none_option."),
191 act_above: ActAbove,
192 review_above: ReviewAbove,
193 },
194 outputSchema: {
195 choice: z.string(),
196 confidence: z.number(),
197 probabilities: z.record(z.number()),
198 none_option: z.string().nullable().describe("The key carrying the no-match meaning, or null when none was added."),
199 action: GateSchema,
200 thresholds: z.object({ act_above: z.number(), review_above: z.number() }),
201 model: z.string(),
202 usage: UsageSchema,
203 },
204 },
205 async ({ state, question, options, add_none, act_above, review_above }, extra) => {
206 try {
207 assertStateWithinLimit(state, maxStateChars.value);
208 const actAbove = act_above ?? 0.8;
209 const reviewAbove = review_above ?? 0.5;
210 const { criteria, noneKey } = buildChoiceCriteria(options as Record<string, EntryType>, add_none !== false);
211
212 const result = await getClient().systemOne(
213 { state, model: MODEL, questions: { classify: choice(question, criteria) } },
214 { signal: extra.signal },
215 );
216 const answer = result.answers.classify;
217 return ok({
218 choice: answer.choice,
219 confidence: answer.confidence,
220 probabilities: answer.probabilities,
221 none_option: noneKey,
222 action: gateConfidence(answer.confidence, actAbove, reviewAbove),
223 thresholds: { act_above: actAbove, review_above: reviewAbove },
224 model: result.model,
225 usage: result.usage,
226 });
227 } catch (error) {
228 return fail(error);
229 }
230 },
231 );
232
233 // ── score: a position on an ordered scale ──────────────────────────────────
234
235 server.registerTool(
236 "jev_score",
237 {
238 title: "Rate on an ordered scale",
239 description:
240 "Rate the state along an ordered scale you define. Returns a probability-weighted score that can land between levels, the distribution, confidence, and a recommended action. " +
241 "Use for degree or severity, not for picking a category.",
242 inputSchema: {
243 state: StateSchema,
244 question: InstructionSchema,
245 levels: z
246 .array(DescriptionSchema)
247 .min(2)
248 .describe("Ordered level descriptions, lowest first. At least two. Each level must describe a concrete situation and stand on its own."),
249 act_above: ActAbove,
250 review_above: ReviewAbove,
251 },
252 outputSchema: {
253 score: z.number(),
254 confidence: z.number(),
255 probabilities: z.record(z.number()),
256 legend: z.record(z.any()),
257 action: GateSchema,
258 thresholds: z.object({ act_above: z.number(), review_above: z.number() }),
259 model: z.string(),
260 usage: UsageSchema,
261 },
262 },
263 async ({ state, question, levels, act_above, review_above }, extra) => {
264 try {
265 assertStateWithinLimit(state, maxStateChars.value);
266 if (levels.length > DEFAULT_MAX_SCORE_LEVELS) {
267 throw new Error(`levels must contain at most ${DEFAULT_MAX_SCORE_LEVELS} entries; received ${levels.length}.`);
268 }
269 const actAbove = act_above ?? 0.8;
270 const reviewAbove = review_above ?? 0.5;
271
272 const result = await getClient().systemOne(
273 // zod already enforces two or more levels; the SDK types that as a tuple.
274 { state, model: MODEL, questions: { rating: score(question, levels as unknown as ScoreCriteria) } },
275 { signal: extra.signal },
276 );
277 const answer = result.answers.rating;
278 return ok({
279 score: answer.score,
280 confidence: answer.confidence,
281 probabilities: answer.probabilities,
282 legend: answer.legend,
283 action: gateConfidence(answer.confidence, actAbove, reviewAbove),
284 thresholds: { act_above: actAbove, review_above: reviewAbove },
285 model: result.model,
286 usage: result.usage,
287 });
288 } catch (error) {
289 return fail(error);
290 }
291 },
292 );
293
294 // ── check: probability that a condition holds ──────────────────────────────
295
296 server.registerTool(
297 "jev_check",
298 {
299 title: "Yes/no with a probability",
300 description:
301 "Ask a yes/no question. Returns the probability that the answer is yes, from 0 to 1, plus a verdict. There is no separate confidence: a value near 0.5 means yes and no are close to equally likely, not that the answer is 'medium'. " +
302 "Use one check per label when several labels may apply at once.",
303 inputSchema: {
304 state: StateSchema,
305 question: InstructionSchema,
306 yes_means: DescriptionSchema.optional().describe("What a yes means. Sharpens the judgment."),
307 no_means: DescriptionSchema.optional().describe("What a no means."),
308 yes_at_or_above: z.number().min(0).max(1).optional().describe("Probability at or above which the verdict is 'yes'. Default 0.7."),
309 no_at_or_below: z.number().min(0).max(1).optional().describe("Probability at or below which the verdict is 'no'. Default 0.3. Between the two the verdict is 'uncertain'."),
310 },
311 outputSchema: {
312 probability_yes: z.number(),
313 verdict: z.enum(["yes", "no", "uncertain"]),
314 thresholds: z.object({ yes_at_or_above: z.number(), no_at_or_below: z.number() }),
315 model: z.string(),
316 usage: UsageSchema,
317 },
318 },
319 async ({ state, question, yes_means, no_means, yes_at_or_above, no_at_or_below }, extra) => {
320 try {
321 assertStateWithinLimit(state, maxStateChars.value);
322 const yesAt = yes_at_or_above ?? 0.7;
323 const noAt = no_at_or_below ?? 0.3;
324 if (noAt > yesAt) throw new Error("no_at_or_below must not exceed yes_at_or_above.");
325
326 const criteria =
327 yes_means !== undefined || no_means !== undefined
328 ? { true: yes_means ?? null, false: no_means ?? null }
329 : undefined;
330
331 const result = await getClient().systemOne(
332 { state, model: MODEL, questions: { check: criteria ? noul(question, criteria) : noul(question) } },
333 { signal: extra.signal },
334 );
335 const probability = result.answers.check.noul;
336 return ok({
337 probability_yes: probability,
338 verdict: gateProbability(probability, yesAt, noAt),
339 thresholds: { yes_at_or_above: yesAt, no_at_or_below: noAt },
340 model: result.model,
341 usage: result.usage,
342 });
343 } catch (error) {
344 return fail(error);
345 }
346 },
347 );
348
349 // ── ask: many questions about one state, in a single request ───────────────
350
351 server.registerTool(
352 "jev_ask",
353 {
354 title: "Ask many questions about one state",
355 description:
356 "Ask several independent questions about the same state in ONE request. Jev prefills the state once and scores every question in a single forward pass, so extra questions add almost no latency. " +
357 "Prefer this over repeated single-question calls: on a document-dominated workload it is dramatically cheaper and faster with no change in answers. " +
358 "Questions cannot see each other's answers, so state any speculative premise explicitly and let your own logic decide which answers apply.",
359 inputSchema: {
360 state: StateSchema,
361 questions: z
362 .array(
363 z.object({
364 id: z.string().min(1).describe("Your key for this question. Returned alongside the answer. Never sent to the model, so put the full meaning in the question itself. Must be unique within the call."),
365 type: z.enum(["classify", "score", "check"]),
366 question: InstructionSchema,
367 options: z.record(DescriptionSchema).optional().describe("Required for type 'classify'."),
368 add_none: z.boolean().optional().describe("For 'classify': add a no-match option. Defaults to true."),
369 levels: z.array(DescriptionSchema).min(2).optional().describe("Required for type 'score'."),
370 yes_means: DescriptionSchema.optional().describe("For 'check': what a yes means."),
371 no_means: DescriptionSchema.optional().describe("For 'check': what a no means."),
372 }),
373 )
374 .min(1),
375 act_above: ActAbove,
376 review_above: ReviewAbove,
377 },
378 outputSchema: {
379 answers: z.record(z.any()).describe("Keyed by your question ids. Choice and Score answers also carry an 'action' gated on confidence."),
380 none_options: z.record(z.string().nullable()).describe("For each 'classify' question, the key carrying the no-match meaning, or null."),
381 model: z.string(),
382 usage: UsageSchema,
383 },
384 },
385 async ({ state, questions, act_above, review_above }, extra) => {
386 try {
387 assertStateWithinLimit(state, maxStateChars.value);
388 if (questions.length > maxQuestions.value) {
389 throw new Error(`questions must contain at most ${maxQuestions.value} entries; received ${questions.length}. Raise JEV_MAX_QUESTIONS if that limit is wrong for your workload.`);
390 }
391 assertUniqueIds(questions.map((q) => q.id));
392 const actAbove = act_above ?? 0.8;
393 const reviewAbove = review_above ?? 0.5;
394
395 const built: Record<string, Question> = {};
396 const noneOptions: Record<string, string | null> = {};
397
398 for (const q of questions) {
399 if (q.type === "classify") {
400 if (!q.options) throw new Error(`Question '${q.id}' is type 'classify' and needs options.`);
401 const { criteria, noneKey } = buildChoiceCriteria(q.options as Record<string, EntryType>, q.add_none !== false, `options for '${q.id}'`);
402 built[q.id] = choice(q.question, criteria);
403 noneOptions[q.id] = noneKey;
404 } else if (q.type === "score") {
405 if (!q.levels) throw new Error(`Question '${q.id}' is type 'score' and needs levels.`);
406 if (q.levels.length > DEFAULT_MAX_SCORE_LEVELS) {
407 throw new Error(`levels for '${q.id}' must contain at most ${DEFAULT_MAX_SCORE_LEVELS} entries.`);
408 }
409 built[q.id] = score(q.question, q.levels as unknown as ScoreCriteria);
410 } else {
411 const criteria =
412 q.yes_means !== undefined || q.no_means !== undefined
413 ? { true: q.yes_means ?? null, false: q.no_means ?? null }
414 : undefined;
415 built[q.id] = criteria ? noul(q.question, criteria) : noul(q.question);
416 }
417 }
418
419 const result = await getClient().systemOne({ state, model: MODEL, questions: built }, { signal: extra.signal });
420
421 // Attach the confidence gate where there is a confidence to gate on.
422 const answers: Record<string, unknown> = {};
423 for (const [id, answer] of Object.entries(result.answers as unknown as Record<string, Record<string, unknown>>)) {
424 answers[id] =
425 typeof answer?.confidence === "number"
426 ? { ...answer, action: gateConfidence(answer.confidence, actAbove, reviewAbove) }
427 : answer;
428 }
429
430 return ok({ answers, none_options: noneOptions, model: result.model, usage: result.usage });
431 } catch (error) {
432 return fail(error);
433 }
434 },
435 );
436
437 // ── models: what this key can actually use ─────────────────────────────────
438
439 server.registerTool(
440 "jev_models",
441 {
442 title: "List available models",
443 description:
444 "List the models this API key can use, with their release dates. Use it to confirm the key works and to find a model id for JEV_MODEL before assuming one exists.",
445 inputSchema: {},
446 outputSchema: {
447 active_model: z.string().describe("The model these tools send requests to."),
448 models: z.array(z.object({ name: z.string(), description: z.string(), release_date: z.string() })),
449 },
450 },
451 async (_args, extra) => {
452 try {
453 const models = await getClient().models.list({ signal: extra.signal });
454 return ok({ active_model: MODEL, models });
455 } catch (error) {
456 return fail(error);
457 }
458 },
459 );
460
461 // ── Boot ────────────────────────────────────────────────────────────────────
462
463 async function main() {
464 await server.connect(new StdioServerTransport());
465 console.error(`[jev-mcp] ready — version ${VERSION}, model ${MODEL}`);
466 }
467
468 main().catch((error) => {
469 console.error("[jev-mcp] failed to start:", error);
470 process.exit(1);
471 });
src/lib.ts added +279
@@ -0,0 +1,279 @@
1 /**
2 * Pure helpers for jev-mcp. No network, no process state, no I/O.
3 *
4 * Everything here is deterministic so the edge cases that matter — a caller
5 * whose own option is named "none", duplicate question ids, a malformed
6 * timeout — are unit-testable without touching the API.
7 */
8
9 import {
10 APIConnectionError,
11 APIError,
12 APITimeoutError,
13 APIUserAbortError,
14 AuthenticationError,
15 BadRequestError,
16 NotFoundError,
17 PermissionDeniedError,
18 RateLimitError,
19 UnprocessableEntityError,
20 } from "@typesafe-ai/sdk";
21 import type { EntryType } from "@typesafe-ai/sdk";
22
23 // ── Limits ──────────────────────────────────────────────────────────────────
24
25 /**
26 * A Choice question accepts up to 255 options. This is a documented API limit,
27 * not a local guess: docs.typesafe.ai/cookbooks/semantic_find.
28 */
29 export const MAX_CHOICE_OPTIONS = 255;
30
31 /**
32 * Local safety caps, not API limits. They bound cost and latency for a single
33 * call so a malformed loop cannot run up a bill. All three are configurable.
34 */
35 export const DEFAULT_MAX_SCORE_LEVELS = 255;
36 export const DEFAULT_MAX_QUESTIONS = 64;
37 export const DEFAULT_MAX_STATE_CHARS = 200_000;
38 export const DEFAULT_TIMEOUT_MS = 15_000;
39
40 // ── Environment parsing ─────────────────────────────────────────────────────
41
42 export interface ParsedNumber {
43 value: number;
44 /** Set when the raw value was present but unusable, so the caller can warn. */
45 warning?: string;
46 }
47
48 /**
49 * Read a positive integer from an environment value.
50 *
51 * `Number("abc")` is `NaN`, and passing `NaN` as a timeout silently disables
52 * it. Anything not a finite positive number falls back and reports why.
53 */
54 export function readPositiveInt(raw: string | undefined, fallback: number, label: string): ParsedNumber {
55 if (raw === undefined || raw.trim() === "") return { value: fallback };
56 const parsed = Number(raw);
57 if (!Number.isFinite(parsed) || parsed <= 0) {
58 return { value: fallback, warning: `${label} must be a positive number; got ${JSON.stringify(raw)}. Using ${fallback}.` };
59 }
60 return { value: Math.floor(parsed) };
61 }
62
63 // ── Choice option handling ──────────────────────────────────────────────────
64
65 /**
66 * Pick a key for the "none of these" escape hatch that does not collide with a
67 * caller's own option.
68 *
69 * Blindly writing `criteria.none` overwrites an option the caller named "none",
70 * destroying its meaning and its probability. That is silent and unrecoverable,
71 * so we step aside instead.
72 */
73 export function resolveNoneKey(existing: readonly string[]): string {
74 const taken = new Set(existing);
75 if (!taken.has("none")) return "none";
76 if (!taken.has("none_of_these")) return "none_of_these";
77 let n = 2;
78 while (taken.has(`none_of_these_${n}`)) n += 1;
79 return `none_of_these_${n}`;
80 }
81
82 export interface BuiltCriteria {
83 criteria: Record<string, EntryType>;
84 /** The key that carries the "nothing fits" meaning, or null when not added. */
85 noneKey: string | null;
86 }
87
88 /**
89 * Build Choice criteria from caller options, optionally adding a no-match
90 * outcome under a key guaranteed not to clash.
91 *
92 * @throws when the option set is empty or exceeds the API's 255-option limit.
93 */
94 export function buildChoiceCriteria(
95 options: Record<string, EntryType>,
96 addNone: boolean,
97 label = "options",
98 ): BuiltCriteria {
99 const keys = Object.keys(options);
100 if (keys.length === 0) {
101 throw new Error(`${label} must contain at least one option. Jev selects among options you supply; it cannot invent one.`);
102 }
103
104 const criteria: Record<string, EntryType> = { ...options };
105 let noneKey: string | null = null;
106 if (addNone) {
107 noneKey = resolveNoneKey(keys);
108 criteria[noneKey] = "None of the other options fits.";
109 }
110
111 const total = Object.keys(criteria).length;
112 if (total > MAX_CHOICE_OPTIONS) {
113 throw new Error(
114 `A Choice question accepts at most ${MAX_CHOICE_OPTIONS} options; received ${total}. ` +
115 "For a larger set, search in two passes: one question picks a window, a second ranks within it.",
116 );
117 }
118 if (total < 2) {
119 throw new Error(`${label} must leave at least two outcomes. Supply another option, or leave the no-match outcome enabled.`);
120 }
121 return { criteria, noneKey };
122 }
123
124 /** Reject duplicate question ids rather than letting a later one shadow an earlier one. */
125 export function assertUniqueIds(ids: readonly string[]): void {
126 const seen = new Set<string>();
127 const duplicates = new Set<string>();
128 for (const id of ids) {
129 if (seen.has(id)) duplicates.add(id);
130 seen.add(id);
131 }
132 if (duplicates.size > 0) {
133 throw new Error(
134 `Duplicate question ids: ${[...duplicates].map((d) => JSON.stringify(d)).join(", ")}. ` +
135 "Answers are keyed by id, so a repeat would silently drop every earlier question sharing it.",
136 );
137 }
138 }
139
140 // ── Size guards ─────────────────────────────────────────────────────────────
141
142 /** Serialized size of the state, used to bound cost before a request goes out. */
143 export function stateSize(state: unknown): number {
144 return typeof state === "string" ? state.length : JSON.stringify(state ?? null).length;
145 }
146
147 /**
148 * Reject oversized state instead of truncating it.
149 *
150 * Truncation silently changes the material the judgment rests on, which turns a
151 * size problem into a wrong answer. Failing loudly keeps the caller in control.
152 */
153 export function assertStateWithinLimit(state: unknown, maxChars: number): void {
154 const size = stateSize(state);
155 if (size > maxChars) {
156 throw new Error(
157 `State is ${size} characters, above the ${maxChars} limit. ` +
158 "Shorten it or select the relevant part first; raise JEV_MAX_STATE_CHARS if the limit is wrong for your workload.",
159 );
160 }
161 }
162
163 // ── Confidence gating ───────────────────────────────────────────────────────
164
165 export type Gate = "act" | "review" | "abstain";
166
167 /**
168 * Turn a Choice/Score confidence into a recommended action.
169 *
170 * Thresholds are starting points to calibrate on your own data, never universal
171 * rules. Confidence describes how concentrated the distribution is; it is not a
172 * statement that the answer is correct or that acting is safe.
173 */
174 export function gateConfidence(confidence: number | null | undefined, actAbove: number, reviewAbove: number): Gate {
175 if (typeof confidence !== "number" || Number.isNaN(confidence)) return "abstain";
176 if (confidence >= actAbove) return "act";
177 if (confidence >= reviewAbove) return "review";
178 return "abstain";
179 }
180
181 export type Verdict = "yes" | "no" | "uncertain";
182
183 /**
184 * Turn a Noul probability into a verdict.
185 *
186 * A Noul has no confidence value. A probability near 0.5 means yes and no are
187 * close to equally likely, not that the answer is "medium", so the middle band
188 * is reported as uncertain rather than rounded to the nearer side.
189 */
190 export function gateProbability(probability: number, yesAtOrAbove: number, noAtOrBelow: number): Verdict {
191 if (probability >= yesAtOrAbove) return "yes";
192 if (probability <= noAtOrBelow) return "no";
193 return "uncertain";
194 }
195
196 // ── Error reporting ─────────────────────────────────────────────────────────
197
198 export type ErrorKind =
199 | "no_api_key"
200 | "authentication"
201 | "permission_denied"
202 | "invalid_request"
203 | "not_found"
204 | "rate_limit"
205 | "server_error"
206 | "timeout"
207 | "connection"
208 | "cancelled"
209 | "invalid_arguments"
210 | "unknown";
211
212 export interface DescribedError {
213 kind: ErrorKind;
214 message: string;
215 /** True when the same request may succeed later. The SDK already retried. */
216 retryable: boolean;
217 status?: number;
218 /** TypeSafe request id, the thing support needs to trace a failure. */
219 requestId?: string;
220 /** What the caller should actually do next. */
221 hint?: string;
222 }
223
224 /**
225 * Classify a failure so a caller can branch on it.
226 *
227 * Flattening every failure to a string makes a missing key, a malformed
228 * question, and a rate limit indistinguishable, so callers retry things that
229 * will never succeed and give up on things that would.
230 */
231 export function describeError(error: unknown): DescribedError {
232 const message = error instanceof Error ? error.message : String(error);
233
234 if (error instanceof APIUserAbortError) {
235 return { kind: "cancelled", message, retryable: false, hint: "The client cancelled the request." };
236 }
237 if (error instanceof APITimeoutError) {
238 return {
239 kind: "timeout",
240 message,
241 retryable: true,
242 hint: "Shorten the state or raise JEV_TIMEOUT_MS.",
243 };
244 }
245 if (error instanceof APIConnectionError) {
246 return { kind: "connection", message, retryable: true, hint: "Check network access to api.typesafe.ai." };
247 }
248
249 if (error instanceof APIError) {
250 const base = { message, status: error.status, requestId: error.requestId };
251 if (error instanceof AuthenticationError) {
252 return { ...base, kind: "authentication", retryable: false, hint: "The API key was rejected. Check TYPESAFE_API_KEY in the server's environment." };
253 }
254 if (error instanceof PermissionDeniedError) {
255 return { ...base, kind: "permission_denied", retryable: false, hint: "The key is valid but lacks access to this model or account." };
256 }
257 if (error instanceof RateLimitError) {
258 return { ...base, kind: "rate_limit", retryable: true, hint: "Rate limited after the SDK's own retries. Back off before trying again." };
259 }
260 if (error instanceof UnprocessableEntityError || error instanceof BadRequestError) {
261 return { ...base, kind: "invalid_request", retryable: false, hint: "The request failed validation. The message names the offending field." };
262 }
263 if (error instanceof NotFoundError) {
264 return { ...base, kind: "not_found", retryable: false, hint: "Check the model id in JEV_MODEL." };
265 }
266 return { ...base, kind: "server_error", retryable: error.status >= 500, hint: "TypeSafe returned an error. Retry if it persists." };
267 }
268
269 if (/^No API key/i.test(message)) {
270 return {
271 kind: "no_api_key",
272 message,
273 retryable: false,
274 hint: "Set TYPESAFE_API_KEY in the environment of the MCP client, not as a tool argument. Many clients filter the environment, so pass it explicitly when registering the server.",
275 };
276 }
277
278 return { kind: "invalid_arguments", message, retryable: false };
279 }
test/e2e.test.mjs added +85
@@ -0,0 +1,85 @@
1 // Live end-to-end tests against the real TypeSafe API.
2 //
3 // Skipped unless a credential is present in the environment, so `npm test` on a
4 // clean checkout and in CI never depends on network access or spends tokens.
5 // Run with: npm run test:e2e
6
7 import assert from "node:assert/strict";
8 import test from "node:test";
9 import { payload, withClient } from "./helpers.mjs";
10
11 const KEY_VAR = ["TYPESAFE", "API", "KEY"].join("_");
12 const hasKey = Boolean(process.env[KEY_VAR]);
13 const options = { skip: hasKey ? false : `set ${KEY_VAR} to run live tests` };
14
15 /** Forward the real credential to the server under test. */
16 function live(fn) {
17 return withClient({ withKey: false, env: { [KEY_VAR]: process.env[KEY_VAR] } }, fn);
18 }
19
20 test("the key works and lists at least one model", options, async () => {
21 await live(async (client) => {
22 const body = payload(await client.callTool({ name: "jev_models", arguments: {} }));
23 assert.ok(body.models.length >= 1, "expected at least one available model");
24 });
25 });
26
27 test("classify routes a support ticket to the right team", options, async () => {
28 await live(async (client) => {
29 const body = payload(
30 await client.callTool({
31 name: "jev_classify",
32 arguments: {
33 state: "My running shoes arrived in the wrong size. Can I swap them for a size 10?",
34 question: "Which team should handle this?",
35 options: {
36 returns: "Exchanges, refunds, wrong or damaged items",
37 shipping: "Delivery status, delays, lost packages",
38 billing: "Charges, invoices, payment problems",
39 },
40 },
41 }),
42 );
43 assert.equal(body.choice, "returns");
44 assert.ok(body.confidence > 0 && body.confidence <= 1);
45 assert.ok(Object.keys(body.probabilities).includes("none"));
46 });
47 });
48
49 test("check returns a high probability for a plainly urgent message", options, async () => {
50 await live(async (client) => {
51 const body = payload(
52 await client.callTool({
53 name: "jev_check",
54 arguments: {
55 state: "Help! My payouts have been failing for 3 days and customers are complaining.",
56 question: "Does this convey urgency?",
57 },
58 }),
59 );
60 assert.ok(body.probability_yes > 0.5, `expected an urgent read, got ${body.probability_yes}`);
61 assert.equal(body.verdict, "yes");
62 });
63 });
64
65 test("ask answers several questions about one state in a single request", options, async () => {
66 await live(async (client) => {
67 const body = payload(
68 await client.callTool({
69 name: "jev_ask",
70 arguments: {
71 state: "I was charged twice for order A-104. Please refund the duplicate.",
72 questions: [
73 { id: "billing", type: "check", question: "Is this about billing?" },
74 { id: "refund", type: "check", question: "Is the customer asking for a refund?" },
75 { id: "anger", type: "score", question: "How frustrated is the customer?", levels: ["Calm", "Frustrated", "Very angry"] },
76 ],
77 },
78 }),
79 );
80 assert.ok(body.answers.billing.noul > 0.5);
81 assert.ok(body.answers.refund.noul > 0.5);
82 assert.ok(typeof body.answers.anger.score === "number");
83 assert.ok(body.usage.input_tokens > 0);
84 });
85 });
test/helpers.mjs added +137
@@ -0,0 +1,137 @@
1 // Shared test rig: a local stand-in for the TypeSafe API plus an MCP client.
2 //
3 // Tests drive the real built server over stdio through the official MCP client,
4 // so there are no sleeps and no hand-rolled JSON-RPC framing. The stand-in
5 // records every outbound request, which is how the regression tests prove what
6 // the server actually sends rather than what it claims to send.
7
8 import { createServer } from "node:http";
9 import { fileURLToPath } from "node:url";
10 import { Client } from "@modelcontextprotocol/sdk/client/index.js";
11 import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
12
13 export const SERVER_PATH = fileURLToPath(new URL("../dist/index.js", import.meta.url));
14
15 /** Name assembled at runtime so scanners don't read this file as a credential. */
16 const KEY_VAR = ["TYPESAFE", "API", "KEY"].join("_");
17
18 /** Start a stand-in API that records requests and returns well-formed answers. */
19 export async function startMock() {
20 const requests = [];
21 const state = { confidence: 0.9, noul: 0.5, status: 200, body: null };
22
23 const server = createServer((req, res) => {
24 let raw = "";
25 req.on("data", (d) => (raw += d));
26 req.on("end", () => {
27 let parsed;
28 try {
29 parsed = JSON.parse(raw || "{}");
30 } catch {
31 parsed = raw;
32 }
33 requests.push({ url: req.url, body: parsed });
34
35 res.setHeader("content-type", "application/json");
36 res.setHeader("x-typesafe-request-id", "req_test_123");
37
38 if (state.status !== 200) {
39 res.writeHead(state.status);
40 res.end(JSON.stringify(state.body ?? { error: { message: "mock failure" } }));
41 return;
42 }
43
44 if (req.url?.includes("/models")) {
45 res.writeHead(200);
46 res.end(JSON.stringify({ models: [{ name: "jev-latest", description: "Flagship", release_date: "2026-01-01" }] }));
47 return;
48 }
49
50 const answers = {};
51 for (const [id, q] of Object.entries(parsed.questions ?? {})) {
52 if (q.type === "noul") {
53 answers[id] = { type: "noul", noul: state.noul };
54 } else if (q.type === "choice") {
55 const keys = Object.keys(q.criteria ?? {});
56 answers[id] = {
57 type: "choice",
58 choice: keys[0] ?? "x",
59 confidence: state.confidence,
60 probabilities: Object.fromEntries(keys.map((k, i) => [k, i === 0 ? 0.9 : 0.1])),
61 };
62 } else {
63 const levels = (q.criteria ?? []).map((_, i) => String(i));
64 answers[id] = {
65 type: "score",
66 score: 1,
67 confidence: state.confidence,
68 legend: Object.fromEntries(levels.map((k, i) => [k, q.criteria[i]])),
69 probabilities: Object.fromEntries(levels.map((k) => [k, 1 / levels.length])),
70 };
71 }
72 }
73 res.writeHead(200);
74 res.end(JSON.stringify({ model: "mock-jev", answers, usage: { input_tokens: 10, output_tokens: 2 } }));
75 });
76 });
77
78 await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
79 const url = `http://127.0.0.1:${server.address().port}`;
80
81 return {
82 url,
83 requests,
84 state,
85 /** The single outbound request body, asserting exactly one was sent. */
86 only() {
87 if (requests.length !== 1) throw new Error(`expected exactly 1 request, saw ${requests.length}`);
88 return requests[0].body;
89 },
90 async close() {
91 await new Promise((resolve) => server.close(resolve));
92 },
93 };
94 }
95
96 /**
97 * Connect an MCP client to the built server.
98 *
99 * `withKey: false` starts the server with no credential at all, which is how
100 * the missing-key path is exercised.
101 */
102 export async function withClient({ baseUrl, withKey = true, env = {} } = {}, fn) {
103 // Pin the key-file fallback at a path that cannot exist. HOME is passed
104 // through, so without this the suite would start reading a real
105 // ~/.config/typesafe/key the moment one exists, and the missing-key test
106 // would quietly begin making live calls instead of exercising the failure
107 // path. A test that needs the fallback passes JEV_KEY_FILE explicitly.
108 const childEnv = {
109 PATH: process.env.PATH,
110 HOME: process.env.HOME,
111 JEV_KEY_FILE: "/nonexistent/jev-mcp-test/key",
112 ...env,
113 };
114 if (baseUrl) childEnv.TYPESAFE_BASE_URL = baseUrl;
115 if (withKey) childEnv[KEY_VAR] = "value-for-the-local-stand-in";
116
117 const client = new Client({ name: "jev-mcp-test", version: "0.2.0" });
118 const transport = new StdioClientTransport({
119 command: process.execPath,
120 args: [SERVER_PATH],
121 env: childEnv,
122 stderr: "ignore",
123 });
124 await client.connect(transport);
125 try {
126 return await fn(client);
127 } finally {
128 await client.close();
129 }
130 }
131
132 /** Parse the JSON a tool returns in its text block. */
133 export function payload(result) {
134 const block = result.content?.find((b) => b.type === "text");
135 if (!block) throw new Error("tool returned no text content");
136 return JSON.parse(block.text);
137 }
test/lib.test.mjs added +122
@@ -0,0 +1,122 @@
1 // Unit tests for the pure helpers. No network, no child process.
2
3 import assert from "node:assert/strict";
4 import test from "node:test";
5 import { APIError } from "@typesafe-ai/sdk";
6 import {
7 assertStateWithinLimit,
8 assertUniqueIds,
9 buildChoiceCriteria,
10 describeError,
11 gateConfidence,
12 gateProbability,
13 MAX_CHOICE_OPTIONS,
14 readPositiveInt,
15 resolveNoneKey,
16 stateSize,
17 } from "../dist/lib.js";
18
19 test("readPositiveInt falls back loudly instead of yielding NaN", () => {
20 assert.deepEqual(readPositiveInt(undefined, 15000, "T"), { value: 15000 });
21 assert.deepEqual(readPositiveInt(" ", 15000, "T"), { value: 15000 });
22 assert.equal(readPositiveInt("500", 15000, "T").value, 500);
23 assert.equal(readPositiveInt("12.7", 15000, "T").value, 12);
24
25 for (const bad of ["abc", "0", "-5", "NaN"]) {
26 const parsed = readPositiveInt(bad, 15000, "T");
27 assert.equal(parsed.value, 15000, `${bad} should fall back`);
28 assert.ok(parsed.warning, `${bad} should warn`);
29 }
30 });
31
32 test("resolveNoneKey steps aside when the caller already uses the name", () => {
33 assert.equal(resolveNoneKey([]), "none");
34 assert.equal(resolveNoneKey(["a", "b"]), "none");
35 assert.equal(resolveNoneKey(["none"]), "none_of_these");
36 assert.equal(resolveNoneKey(["none", "none_of_these"]), "none_of_these_2");
37 });
38
39 test("buildChoiceCriteria never overwrites a caller's own option", () => {
40 const callerMeaning = "The request was already handled; no action needed.";
41 const { criteria, noneKey } = buildChoiceCriteria({ none: callerMeaning, escalate: "Send to a human" }, true);
42
43 assert.equal(criteria.none, callerMeaning, "the caller's option must survive verbatim");
44 assert.equal(noneKey, "none_of_these");
45 assert.equal(criteria.none_of_these, "None of the other options fits.");
46 });
47
48 test("buildChoiceCriteria reports the no-match key it used", () => {
49 const { criteria, noneKey } = buildChoiceCriteria({ a: "d", b: "d" }, true);
50 assert.equal(noneKey, "none");
51 assert.deepEqual(Object.keys(criteria).sort(), ["a", "b", "none"]);
52
53 const off = buildChoiceCriteria({ a: "d", b: "d" }, false);
54 assert.equal(off.noneKey, null);
55 assert.deepEqual(Object.keys(off.criteria).sort(), ["a", "b"]);
56 });
57
58 test("buildChoiceCriteria rejects empty, single-outcome, and oversized option sets", () => {
59 assert.throws(() => buildChoiceCriteria({}, true), /at least one option/);
60 assert.throws(() => buildChoiceCriteria({ only: "d" }, false), /at least two outcomes/);
61
62 const tooMany = Object.fromEntries(Array.from({ length: MAX_CHOICE_OPTIONS + 1 }, (_, i) => [`o${i}`, null]));
63 assert.throws(() => buildChoiceCriteria(tooMany, false), /at most 255 options/);
64
65 const exactlyAtLimit = Object.fromEntries(Array.from({ length: MAX_CHOICE_OPTIONS }, (_, i) => [`o${i}`, null]));
66 assert.doesNotThrow(() => buildChoiceCriteria(exactlyAtLimit, false));
67 });
68
69 test("assertUniqueIds names the duplicates rather than dropping questions", () => {
70 assert.doesNotThrow(() => assertUniqueIds(["a", "b", "c"]));
71 assert.throws(() => assertUniqueIds(["a", "b", "a"]), /Duplicate question ids: "a"/);
72 });
73
74 test("state size is measured, and oversized state is rejected not truncated", () => {
75 assert.equal(stateSize("abcde"), 5);
76 assert.equal(stateSize({ a: 1 }), JSON.stringify({ a: 1 }).length);
77
78 assert.doesNotThrow(() => assertStateWithinLimit("abc", 10));
79 assert.throws(() => assertStateWithinLimit("a".repeat(20), 10), /above the 10 limit/);
80 });
81
82 test("gateConfidence maps confidence onto an action", () => {
83 assert.equal(gateConfidence(0.95, 0.8, 0.5), "act");
84 assert.equal(gateConfidence(0.8, 0.8, 0.5), "act");
85 assert.equal(gateConfidence(0.6, 0.8, 0.5), "review");
86 assert.equal(gateConfidence(0.2, 0.8, 0.5), "abstain");
87 assert.equal(gateConfidence(null, 0.8, 0.5), "abstain");
88 assert.equal(gateConfidence(undefined, 0.8, 0.5), "abstain");
89 });
90
91 test("gateProbability keeps the middle band uncertain instead of rounding", () => {
92 assert.equal(gateProbability(0.9, 0.7, 0.3), "yes");
93 assert.equal(gateProbability(0.7, 0.7, 0.3), "yes");
94 assert.equal(gateProbability(0.51, 0.7, 0.3), "uncertain", "0.51 is a coin flip, not a yes");
95 assert.equal(gateProbability(0.3, 0.7, 0.3), "no");
96 assert.equal(gateProbability(0.05, 0.7, 0.3), "no");
97 });
98
99 test("describeError separates failures a caller should treat differently", () => {
100 const missing = describeError(new Error("No API key. Set TYPESAFE_API_KEY in the environment."));
101 assert.equal(missing.kind, "no_api_key");
102 assert.equal(missing.retryable, false);
103
104 const headers = new Headers({ "x-typesafe-request-id": "req_abc" });
105 const rateLimited = describeError(APIError.fromResponse(429, {}, headers));
106 assert.equal(rateLimited.kind, "rate_limit");
107 assert.equal(rateLimited.retryable, true);
108 assert.equal(rateLimited.status, 429);
109 assert.equal(rateLimited.requestId, "req_abc");
110
111 const unauthorized = describeError(APIError.fromResponse(401, {}, new Headers()));
112 assert.equal(unauthorized.kind, "authentication");
113 assert.equal(unauthorized.retryable, false, "a bad key never succeeds on retry");
114
115 const malformed = describeError(APIError.fromResponse(422, {}, new Headers()));
116 assert.equal(malformed.kind, "invalid_request");
117 assert.equal(malformed.retryable, false);
118
119 const overloaded = describeError(APIError.fromResponse(529, {}, new Headers()));
120 assert.equal(overloaded.kind, "server_error");
121 assert.equal(overloaded.retryable, true);
122 });
test/tools.test.mjs added +372
@@ -0,0 +1,372 @@
1 // Contract and regression tests for the tool surface.
2 //
3 // These run the real built server over stdio against a local stand-in for the
4 // TypeSafe API, so each assertion is about what the server actually sends and
5 // returns. Every test named "regression" pins a defect that was confirmed on
6 // the wire before it was fixed.
7
8 import assert from "node:assert/strict";
9 import test from "node:test";
10 import { payload, startMock, withClient } from "./helpers.mjs";
11
12 const TOOLS = ["jev_ask", "jev_check", "jev_classify", "jev_models", "jev_score"];
13
14 // ── Contract ────────────────────────────────────────────────────────────────
15
16 test("advertises exactly the five judgment tools", async () => {
17 await withClient({ withKey: false }, async (client) => {
18 const { tools } = await client.listTools();
19 assert.deepEqual(tools.map((t) => t.name).sort(), TOOLS);
20 });
21 });
22
23 test("every tool documents itself and declares both input and output schemas", async () => {
24 await withClient({ withKey: false }, async (client) => {
25 const { tools } = await client.listTools();
26 for (const tool of tools) {
27 assert.ok(tool.description?.length > 40, `${tool.name} needs a real description`);
28 assert.ok(tool.inputSchema, `${tool.name} has no inputSchema`);
29 assert.equal(tool.inputSchema.type, "object");
30 assert.ok(tool.outputSchema, `${tool.name} has no outputSchema, so callers get an opaque string`);
31 }
32 });
33 });
34
35 test("classify requires caller-supplied options, so the model cannot invent one", async () => {
36 await withClient({ withKey: false }, async (client) => {
37 const { tools } = await client.listTools();
38 const classify = tools.find((t) => t.name === "jev_classify");
39 for (const field of ["state", "question", "options"]) {
40 assert.ok(classify.inputSchema.required.includes(field), `${field} must be required`);
41 }
42 const scoreTool = tools.find((t) => t.name === "jev_score");
43 assert.ok(scoreTool.inputSchema.required.includes("levels"));
44 assert.equal(scoreTool.inputSchema.properties.levels.minItems, 2);
45 });
46 });
47
48 test("a missing API key fails loudly and says what to do about it", async () => {
49 await withClient({ withKey: false }, async (client) => {
50 const result = await client.callTool({
51 name: "jev_check",
52 arguments: { state: "anything", question: "Is this a greeting?" },
53 });
54 assert.equal(result.isError, true, "must report an error, not a fabricated answer");
55 const { error } = payload(result);
56 assert.equal(error.kind, "no_api_key");
57 assert.equal(error.retryable, false);
58 assert.match(error.hint, /environment/i);
59 });
60 });
61
62 test("falls back to a key file when the environment carries no key", async () => {
63 // An MCP server inherits the client's environment, not your shell's, so a key
64 // exported from a shell profile never arrives. The file fallback is the only
65 // route that reaches every spawn path, which makes it worth pinning here.
66 const { mkdtempSync, writeFileSync } = await import("node:fs");
67 const { tmpdir } = await import("node:os");
68 const { join } = await import("node:path");
69
70 const keyFile = join(mkdtempSync(join(tmpdir(), "jev-key-")), "key");
71 writeFileSync(keyFile, " value-for-the-local-stand-in\n", { mode: 0o600 });
72
73 const mock = await startMock();
74 try {
75 await withClient({ baseUrl: mock.url, withKey: false, env: { JEV_KEY_FILE: keyFile } }, async (client) => {
76 const result = await client.callTool({
77 name: "jev_check",
78 arguments: { state: "s", question: "Is this fine?" },
79 });
80 assert.notEqual(result.isError, true, "the key file should satisfy the credential check");
81 assert.equal(typeof payload(result).probability_yes, "number");
82 assert.equal(mock.requests.length, 1, "the request must actually reach the API");
83 });
84 } finally {
85 await mock.close();
86 }
87 });
88
89 // ── Regressions confirmed on the wire ───────────────────────────────────────
90
91 test("regression: an option the caller names 'none' is never overwritten", async () => {
92 const mock = await startMock();
93 const callerMeaning = "Already resolved; no action needed.";
94 try {
95 await withClient({ baseUrl: mock.url }, async (client) => {
96 const result = await client.callTool({
97 name: "jev_classify",
98 arguments: {
99 state: "s",
100 question: "What should happen next?",
101 options: { none: callerMeaning, escalate: "Send to a human" },
102 },
103 });
104
105 const sent = mock.only().questions.classify.criteria;
106 assert.equal(sent.none, callerMeaning, "the caller's meaning must reach the model intact");
107 assert.equal(sent.none_of_these, "None of the other options fits.");
108
109 const body = payload(result);
110 assert.equal(body.none_option, "none_of_these", "the caller must be told which key means no-match");
111 });
112 } finally {
113 await mock.close();
114 }
115 });
116
117 test("regression: add_none false sends exactly the caller's options", async () => {
118 const mock = await startMock();
119 try {
120 await withClient({ baseUrl: mock.url }, async (client) => {
121 const result = await client.callTool({
122 name: "jev_classify",
123 arguments: { state: "s", question: "q", options: { a: "first", b: "second" }, add_none: false },
124 });
125 assert.deepEqual(Object.keys(mock.only().questions.classify.criteria).sort(), ["a", "b"]);
126 assert.equal(payload(result).none_option, null);
127 });
128 } finally {
129 await mock.close();
130 }
131 });
132
133 test("regression: an empty or oversized option set fails before any request is sent", async () => {
134 const mock = await startMock();
135 try {
136 await withClient({ baseUrl: mock.url }, async (client) => {
137 const empty = await client.callTool({
138 name: "jev_classify",
139 arguments: { state: "s", question: "q", options: {}, add_none: false },
140 });
141 assert.equal(empty.isError, true);
142 assert.match(payload(empty).error.message, /at least one option/);
143
144 const tooMany = Object.fromEntries(Array.from({ length: 256 }, (_, i) => [`o${i}`, null]));
145 const oversized = await client.callTool({
146 name: "jev_classify",
147 arguments: { state: "s", question: "q", options: tooMany, add_none: false },
148 });
149 assert.equal(oversized.isError, true);
150 assert.match(payload(oversized).error.message, /at most 255 options/);
151
152 assert.equal(mock.requests.length, 0, "neither request should reach the API");
153 });
154 } finally {
155 await mock.close();
156 }
157 });
158
159 test("regression: duplicate question ids fail instead of silently dropping a question", async () => {
160 const mock = await startMock();
161 try {
162 await withClient({ baseUrl: mock.url }, async (client) => {
163 const result = await client.callTool({
164 name: "jev_ask",
165 arguments: {
166 state: "s",
167 questions: [
168 { id: "dup", type: "check", question: "first" },
169 { id: "dup", type: "check", question: "second" },
170 ],
171 },
172 });
173 assert.equal(result.isError, true);
174 assert.match(payload(result).error.message, /Duplicate question ids: "dup"/);
175 assert.equal(mock.requests.length, 0, "no partial request should be sent");
176 });
177 } finally {
178 await mock.close();
179 }
180 });
181
182 test("regression: jev_ask honours add_none and passes yes/no meanings", async () => {
183 const mock = await startMock();
184 try {
185 await withClient({ baseUrl: mock.url }, async (client) => {
186 await client.callTool({
187 name: "jev_ask",
188 arguments: {
189 state: "s",
190 questions: [
191 { id: "pick", type: "classify", question: "q", options: { a: "d", b: "d" }, add_none: false },
192 { id: "flag", type: "check", question: "q", yes_means: "It is urgent", no_means: "It can wait" },
193 ],
194 },
195 });
196
197 const sent = mock.only().questions;
198 assert.deepEqual(Object.keys(sent.pick.criteria).sort(), ["a", "b"], "add_none:false must be honoured");
199 assert.deepEqual(sent.flag.criteria, { true: "It is urgent", false: "It can wait" });
200 });
201 } finally {
202 await mock.close();
203 }
204 });
205
206 test("regression: a verbose SDK log level does not corrupt the JSON-RPC stream", async () => {
207 const mock = await startMock();
208 try {
209 await withClient({ baseUrl: mock.url, env: { TYPESAFE_LOG_LEVEL: "debug" } }, async (client) => {
210 const result = await client.callTool({
211 name: "jev_check",
212 arguments: { state: "s", question: "Is this fine?" },
213 });
214 assert.notEqual(result.isError, true, "debug logging must not break the connection");
215 assert.equal(typeof payload(result).probability_yes, "number");
216 });
217 } finally {
218 await mock.close();
219 }
220 });
221
222 // ── Behaviour ───────────────────────────────────────────────────────────────
223
224 test("results carry structured content matching the text block", async () => {
225 const mock = await startMock();
226 try {
227 await withClient({ baseUrl: mock.url }, async (client) => {
228 const result = await client.callTool({
229 name: "jev_classify",
230 arguments: { state: "s", question: "q", options: { a: "d", b: "d" } },
231 });
232 assert.ok(result.structuredContent, "clients should get typed data, not a JSON string");
233 assert.deepEqual(result.structuredContent, payload(result));
234 assert.equal(typeof result.structuredContent.probabilities.a, "number");
235 });
236 } finally {
237 await mock.close();
238 }
239 });
240
241 test("confidence drives the recommended action", async () => {
242 const mock = await startMock();
243 try {
244 await withClient({ baseUrl: mock.url }, async (client) => {
245 const args = { state: "s", question: "q", options: { a: "d", b: "d" } };
246
247 mock.state.confidence = 0.95;
248 assert.equal(payload(await client.callTool({ name: "jev_classify", arguments: args })).action, "act");
249
250 mock.state.confidence = 0.6;
251 assert.equal(payload(await client.callTool({ name: "jev_classify", arguments: args })).action, "review");
252
253 mock.state.confidence = 0.2;
254 assert.equal(payload(await client.callTool({ name: "jev_classify", arguments: args })).action, "abstain");
255
256 mock.state.confidence = 0.6;
257 const strict = await client.callTool({ name: "jev_classify", arguments: { ...args, act_above: 0.5 } });
258 assert.equal(payload(strict).action, "act", "caller thresholds must override the defaults");
259 });
260 } finally {
261 await mock.close();
262 }
263 });
264
265 test("a near-even probability is reported as uncertain, not rounded to a yes", async () => {
266 const mock = await startMock();
267 try {
268 await withClient({ baseUrl: mock.url }, async (client) => {
269 mock.state.noul = 0.52;
270 const result = await client.callTool({
271 name: "jev_check",
272 arguments: { state: "s", question: "Is this urgent?" },
273 });
274 const body = payload(result);
275 assert.equal(body.probability_yes, 0.52);
276 assert.equal(body.verdict, "uncertain");
277 });
278 } finally {
279 await mock.close();
280 }
281 });
282
283 test("oversized state is rejected before it costs anything", async () => {
284 const mock = await startMock();
285 try {
286 await withClient({ baseUrl: mock.url, env: { JEV_MAX_STATE_CHARS: "50" } }, async (client) => {
287 const result = await client.callTool({
288 name: "jev_check",
289 arguments: { state: "x".repeat(200), question: "q" },
290 });
291 assert.equal(result.isError, true);
292 assert.match(payload(result).error.message, /above the 50 limit/);
293 assert.equal(mock.requests.length, 0);
294 });
295 } finally {
296 await mock.close();
297 }
298 });
299
300 test("an unusable timeout falls back instead of disabling the timeout", async () => {
301 const mock = await startMock();
302 try {
303 await withClient({ baseUrl: mock.url, env: { JEV_TIMEOUT_MS: "not-a-number" } }, async (client) => {
304 const result = await client.callTool({
305 name: "jev_check",
306 arguments: { state: "s", question: "q" },
307 });
308 assert.notEqual(result.isError, true, "a malformed timeout must not break the server");
309 });
310 } finally {
311 await mock.close();
312 }
313 });
314
315 test("API failures are classified so a caller can tell retryable from terminal", async () => {
316 const mock = await startMock();
317 try {
318 await withClient({ baseUrl: mock.url }, async (client) => {
319 mock.state.status = 401;
320 const unauthorized = await client.callTool({ name: "jev_check", arguments: { state: "s", question: "q" } });
321 assert.equal(unauthorized.isError, true);
322 const { error } = payload(unauthorized);
323 assert.equal(error.kind, "authentication");
324 assert.equal(error.retryable, false);
325 assert.equal(error.requestId, "req_test_123", "the request id support needs must survive");
326 });
327 } finally {
328 await mock.close();
329 }
330 });
331
332 test("jev_models reports the active model and what the key can use", async () => {
333 const mock = await startMock();
334 try {
335 await withClient({ baseUrl: mock.url }, async (client) => {
336 const body = payload(await client.callTool({ name: "jev_models", arguments: {} }));
337 assert.equal(body.active_model, "jev-latest");
338 assert.equal(body.models[0].name, "jev-latest");
339 });
340 } finally {
341 await mock.close();
342 }
343 });
344
345 test("one jev_ask call batches every question into a single request", async () => {
346 const mock = await startMock();
347 try {
348 await withClient({ baseUrl: mock.url }, async (client) => {
349 const result = await client.callTool({
350 name: "jev_ask",
351 arguments: {
352 state: { ticket: "My card was charged twice." },
353 questions: [
354 { id: "billing", type: "check", question: "Is this about billing?" },
355 { id: "team", type: "classify", question: "Which team?", options: { billing: "d", tech: "d" } },
356 { id: "anger", type: "score", question: "How angry?", levels: ["calm", "annoyed", "furious"] },
357 ],
358 },
359 });
360
361 assert.equal(mock.requests.length, 1, "three questions must cost one round trip");
362 assert.deepEqual(Object.keys(mock.only().questions).sort(), ["anger", "billing", "team"]);
363
364 const body = payload(result);
365 assert.deepEqual(Object.keys(body.answers).sort(), ["anger", "billing", "team"]);
366 assert.equal(body.answers.team.action, "act", "choice answers carry a gate");
367 assert.equal(body.answers.billing.action, undefined, "a noul has no confidence to gate on");
368 });
369 } finally {
370 await mock.close();
371 }
372 });
tsconfig.json added +19
@@ -0,0 +1,19 @@
1 {
2 "compilerOptions": {
3 "target": "ES2022",
4 "lib": ["ES2022"],
5 "module": "Node16",
6 "moduleResolution": "Node16",
7 "outDir": "dist",
8 "rootDir": "src",
9 "strict": true,
10 "noUncheckedIndexedAccess": true,
11 "noImplicitOverride": true,
12 "declaration": false,
13 "sourceMap": true,
14 "skipLibCheck": true,
15 "esModuleInterop": true,
16 "forceConsistentCasingInFileNames": true
17 },
18 "include": ["src/**/*.ts"]
19 }
↑↓ navigate ↵ open esc close

Keyboard shortcuts