jev-mcp-server 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- jev_mcp_server/__init__.py +3 -0
- jev_mcp_server/cache.py +51 -0
- jev_mcp_server/client.py +63 -0
- jev_mcp_server/config.py +64 -0
- jev_mcp_server/server.py +297 -0
- jev_mcp_server-0.1.0.dist-info/METADATA +213 -0
- jev_mcp_server-0.1.0.dist-info/RECORD +10 -0
- jev_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- jev_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- jev_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
jev_mcp_server/cache.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Optional response cache (OFF by default).
|
|
2
|
+
|
|
3
|
+
Enable with ``JEVMCP_CACHE=1``. The cache key is the SHA-256 of the exact
|
|
4
|
+
question payload (model, question type, criteria/levels, question, context),
|
|
5
|
+
so an identical decision repeated within a run returns in ~0 ms at zero API
|
|
6
|
+
cost and ``usage`` is reported as ``{"cached": true}``. Keep it off when
|
|
7
|
+
decisions must stay fresh (e.g. live triage of changing data).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from . import config
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _hash(payload: dict) -> str:
|
|
20
|
+
canonical = json.dumps(payload, sort_keys=True, ensure_ascii=False)
|
|
21
|
+
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _path(payload: dict):
|
|
25
|
+
return config.cache_dir() / f"{_hash(payload)}.json"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def lookup(payload: dict) -> dict | None:
|
|
29
|
+
if not config.cache_enabled():
|
|
30
|
+
return None
|
|
31
|
+
try:
|
|
32
|
+
return json.loads(_path(payload).read_text(encoding="utf-8"))
|
|
33
|
+
except (OSError, ValueError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def store(payload: dict, answer: dict) -> None:
|
|
38
|
+
if not config.cache_enabled():
|
|
39
|
+
return
|
|
40
|
+
directory = config.cache_dir()
|
|
41
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
tmp_path = directory / f"{_hash(payload)}.json.tmp"
|
|
43
|
+
try:
|
|
44
|
+
with open(tmp_path, "w", encoding="utf-8") as handle:
|
|
45
|
+
json.dump(answer, handle, ensure_ascii=False)
|
|
46
|
+
os.replace(tmp_path, _path(payload))
|
|
47
|
+
except OSError:
|
|
48
|
+
try:
|
|
49
|
+
tmp_path.unlink()
|
|
50
|
+
except OSError:
|
|
51
|
+
pass
|
jev_mcp_server/client.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Thin HTTP client for the TypeSafe System One API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from . import __version__, config
|
|
10
|
+
|
|
11
|
+
RETRY_STATUS = {429, 503, 529}
|
|
12
|
+
USER_AGENT = f"jev-mcp-server/{__version__} (+https://github.com/wangkuangkuang/jev-mcp-server)"
|
|
13
|
+
MISSING_KEY_MESSAGE = (
|
|
14
|
+
"No TypeSafe API key found. Either set TYPESAFE_API_KEY in the server environment, "
|
|
15
|
+
"or call the `setup` tool once with your key. Get a key at https://console.typesafe.ai/settings/keys"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
_client: httpx.Client | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _http() -> httpx.Client:
|
|
22
|
+
global _client
|
|
23
|
+
if _client is None:
|
|
24
|
+
_client = httpx.Client(http2=True, timeout=25, headers={"User-Agent": USER_AGENT})
|
|
25
|
+
return _client
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def ask(questions: dict) -> dict:
|
|
29
|
+
"""Send one or more typed questions to System One and return the full response."""
|
|
30
|
+
key = config.resolve_key()
|
|
31
|
+
if not key:
|
|
32
|
+
raise RuntimeError(MISSING_KEY_MESSAGE)
|
|
33
|
+
body = {"model": config.model_name(), "state": {}, "questions": questions}
|
|
34
|
+
for attempt in range(3):
|
|
35
|
+
try:
|
|
36
|
+
response = _http().post(config.base_url(), json=body, headers={"Authorization": f"Bearer {key}"})
|
|
37
|
+
except httpx.HTTPError as error:
|
|
38
|
+
raise RuntimeError(f"TypeSafe connection failed ({type(error).__name__}); no decision made.") from None
|
|
39
|
+
if response.status_code in RETRY_STATUS and attempt < 2:
|
|
40
|
+
time.sleep(0.5 * 2**attempt)
|
|
41
|
+
continue
|
|
42
|
+
if response.is_error:
|
|
43
|
+
raise RuntimeError(f"TypeSafe returned HTTP {response.status_code}: {response.text[:200]}")
|
|
44
|
+
return response.json()
|
|
45
|
+
raise RuntimeError("TypeSafe unavailable after retries; no decision made.")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def verify_key(key: str) -> dict:
|
|
49
|
+
"""Prove a key works with a cheap live call before storing it."""
|
|
50
|
+
body = {
|
|
51
|
+
"model": config.model_name(),
|
|
52
|
+
"state": {},
|
|
53
|
+
"questions": {
|
|
54
|
+
"handshake": {"type": "noul", "instructions": {"question": "Is the sky blue?", "context": "key verification"}}
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
try:
|
|
58
|
+
response = _http().post(config.base_url(), json=body, headers={"Authorization": f"Bearer {key.strip()}"})
|
|
59
|
+
except httpx.HTTPError as error:
|
|
60
|
+
raise RuntimeError(f"TypeSafe connection failed ({type(error).__name__}); key NOT stored.") from None
|
|
61
|
+
if response.is_error:
|
|
62
|
+
raise RuntimeError(f"Key rejected (HTTP {response.status_code}); key NOT stored.")
|
|
63
|
+
return response.json()
|
jev_mcp_server/config.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Key and runtime configuration resolution.
|
|
2
|
+
|
|
3
|
+
Precedence for the TypeSafe API key:
|
|
4
|
+
1. ``TYPESAFE_API_KEY`` environment variable
|
|
5
|
+
2. key file written by the ``setup`` tool (``~/.config/jev-mcp/key`` by default)
|
|
6
|
+
|
|
7
|
+
All directories can be relocated via environment variables, mainly for tests.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
CONFIG_DIR_ENV = "JEVMCP_CONFIG_DIR"
|
|
16
|
+
CACHE_DIR_ENV = "JEVMCP_CACHE_DIR"
|
|
17
|
+
DEFAULT_CONFIG_DIR = Path.home() / ".config" / "jev-mcp"
|
|
18
|
+
DEFAULT_CACHE_DIR = Path.home() / ".cache" / "jev-mcp"
|
|
19
|
+
TRUTHY = {"1", "true", "yes", "on"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def config_dir() -> Path:
|
|
23
|
+
return Path(os.environ.get(CONFIG_DIR_ENV, str(DEFAULT_CONFIG_DIR)))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def key_file() -> Path:
|
|
27
|
+
return config_dir() / "key"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_key() -> str | None:
|
|
31
|
+
env_key = os.environ.get("TYPESAFE_API_KEY", "").strip()
|
|
32
|
+
if env_key:
|
|
33
|
+
return env_key
|
|
34
|
+
try:
|
|
35
|
+
value = key_file().read_text(encoding="utf-8").strip()
|
|
36
|
+
except OSError:
|
|
37
|
+
return None
|
|
38
|
+
return value or None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def save_key(api_key: str) -> Path:
|
|
42
|
+
directory = config_dir()
|
|
43
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
os.chmod(directory, 0o700)
|
|
45
|
+
target = key_file()
|
|
46
|
+
target.write_text(api_key.strip() + "\n", encoding="utf-8")
|
|
47
|
+
os.chmod(target, 0o600)
|
|
48
|
+
return target
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def base_url() -> str:
|
|
52
|
+
return os.environ.get("JEVMCP_BASE_URL", "https://api.typesafe.ai/v1/systemone").rstrip("/")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def model_name() -> str:
|
|
56
|
+
return os.environ.get("JEVMCP_MODEL", "jev-latest")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def cache_enabled() -> bool:
|
|
60
|
+
return os.environ.get("JEVMCP_CACHE", "").strip().lower() in TRUTHY
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def cache_dir() -> Path:
|
|
64
|
+
return Path(os.environ.get(CACHE_DIR_ENV, str(DEFAULT_CACHE_DIR)))
|
jev_mcp_server/server.py
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""MCP server: Jev (TypeSafe System One) judgment tools.
|
|
2
|
+
|
|
3
|
+
Tools map 1:1 to the official System One question types — ``choice``, ``score``,
|
|
4
|
+
``noul`` — plus ``classify`` (batch) and ``setup`` (one-time key onboarding).
|
|
5
|
+
|
|
6
|
+
Jev returns decisions and calibrated probabilities, never explanations: any
|
|
7
|
+
"reason" an assistant adds on top is its own interpretation of the numbers.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import math
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
|
|
18
|
+
from . import cache, client, config
|
|
19
|
+
|
|
20
|
+
mcp = FastMCP("jev")
|
|
21
|
+
|
|
22
|
+
MAX_OPTIONS = 100
|
|
23
|
+
MAX_ITEMS = 100
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _answer(questions: dict) -> tuple[dict, bool]:
|
|
27
|
+
"""Cache-aware entry to the API. Returns (response, from_cache)."""
|
|
28
|
+
cached = cache.lookup(questions)
|
|
29
|
+
if cached is not None:
|
|
30
|
+
return cached, True
|
|
31
|
+
result = client.ask(questions)
|
|
32
|
+
cache.store(questions, result)
|
|
33
|
+
return result, False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _meta(result: dict, started: float, from_cache: bool) -> dict:
|
|
37
|
+
usage = result.get("usage", {})
|
|
38
|
+
if from_cache:
|
|
39
|
+
usage = {"input_tokens": 0, "output_tokens": 0, "cached": True}
|
|
40
|
+
return {
|
|
41
|
+
"model": result.get("model", "jev-latest"),
|
|
42
|
+
"latency_ms": round((time.perf_counter() - started) * 1000),
|
|
43
|
+
"usage": usage,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _validated_choice(answer: dict, ids: set) -> dict:
|
|
48
|
+
try:
|
|
49
|
+
probs = answer["probabilities"]
|
|
50
|
+
numbers = [*probs.values(), answer["confidence"]]
|
|
51
|
+
valid = (
|
|
52
|
+
answer["choice"] in ids
|
|
53
|
+
and set(probs) == ids
|
|
54
|
+
and all(type(n) in (int, float) and math.isfinite(n) and 0 <= n <= 1 for n in numbers)
|
|
55
|
+
and abs(sum(probs.values()) - 1) < 0.02
|
|
56
|
+
and probs[answer["choice"]] >= max(probs.values()) - 1e-6
|
|
57
|
+
)
|
|
58
|
+
except (KeyError, TypeError, ValueError):
|
|
59
|
+
valid = False
|
|
60
|
+
if not valid:
|
|
61
|
+
raise ValueError("Invalid TypeSafe response; no decision returned.")
|
|
62
|
+
return answer
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _validated_options(options: dict[str, str]) -> None:
|
|
66
|
+
if len(options) != len(set(options)) or not 2 <= len(options) <= MAX_OPTIONS:
|
|
67
|
+
raise ValueError(f"Provide between 2 and {MAX_OPTIONS} uniquely-keyed options.")
|
|
68
|
+
if any(not str(k).strip() or not str(v).strip() for k, v in options.items()):
|
|
69
|
+
raise ValueError("Option ids and descriptions must be non-empty strings.")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@mcp.tool()
|
|
73
|
+
def choice(question: str, options: dict[str, str], context: str = "") -> str:
|
|
74
|
+
"""Ask Jev (TypeSafe System One) to pick ONE of 2-100 mutually exclusive options, with calibrated probabilities.
|
|
75
|
+
|
|
76
|
+
The official `choice` question type — for fast, cheap structured decisions:
|
|
77
|
+
triage, routing, classification, prioritization, tie-breaking among enumerated
|
|
78
|
+
candidates. Returns probabilities over ALL options (not just the winner), so
|
|
79
|
+
near-ties are visible. NOT for open-ended generation or multi-step reasoning —
|
|
80
|
+
use an LLM for those.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
question: The decision, e.g. "Which error class is most likely the root cause?"
|
|
84
|
+
options: Mapping of short unique id -> one-line description. 2-100 options.
|
|
85
|
+
context: Optional background facts that inform the decision. Keep it short.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
JSON string: {choice, confidence, probabilities (sorted desc), runner_up,
|
|
89
|
+
model, latency_ms, usage}
|
|
90
|
+
"""
|
|
91
|
+
_validated_options(options)
|
|
92
|
+
started = time.perf_counter()
|
|
93
|
+
result, from_cache = _answer(
|
|
94
|
+
{"decision": {"type": "choice", "criteria": options, "instructions": {"question": question, "context": context}}}
|
|
95
|
+
)
|
|
96
|
+
answer = _validated_choice(result.get("answers", {}).get("decision", {}), set(options))
|
|
97
|
+
probs = dict(sorted(answer["probabilities"].items(), key=lambda kv: -kv[1]))
|
|
98
|
+
ranked = list(probs)
|
|
99
|
+
return json.dumps(
|
|
100
|
+
{
|
|
101
|
+
"choice": answer["choice"],
|
|
102
|
+
"confidence": answer["confidence"],
|
|
103
|
+
"probabilities": probs,
|
|
104
|
+
"runner_up": ranked[1] if len(ranked) > 1 else None,
|
|
105
|
+
**_meta(result, started, from_cache),
|
|
106
|
+
},
|
|
107
|
+
ensure_ascii=False,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@mcp.tool()
|
|
112
|
+
def score(question: str, levels: list[str], context: str = "") -> str:
|
|
113
|
+
"""Ask Jev (TypeSafe System One) to grade something on an ORDERED scale of 2-8 levels.
|
|
114
|
+
|
|
115
|
+
The official `score` question type — risk/severity/quality rubrics, e.g.
|
|
116
|
+
levels ["minor", "moderate", "severe", "critical"]. Returns a fractional
|
|
117
|
+
0-based index into `levels` (1.88 = between levels[1] and levels[2], leaning
|
|
118
|
+
to levels[2]), plus confidence and per-level probabilities.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
question: What to grade, e.g. "Regression risk of renaming public config key X".
|
|
122
|
+
levels: 2-8 unique ordered scale points, low to high.
|
|
123
|
+
context: Optional background facts. Keep it short.
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
JSON string: {score, nearest_level, confidence, probabilities (sorted desc),
|
|
127
|
+
model, latency_ms, usage}
|
|
128
|
+
"""
|
|
129
|
+
if not 2 <= len(levels) <= 8:
|
|
130
|
+
raise ValueError("Provide between 2 and 8 levels.")
|
|
131
|
+
if len(set(levels)) != len(levels) or any(not str(level).strip() for level in levels):
|
|
132
|
+
raise ValueError("Levels must be unique, non-empty strings.")
|
|
133
|
+
started = time.perf_counter()
|
|
134
|
+
result, from_cache = _answer(
|
|
135
|
+
{"score": {"type": "score", "criteria": levels, "instructions": {"question": question, "context": context}}}
|
|
136
|
+
)
|
|
137
|
+
answer = result.get("answers", {}).get("score", {})
|
|
138
|
+
try:
|
|
139
|
+
probs = answer["probabilities"]
|
|
140
|
+
value = answer["score"]
|
|
141
|
+
valid = (
|
|
142
|
+
type(value) in (int, float)
|
|
143
|
+
and math.isfinite(value)
|
|
144
|
+
and 0 <= value <= len(levels) - 1
|
|
145
|
+
and set(probs) == {str(i) for i in range(len(levels))}
|
|
146
|
+
and all(type(v) in (int, float) and 0 <= v <= 1 for v in probs.values())
|
|
147
|
+
and abs(sum(probs.values()) - 1) < 0.02
|
|
148
|
+
)
|
|
149
|
+
except (KeyError, TypeError, ValueError):
|
|
150
|
+
valid = False
|
|
151
|
+
if not valid:
|
|
152
|
+
raise ValueError("Invalid TypeSafe response; no score returned.")
|
|
153
|
+
readable = {levels[int(k)]: v for k, v in probs.items()}
|
|
154
|
+
nearest = min(range(len(levels)), key=lambda i: abs(i - value))
|
|
155
|
+
return json.dumps(
|
|
156
|
+
{
|
|
157
|
+
"score": value,
|
|
158
|
+
"nearest_level": levels[nearest],
|
|
159
|
+
"confidence": answer.get("confidence"),
|
|
160
|
+
"probabilities": dict(sorted(readable.items(), key=lambda kv: -kv[1])),
|
|
161
|
+
**_meta(result, started, from_cache),
|
|
162
|
+
},
|
|
163
|
+
ensure_ascii=False,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@mcp.tool()
|
|
168
|
+
def noul(question: str, context: str = "") -> str:
|
|
169
|
+
"""Ask Jev (TypeSafe System One) a yes/no question; returns a 0-1 degree (>= 0.5 leans yes).
|
|
170
|
+
|
|
171
|
+
The official `noul` question type — fast, cheap binary checks: "is this change
|
|
172
|
+
breaking?", "does this log line match the reported symptom?". No probability
|
|
173
|
+
list, just the degree. NOT for questions that need multi-step reasoning — use
|
|
174
|
+
an LLM for those.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
question: A yes/no question, e.g. "Is renaming a public config key a breaking change?"
|
|
178
|
+
context: Optional background facts. Keep it short.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
JSON string: {noul, verdict, model, latency_ms, usage}
|
|
182
|
+
"""
|
|
183
|
+
started = time.perf_counter()
|
|
184
|
+
result, from_cache = _answer({"noul": {"type": "noul", "instructions": {"question": question, "context": context}}})
|
|
185
|
+
answer = result.get("answers", {}).get("noul", {})
|
|
186
|
+
value = answer.get("noul")
|
|
187
|
+
if type(value) not in (int, float) or not math.isfinite(value) or not 0 <= value <= 1:
|
|
188
|
+
raise ValueError("Invalid TypeSafe response; no verdict returned.")
|
|
189
|
+
return json.dumps({"noul": value, "verdict": "yes" if value >= 0.5 else "no", **_meta(result, started, from_cache)}, ensure_ascii=False)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@mcp.tool()
|
|
193
|
+
def classify(
|
|
194
|
+
items: list[str],
|
|
195
|
+
options: dict[str, str],
|
|
196
|
+
question: str = "Which category does this item belong to?",
|
|
197
|
+
context: str = "",
|
|
198
|
+
) -> str:
|
|
199
|
+
"""Batch-classify up to 100 items against ONE shared set of categories using Jev.
|
|
200
|
+
|
|
201
|
+
Runs the official `choice` question type once per item and aggregates the
|
|
202
|
+
results — routing support tickets, labeling log lines, triaging inbox items
|
|
203
|
+
against your own label set. Much cheaper and faster than an LLM for
|
|
204
|
+
mechanical labeling.
|
|
205
|
+
|
|
206
|
+
Args:
|
|
207
|
+
items: 1-100 non-empty strings to classify.
|
|
208
|
+
options: Mapping of short unique id -> one-line category description. 2-100 options.
|
|
209
|
+
question: Per-item question; the item itself is appended automatically.
|
|
210
|
+
context: Optional background facts shared by all items.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
JSON string: {results: [{item, choice, confidence, probabilities}], summary
|
|
214
|
+
(counts per choice, sorted desc), model, total_latency_ms,
|
|
215
|
+
usage: {input_tokens, output_tokens, calls, cached_calls}}
|
|
216
|
+
"""
|
|
217
|
+
if not 1 <= len(items) <= MAX_ITEMS:
|
|
218
|
+
raise ValueError(f"Provide between 1 and {MAX_ITEMS} items.")
|
|
219
|
+
if any(not str(item).strip() for item in items):
|
|
220
|
+
raise ValueError("Items must be non-empty strings.")
|
|
221
|
+
_validated_options(options)
|
|
222
|
+
ids = set(options)
|
|
223
|
+
results = []
|
|
224
|
+
summary: dict[str, int] = {}
|
|
225
|
+
usage_in = usage_out = calls = cached_calls = 0
|
|
226
|
+
model = "jev-latest"
|
|
227
|
+
started = time.perf_counter()
|
|
228
|
+
for item in items:
|
|
229
|
+
payload = {
|
|
230
|
+
"decision": {
|
|
231
|
+
"type": "choice",
|
|
232
|
+
"criteria": options,
|
|
233
|
+
"instructions": {"question": f"{question}\nItem: {item}", "context": context},
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
result, from_cache = _answer(payload)
|
|
237
|
+
answer = _validated_choice(result.get("answers", {}).get("decision", {}), ids)
|
|
238
|
+
picked = answer["choice"]
|
|
239
|
+
summary[picked] = summary.get(picked, 0) + 1
|
|
240
|
+
results.append(
|
|
241
|
+
{
|
|
242
|
+
"item": item,
|
|
243
|
+
"choice": picked,
|
|
244
|
+
"confidence": answer["confidence"],
|
|
245
|
+
"probabilities": dict(sorted(answer["probabilities"].items(), key=lambda kv: -kv[1])),
|
|
246
|
+
}
|
|
247
|
+
)
|
|
248
|
+
model = result.get("model", model)
|
|
249
|
+
item_usage = result.get("usage", {})
|
|
250
|
+
if from_cache:
|
|
251
|
+
cached_calls += 1
|
|
252
|
+
else:
|
|
253
|
+
usage_in += item_usage.get("input_tokens", 0)
|
|
254
|
+
usage_out += item_usage.get("output_tokens", 0)
|
|
255
|
+
calls += 1
|
|
256
|
+
return json.dumps(
|
|
257
|
+
{
|
|
258
|
+
"results": results,
|
|
259
|
+
"summary": dict(sorted(summary.items(), key=lambda kv: -kv[1])),
|
|
260
|
+
"model": model,
|
|
261
|
+
"total_latency_ms": round((time.perf_counter() - started) * 1000),
|
|
262
|
+
"usage": {"input_tokens": usage_in, "output_tokens": usage_out, "calls": calls, "cached_calls": cached_calls},
|
|
263
|
+
},
|
|
264
|
+
ensure_ascii=False,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@mcp.tool()
|
|
269
|
+
def setup(api_key: str) -> str:
|
|
270
|
+
"""One-time onboarding: verify a TypeSafe API key with a live call, then store it locally.
|
|
271
|
+
|
|
272
|
+
Use when TYPESAFE_API_KEY is not set in the server environment: pass your key
|
|
273
|
+
once (get one at https://console.typesafe.ai/settings/keys). It is verified
|
|
274
|
+
against the live API, then saved to ~/.config/jev-mcp/key (permissions 0600)
|
|
275
|
+
and never echoed back. An existing TYPESAFE_API_KEY env var always takes
|
|
276
|
+
precedence over the stored file.
|
|
277
|
+
|
|
278
|
+
Args:
|
|
279
|
+
api_key: Your TypeSafe API key (from console.typesafe.ai/settings/keys).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
JSON string: {ok, stored_path, verified}
|
|
283
|
+
"""
|
|
284
|
+
key = str(api_key).strip()
|
|
285
|
+
if not key:
|
|
286
|
+
raise ValueError("api_key must be a non-empty string.")
|
|
287
|
+
client.verify_key(key)
|
|
288
|
+
path = config.save_key(key)
|
|
289
|
+
return json.dumps({"ok": True, "stored_path": str(path), "verified": True}, ensure_ascii=False)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def main() -> None:
|
|
293
|
+
mcp.run(transport="stdio")
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
if __name__ == "__main__":
|
|
297
|
+
main()
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jev-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Faithful MCP server for Jev (TypeSafe System One): the three official question types — choice, score, noul — plus batch classify. Calibrated probabilities for coding agents.
|
|
5
|
+
Project-URL: Homepage, https://github.com/wangkuangkuang/jev-mcp-server
|
|
6
|
+
Project-URL: Repository, https://github.com/wangkuangkuang/jev-mcp-server
|
|
7
|
+
Project-URL: Issues, https://github.com/wangkuangkuang/jev-mcp-server/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/wangkuangkuang/jev-mcp-server/blob/main/CHANGELOG.md
|
|
9
|
+
Author: wangkuangkuang
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: classifier,decision,jev,llm,mcp,model-context-protocol,routing,system-one,typesafe
|
|
13
|
+
Classifier: Development Status :: 4 - Beta
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: httpx[http2]>=0.27
|
|
25
|
+
Requires-Dist: mcp<2,>=1.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
[English](README.md) | [简体中文](README.zh-CN.md)
|
|
29
|
+
|
|
30
|
+
# jev-mcp-server
|
|
31
|
+
|
|
32
|
+
[](https://github.com/wangkuangkuang/jev-mcp-server/actions/workflows/ci.yml)
|
|
33
|
+
[](https://pypi.org/project/jev-mcp-server/)
|
|
34
|
+
[](https://pypi.org/project/jev-mcp-server/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
|
|
37
|
+
**MCP server for [Jev](https://typesafe.ai) (TypeSafe System One) — a faithful mapping of the three official question types, plus batch classify.**
|
|
38
|
+
|
|
39
|
+
When your coding agent needs a judgment — which log line is the root cause, how risky is this diff, is this change breaking — it usually burns a frontier LLM call and gets prose back. Jev answers with a **typed decision and calibrated probabilities** in a fraction of a second, for a fraction of a cent:
|
|
40
|
+
|
|
41
|
+
| Question type | Tool | Returns | Measured* |
|
|
42
|
+
|---|---|---|---|
|
|
43
|
+
| `choice` | pick 1 of 2-100 options | winner + probabilities over ALL options + confidence | ~0.6 s, ~$0.00002 |
|
|
44
|
+
| `score` | grade on a rubric of 2-8 levels | fractional index + per-level probabilities | ~0.5 s, ~$0.00002 |
|
|
45
|
+
| `noul` | yes / no | 0-1 degree | ~0.4 s, ~$0.00001 |
|
|
46
|
+
| `classify` | label up to 100 items | per-item choice + aggregate summary | ~0.5 s × items |
|
|
47
|
+
|
|
48
|
+
\* Measured against `jev-1.13.0`, 2026-09, from real sessions (see [Benchmarks](#benchmarks)).
|
|
49
|
+
|
|
50
|
+
## Why this one
|
|
51
|
+
|
|
52
|
+
- **1:1 with the official API.** Tool names match the System One question types (`choice` / `score` / `noul`), so anything you learn from [TypeSafe's docs](https://typesafe.ai) transfers directly. No invented abstractions.
|
|
53
|
+
- **Decisions, not explanations.** Jev never returns reasons — any "why" your assistant writes is its own interpretation of the probability distribution. The README (and the tool docs) say so explicitly, so reports built on top stay honest.
|
|
54
|
+
- **Batch `classify`** for routing/labeling workflows, with per-item caching.
|
|
55
|
+
- **One-time `setup` tool**: paste your API key once in chat; it's verified live, stored with `0600` permissions, and never echoed back.
|
|
56
|
+
- **Bilingual docs** (English / 简体中文), configs for Claude Code, Codex, OpenCode, pi, and any stdio MCP client.
|
|
57
|
+
- Offline-tested (no network in CI), retries on 429/503/529, response validation (probabilities sum to 1, winner is the max), optional response cache.
|
|
58
|
+
|
|
59
|
+
## Quickstart
|
|
60
|
+
|
|
61
|
+
1. Get a TypeSafe API key at [console.typesafe.ai/settings/keys](https://console.typesafe.ai/settings/keys).
|
|
62
|
+
2. Register the server with your client (pick one below).
|
|
63
|
+
3. Either export `TYPESAFE_API_KEY`, or just ask your agent: *"run the jev setup tool with key `tsk_...`"*.
|
|
64
|
+
|
|
65
|
+
### Claude Code
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
claude mcp add jev --env TYPESAFE_API_KEY=YOUR_KEY -- uvx jev-mcp-server
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Codex (`~/.codex/config.toml`)
|
|
72
|
+
|
|
73
|
+
```toml
|
|
74
|
+
[mcp_servers.jev]
|
|
75
|
+
command = "uvx"
|
|
76
|
+
args = ["jev-mcp-server"]
|
|
77
|
+
env = { TYPESAFE_API_KEY = "YOUR_KEY" }
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### OpenCode (`~/.config/opencode/opencode.json`)
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"mcp": {
|
|
85
|
+
"jev": { "type": "local", "command": ["uvx", "jev-mcp-server"], "enabled": true }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### pi (`~/.pi/agent/mcp.json`)
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"jev": { "command": "uvx", "args": ["jev-mcp-server"], "lifecycle": "lazy" }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### Any stdio MCP client
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "command": "uvx", "args": ["jev-mcp-server"] }
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### From source (this repo)
|
|
107
|
+
|
|
108
|
+
```json
|
|
109
|
+
{ "command": "uv", "args": ["run", "--directory", "/path/to/jev-mcp-server", "jev-mcp-server"] }
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Tools
|
|
113
|
+
|
|
114
|
+
### `choice(question, options, context="")`
|
|
115
|
+
|
|
116
|
+
Pick ONE of 2-100 mutually exclusive options. Returns probabilities over all options (near-ties are visible), confidence, and the runner-up.
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
{"choice": "E1", "confidence": 0.67,
|
|
120
|
+
"probabilities": {"E1": 0.72, "E6": 0.2, "E5": 0.05, "E2": 0.01, "E3": 0.01, "E4": 0.01},
|
|
121
|
+
"runner_up": "E6", "model": "jev-1.13.0", "latency_ms": 678,
|
|
122
|
+
"usage": {"input_tokens": 1677, "output_tokens": 66}}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### `score(question, levels, context="")`
|
|
126
|
+
|
|
127
|
+
Grade on an ordered rubric of 2-8 levels. `score` is a fractional 0-based index: `2.22` with levels `["minor","moderate","severe","critical"]` means *severe, leaning critical*.
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{"score": 2.22, "nearest_level": "severe", "confidence": 0.59,
|
|
131
|
+
"probabilities": {"severe": 0.6, "critical": 0.2, "moderate": 0.2}, "...": "..."}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### `noul(question, context="")`
|
|
135
|
+
|
|
136
|
+
Yes/no with a 0-1 degree (`>= 0.5` leans yes). No probability list — the degree is the answer.
|
|
137
|
+
|
|
138
|
+
```json
|
|
139
|
+
{"noul": 0.76, "verdict": "yes", "model": "jev-1.13.0", "latency_ms": 402, "usage": {"...": "..."}}
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `classify(items, options, question=..., context="")`
|
|
143
|
+
|
|
144
|
+
Batch-label up to 100 items against one shared category set. One `choice` call per item, aggregated:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{"results": [{"item": "ticket #1", "choice": "billing", "confidence": 0.81, "probabilities": {"...": "..."}}],
|
|
148
|
+
"summary": {"billing": 12, "bug": 7, "howto": 3},
|
|
149
|
+
"usage": {"input_tokens": 8210, "output_tokens": 210, "calls": 22, "cached_calls": 0}}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### `setup(api_key)`
|
|
153
|
+
|
|
154
|
+
One-time onboarding: verifies the key with a live call, stores it at `~/.config/jev-mcp/key` (0600), never echoes it. An env var `TYPESAFE_API_KEY` always wins over the stored file.
|
|
155
|
+
|
|
156
|
+
## Caching (off by default)
|
|
157
|
+
|
|
158
|
+
Set `JEVMCP_CACHE=1` to enable. The cache key is the SHA-256 of the exact question payload, so:
|
|
159
|
+
|
|
160
|
+
- Identical repeated decisions (retries, re-runs, deterministic pipelines) return in ~0 ms at **zero API cost**; `usage` then reports `{"cached": true}`.
|
|
161
|
+
- `classify` benefits automatically: duplicate items inside one batch are single-billed.
|
|
162
|
+
|
|
163
|
+
Keep it **off** when decisions must stay fresh (live triage of changing data). Cache files live in `~/.cache/jev-mcp/` (override with `JEVMCP_CACHE_DIR`); delete them anytime.
|
|
164
|
+
|
|
165
|
+
## Configuration
|
|
166
|
+
|
|
167
|
+
| Variable | Default | Purpose |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `TYPESAFE_API_KEY` | — | API key (env wins over the file written by `setup`) |
|
|
170
|
+
| `JEVMCP_MODEL` | `jev-latest` | Model name sent to the API |
|
|
171
|
+
| `JEVMCP_BASE_URL` | `https://api.typesafe.ai/v1/systemone` | Point at a compatible gateway (experimental) |
|
|
172
|
+
| `JEVMCP_CACHE` | off | `1`/`true` enables the response cache |
|
|
173
|
+
| `JEVMCP_CACHE_DIR` | `~/.cache/jev-mcp` | Cache location |
|
|
174
|
+
| `JEVMCP_CONFIG_DIR` | `~/.config/jev-mcp` | Where `setup` stores the key |
|
|
175
|
+
|
|
176
|
+
> **Note on OpenRouter**: Jev was announced for OpenRouter (`~typesafe/jev-latest`), but at publish time it does **not** appear in OpenRouter's public model catalog, and we could not verify a compatible call shape. If you route Jev through a gateway, set `JEVMCP_BASE_URL` accordingly and please open an issue with your findings.
|
|
177
|
+
|
|
178
|
+
## Decisions, not explanations
|
|
179
|
+
|
|
180
|
+
Jev's contract is: a decision, calibrated probabilities, and nothing else — no rationale text. That is why it is fast and cheap. When your assistant narrates *"jev chose E1 because..."*, that explanation is the assistant's **interpretation** of the numbers, not Jev's output. For formal reports (root-cause analyses, review verdicts), either let the LLM reason itself, or use the two-step pattern — Jev decides, LLM explains, clearly labeled.
|
|
181
|
+
|
|
182
|
+
## Benchmarks
|
|
183
|
+
|
|
184
|
+
Measured 2026-09 against `jev-1.13.0`, single questions, real sessions:
|
|
185
|
+
|
|
186
|
+
| Call | Latency | Input tokens | Output tokens |
|
|
187
|
+
|---|---|---|---|
|
|
188
|
+
| `choice`, 6 options | 615-678 ms | 344-1677 | 31-66 |
|
|
189
|
+
| `score`, 3 levels | ~500 ms | ~350 | ~30 |
|
|
190
|
+
| `noul` | ~400 ms | ~300 | ~25 |
|
|
191
|
+
|
|
192
|
+
At [$42 / 1B input tokens](https://typesafe.ai) a typical call costs ≈ $0.00002 — roughly two orders of magnitude below a frontier-LLM judgment call.
|
|
193
|
+
|
|
194
|
+
## Alternatives (fair and square)
|
|
195
|
+
|
|
196
|
+
- [jkudish/jev-mcp](https://github.com/jkudish/jev-mcp) — Node/npm, ten opinionated workflow tools (verify, screen, rerank, gate...). Pick it if you want ready-made agent-safety workflows.
|
|
197
|
+
- [itsmostafa/typesafe-mcp](https://github.com/itsmostafa/typesafe-mcp) — Go binary, one generic `evaluate` tool, one-command client setup.
|
|
198
|
+
|
|
199
|
+
`jev-mcp-server` is the close-to-the-metal option: the three official question types, named exactly as TypeSafe names them, with batch classify, bilingual docs, and measured numbers. Pick whichever fits your taste — they're all MIT.
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
```bash
|
|
204
|
+
uv sync
|
|
205
|
+
uv run ruff check .
|
|
206
|
+
uv run pytest -q
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Tests are fully offline (the HTTP layer is mocked; CI never spends API credits).
|
|
210
|
+
|
|
211
|
+
## License
|
|
212
|
+
|
|
213
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
jev_mcp_server/__init__.py,sha256=laVEHZoP3DMmo4xcJFACtkbrpiqJE4XtwZbpr5eE5bw,87
|
|
2
|
+
jev_mcp_server/cache.py,sha256=p-WlCur6dMGaZmRnBjeoHhQcD6h0coUyNIteLc-b1CQ,1516
|
|
3
|
+
jev_mcp_server/client.py,sha256=KoydREH5k-DUTfHQJ2zwPXirQVNtOXOU4wrXSmy-FHk,2458
|
|
4
|
+
jev_mcp_server/config.py,sha256=-GLo5L7rw0MWANH2UV-JvbVvk0wiKCqqSG-RjbbD2w8,1703
|
|
5
|
+
jev_mcp_server/server.py,sha256=_l-PoBe1isg6OonlYKz5bepPAFBha6Q8BgB25F1w4ZM,11725
|
|
6
|
+
jev_mcp_server-0.1.0.dist-info/METADATA,sha256=DY5OI8pGbTvHhn0XsXpJZlgtSU3xxp4Cy6h0WrhEaqg,9782
|
|
7
|
+
jev_mcp_server-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
jev_mcp_server-0.1.0.dist-info/entry_points.txt,sha256=z1C2nPCxZkmUHp-xMFUKjNrw74up5SbE938j-XxXJwc,62
|
|
9
|
+
jev_mcp_server-0.1.0.dist-info/licenses/LICENSE,sha256=KpMvmewxpr-bKh8ksJSS6ENHKp12dV1sZFPNNNdw24s,1071
|
|
10
|
+
jev_mcp_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wangkuangkuang
|
|
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.
|