jev-mcp-python 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/__init__.py +1 -0
- jev_mcp/__main__.py +3 -0
- jev_mcp/domain/__init__.py +32 -0
- jev_mcp/domain/answers.py +25 -0
- jev_mcp/domain/json.py +49 -0
- jev_mcp/domain/questions.py +75 -0
- jev_mcp/domain/usage.py +16 -0
- jev_mcp/errors.py +59 -0
- jev_mcp/extract/__init__.py +1 -0
- jev_mcp/extract/candidates.py +75 -0
- jev_mcp/extract/dialect.py +400 -0
- jev_mcp/extract/executor.py +118 -0
- jev_mcp/extract/worker.py +198 -0
- jev_mcp/ids.py +49 -0
- jev_mcp/limits.py +218 -0
- jev_mcp/policy/__init__.py +98 -0
- jev_mcp/policy/actions.py +41 -0
- jev_mcp/policy/claims.py +103 -0
- jev_mcp/policy/extract.py +73 -0
- jev_mcp/policy/ranking.py +41 -0
- jev_mcp/policy/review.py +73 -0
- jev_mcp/policy/screen.py +48 -0
- jev_mcp/policy/thresholds.py +74 -0
- jev_mcp/providers/__init__.py +26 -0
- jev_mcp/providers/base.py +236 -0
- jev_mcp/providers/cloudflare.py +59 -0
- jev_mcp/providers/compatible.py +43 -0
- jev_mcp/providers/openrouter.py +47 -0
- jev_mcp/providers/resolver.py +106 -0
- jev_mcp/providers/typesafe.py +127 -0
- jev_mcp/py.typed +0 -0
- jev_mcp/serialize.py +199 -0
- jev_mcp/server.py +176 -0
- jev_mcp/settings.py +73 -0
- jev_mcp/stdio.py +99 -0
- jev_mcp/telemetry.py +223 -0
- jev_mcp/text.py +42 -0
- jev_mcp/tools/__init__.py +20 -0
- jev_mcp/tools/arguments.py +447 -0
- jev_mcp/tools/base.py +153 -0
- jev_mcp/tools/classify.py +187 -0
- jev_mcp/tools/common.py +96 -0
- jev_mcp/tools/compare.py +143 -0
- jev_mcp/tools/decide.py +206 -0
- jev_mcp/tools/extract.py +262 -0
- jev_mcp/tools/find.py +113 -0
- jev_mcp/tools/gate.py +236 -0
- jev_mcp/tools/observed.py +69 -0
- jev_mcp/tools/rerank.py +139 -0
- jev_mcp/tools/review.py +236 -0
- jev_mcp/tools/screen.py +126 -0
- jev_mcp/tools/toolset.py +92 -0
- jev_mcp/tools/verify.py +141 -0
- jev_mcp/validation/__init__.py +25 -0
- jev_mcp/validation/caps.py +93 -0
- jev_mcp/validation/choice.py +65 -0
- jev_mcp/validation/extract.py +48 -0
- jev_mcp/validation/noul.py +15 -0
- jev_mcp/validation/numbers.py +21 -0
- jev_mcp/validation/score.py +20 -0
- jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
- jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
- jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
- jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
- jev_mcp_python-0.1.0.dist-info/licenses/LICENSE +21 -0
jev_mcp/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Behavior-compatible Python rewrite of the TypeScript reference server 0.5.0 (see ROADMAP.md)."""
|
jev_mcp/__main__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Canonical questions, answers, and usage owned by jev_mcp, not SDK classes."""
|
|
2
|
+
|
|
3
|
+
from jev_mcp.domain.answers import ChoiceAnswer, RawAnswer, ScoreAnswer
|
|
4
|
+
from jev_mcp.domain.json import JsonValue, as_number, decode_json, is_json_object
|
|
5
|
+
from jev_mcp.domain.questions import (
|
|
6
|
+
ChoiceQuestion,
|
|
7
|
+
Description,
|
|
8
|
+
NoulCriteria,
|
|
9
|
+
NoulQuestion,
|
|
10
|
+
Question,
|
|
11
|
+
ScoreQuestion,
|
|
12
|
+
questions_to_wire,
|
|
13
|
+
)
|
|
14
|
+
from jev_mcp.domain.usage import Usage
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"ChoiceAnswer",
|
|
18
|
+
"ChoiceQuestion",
|
|
19
|
+
"Description",
|
|
20
|
+
"JsonValue",
|
|
21
|
+
"NoulCriteria",
|
|
22
|
+
"NoulQuestion",
|
|
23
|
+
"Question",
|
|
24
|
+
"RawAnswer",
|
|
25
|
+
"ScoreAnswer",
|
|
26
|
+
"ScoreQuestion",
|
|
27
|
+
"Usage",
|
|
28
|
+
"as_number",
|
|
29
|
+
"decode_json",
|
|
30
|
+
"is_json_object",
|
|
31
|
+
"questions_to_wire",
|
|
32
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Validated answers. Raw answers are whatever JSON the provider returned; validation turns them into these."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
type RawAnswer = object
|
|
6
|
+
"""One answer as parsed from the provider envelope, before validation. Anything may arrive."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True, slots=True)
|
|
10
|
+
class ChoiceAnswer:
|
|
11
|
+
"""A Choice answer that passed `validate_choice`."""
|
|
12
|
+
|
|
13
|
+
choice: str
|
|
14
|
+
probabilities: dict[str, float]
|
|
15
|
+
"""Every expected label, in the provider's key order."""
|
|
16
|
+
confidence: float | None
|
|
17
|
+
"""`None` when the provider sent none or a malformed one. Unknown never satisfies a threshold."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class ScoreAnswer:
|
|
22
|
+
"""A Score answer that passed `validate_score`."""
|
|
23
|
+
|
|
24
|
+
score: float
|
|
25
|
+
confidence: float | None
|
jev_mcp/domain/json.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""JSON value types, JSON text read as `JSON.parse` reads it, and parsed-JSON value-shape semantics (ADR-0019).
|
|
2
|
+
|
|
3
|
+
Beyond decoding, this module answers exactly two kinds of question about a parsed JSON value: what
|
|
4
|
+
JS `typeof` would say, and what shape it is. Nothing else belongs here — no answer semantics, no
|
|
5
|
+
provider rules, no normalization; those live above, in `validation/` and `providers/`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import math
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from typing import TypeGuard
|
|
12
|
+
|
|
13
|
+
type JsonValue = str | int | float | bool | Sequence[JsonValue] | Mapping[str, JsonValue] | None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def decode_json(text: str) -> object:
|
|
17
|
+
"""`JSON.parse(text)`, raising `ValueError` where it throws.
|
|
18
|
+
|
|
19
|
+
`json.loads` also reads `NaN`, `Infinity`, and `-Infinity`, which `JSON.parse` rejects, so those
|
|
20
|
+
are refused here. A lone surrogate escape is kept as one code point, as `JSON.parse` keeps it.
|
|
21
|
+
Nesting too deep for Python's recursion limit is a `ValueError` too.
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
return json.loads(text, parse_constant=_reject_constant)
|
|
25
|
+
except RecursionError as error:
|
|
26
|
+
raise ValueError("JSON nesting is too deep") from error
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _reject_constant(name: str) -> object:
|
|
30
|
+
raise ValueError(f"{name} is not JSON")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_json_object(value: object) -> TypeGuard[dict[str, object]]:
|
|
34
|
+
"""A JSON object: not null, not an array, not a primitive (`index.ts:1170-1172`)."""
|
|
35
|
+
return isinstance(value, dict)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def as_number(value: object) -> float | None:
|
|
39
|
+
"""The float64 a JS `number` would hold, or `None` when `typeof value !== "number"`.
|
|
40
|
+
|
|
41
|
+
`bool` is an `int` in Python but a boolean in JS, so it is not a number. An integer too large
|
|
42
|
+
for a double becomes infinity, as `JSON.parse` would make it.
|
|
43
|
+
"""
|
|
44
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
return float(value)
|
|
48
|
+
except OverflowError:
|
|
49
|
+
return math.inf if value > 0 else -math.inf
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Canonical Choice, Noul, and Score questions, owned here rather than by any provider SDK.
|
|
2
|
+
|
|
3
|
+
`to_wire()` reproduces the object the reference builds with `@typesafe-ai/sdk` 0.6.0
|
|
4
|
+
`choice`/`noul`/`score`: keys `type`, `instructions`, `criteria`, in that order. The reference
|
|
5
|
+
never omits Noul criteria, so a Noul without criteria is not representable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from jev_mcp.domain.json import JsonValue
|
|
13
|
+
|
|
14
|
+
type Description = JsonValue
|
|
15
|
+
"""Text, a JSON object or array, or `None` for an undescribed label (the SDK's `EntryType`)."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class ChoiceQuestion:
|
|
20
|
+
"""Pick one label; every label gets a probability."""
|
|
21
|
+
|
|
22
|
+
instructions: Description
|
|
23
|
+
criteria: Mapping[str, Description]
|
|
24
|
+
type: Literal["choice"] = "choice"
|
|
25
|
+
|
|
26
|
+
def to_wire(self) -> dict[str, JsonValue]:
|
|
27
|
+
return {"type": self.type, "instructions": self.instructions, "criteria": dict(self.criteria)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class NoulCriteria:
|
|
32
|
+
"""Descriptions of the yes (`true`) and no (`false`) outcomes."""
|
|
33
|
+
|
|
34
|
+
true: Description
|
|
35
|
+
false: Description
|
|
36
|
+
|
|
37
|
+
def to_wire(self) -> dict[str, JsonValue]:
|
|
38
|
+
return {"true": self.true, "false": self.false}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class NoulQuestion:
|
|
43
|
+
"""The probability that a yes/no condition holds; near 0.5 means uncertain, not medium."""
|
|
44
|
+
|
|
45
|
+
instructions: Description
|
|
46
|
+
criteria: NoulCriteria
|
|
47
|
+
type: Literal["noul"] = "noul"
|
|
48
|
+
|
|
49
|
+
def to_wire(self) -> dict[str, JsonValue]:
|
|
50
|
+
return {"type": self.type, "instructions": self.instructions, "criteria": self.criteria.to_wire()}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class ScoreQuestion:
|
|
55
|
+
"""A probability-weighted position on ordered levels, 0-indexed: N levels score 0 to N-1."""
|
|
56
|
+
|
|
57
|
+
instructions: Description
|
|
58
|
+
criteria: Sequence[Description]
|
|
59
|
+
type: Literal["score"] = "score"
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
# The SDK rejects a score question with fewer than two levels before sending it.
|
|
63
|
+
if len(self.criteria) < 2:
|
|
64
|
+
raise ValueError("Score criteria must list at least two levels.")
|
|
65
|
+
|
|
66
|
+
def to_wire(self) -> dict[str, JsonValue]:
|
|
67
|
+
return {"type": self.type, "instructions": self.instructions, "criteria": list(self.criteria)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
type Question = ChoiceQuestion | NoulQuestion | ScoreQuestion
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def questions_to_wire(questions: Mapping[str, Question]) -> dict[str, JsonValue]:
|
|
74
|
+
"""The request body's `questions` object, in the caller's key order."""
|
|
75
|
+
return {name: question.to_wire() for name, question in questions.items()}
|
jev_mcp/domain/usage.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Token usage reported by a provider."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from jev_mcp.domain.json import JsonValue
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class Usage:
|
|
10
|
+
"""Token counts for one provider request. A provider that reports none yields zeros (`provider.ts:120`)."""
|
|
11
|
+
|
|
12
|
+
input_tokens: int | float = 0
|
|
13
|
+
output_tokens: int | float = 0
|
|
14
|
+
|
|
15
|
+
def to_wire(self) -> dict[str, JsonValue]:
|
|
16
|
+
return {"input_tokens": self.input_tokens, "output_tokens": self.output_tokens}
|
jev_mcp/errors.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Error types and handler error text, and secret redaction (ADR-0008)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
from urllib.parse import urlsplit
|
|
6
|
+
|
|
7
|
+
REDACTED = "[redacted]"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Redactor:
|
|
11
|
+
"""Replaces every configured secret with `[redacted]` in MCP-visible text and log lines (ADR-0008).
|
|
12
|
+
|
|
13
|
+
Replacement is of the exact value, so `Bearer <secret>` is covered. A secret that is a URL with
|
|
14
|
+
userinfo also contributes its userinfo and password: an HTTP client may print the URL
|
|
15
|
+
normalized (lowercased host, added `/`), and the credential must not survive that.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, secrets: Iterable[str]) -> None:
|
|
19
|
+
values = {value for secret in secrets for value in (secret, *_userinfo_parts(secret)) if value}
|
|
20
|
+
# Longest first, so a secret containing another is replaced whole.
|
|
21
|
+
self._secrets = sorted(values, key=len, reverse=True)
|
|
22
|
+
|
|
23
|
+
def __call__(self, text: str) -> str:
|
|
24
|
+
for secret in self._secrets:
|
|
25
|
+
text = text.replace(secret, REDACTED)
|
|
26
|
+
return text
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _userinfo_parts(secret: str) -> tuple[str, ...]:
|
|
30
|
+
try:
|
|
31
|
+
parts = urlsplit(secret)
|
|
32
|
+
except ValueError:
|
|
33
|
+
return ()
|
|
34
|
+
if not parts.scheme or "@" not in parts.netloc:
|
|
35
|
+
return ()
|
|
36
|
+
userinfo = parts.netloc.rpartition("@")[0]
|
|
37
|
+
password = userinfo.partition(":")[2]
|
|
38
|
+
return (userinfo, password)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RedactingFilter(logging.Filter):
|
|
42
|
+
"""A handler filter that redacts the fully formatted message, including exception text."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, redact: Redactor) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self._redact = redact
|
|
47
|
+
|
|
48
|
+
def filter(self, record: logging.LogRecord) -> bool:
|
|
49
|
+
record.msg = self._redact(record.getMessage())
|
|
50
|
+
record.args = None
|
|
51
|
+
if record.exc_info is not None:
|
|
52
|
+
# Render the traceback now, redacted, so the formatter never sees the raw exception.
|
|
53
|
+
record.exc_text = self._redact(logging.Formatter().formatException(record.exc_info))
|
|
54
|
+
record.exc_info = None
|
|
55
|
+
elif record.exc_text:
|
|
56
|
+
record.exc_text = self._redact(record.exc_text)
|
|
57
|
+
if record.stack_info:
|
|
58
|
+
record.stack_info = self._redact(record.stack_info)
|
|
59
|
+
return True
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""jev_extract regex dialect gate and killable worker pool (ADR-0004). Populated in P5."""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""One field's candidate pipeline: caller pattern and flags in, verbatim candidates or a refusal out.
|
|
2
|
+
|
|
3
|
+
The whole path the reference runs per field (`index.ts:884-903`), for production and the Node
|
|
4
|
+
differential test alike: normalize the flags, translate the dialect (ADR-0004), take the absolute
|
|
5
|
+
deadline before admission (ADR-0016), run the executor, map candidates back from unit space, and
|
|
6
|
+
classify every refusal into its caller-facing reason. Executors return plain results
|
|
7
|
+
(`executor.MatchResult`); the frozen reason text lives only here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from time import monotonic
|
|
12
|
+
from typing import Final, Literal
|
|
13
|
+
|
|
14
|
+
from jev_mcp.extract.dialect import PatternRejected, from_units, translate
|
|
15
|
+
from jev_mcp.extract.executor import Invalid, Matches, RegexExecutor, Saturated, Timeout, Unavailable
|
|
16
|
+
|
|
17
|
+
REGEX_TIMEOUT_S: Final = 1.0
|
|
18
|
+
"""`REGEX_TIMEOUT_MS` (`lib.ts:185`)."""
|
|
19
|
+
|
|
20
|
+
REGEX_TIMEOUT_REASON: Final = "regex timed out after 1000ms; simplify the pattern"
|
|
21
|
+
|
|
22
|
+
REGEX_POOL_SATURATED_REASON: Final = "regex_pool_saturated"
|
|
23
|
+
"""ADR-0025: a queue-bound refusal is a capacity signal, not a pattern problem — no timeout
|
|
24
|
+
figure, no advice about the pattern, a stable token an agent (or P9) can tell apart."""
|
|
25
|
+
|
|
26
|
+
_UNAVAILABLE_REASONS: Final = {
|
|
27
|
+
Unavailable.NO_RESULT: "regex worker exited without a result",
|
|
28
|
+
Unavailable.NOT_STARTED: "regex worker did not start",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class Found:
|
|
34
|
+
"""The field's candidates, verbatim document text in match order."""
|
|
35
|
+
|
|
36
|
+
candidates: list[str]
|
|
37
|
+
truncated: bool
|
|
38
|
+
too_long: int
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Refused:
|
|
43
|
+
"""No candidates: `reason` is the caller-facing text, `outcome` the telemetry label."""
|
|
44
|
+
|
|
45
|
+
outcome: Literal["rejected", "saturated", "timeout", "worker_error"]
|
|
46
|
+
reason: str
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def normalize_flags(flags: str) -> str:
|
|
50
|
+
"""`((flags ?? "").replace(/[^a-z]/g, "") + "g").replace(/g+/g, "g")` (`index.ts:992`)."""
|
|
51
|
+
letters = "".join(char for char in flags if "a" <= char <= "z") + "g"
|
|
52
|
+
out: list[str] = []
|
|
53
|
+
for char in letters:
|
|
54
|
+
if not (char == "g" and out and out[-1] == "g"):
|
|
55
|
+
out.append(char)
|
|
56
|
+
return "".join(out)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def find_candidates(executor: RegexExecutor, pattern: str, flags: str, units: str) -> Found | Refused:
|
|
60
|
+
"""Run caller `pattern` with caller `flags` over unit-space `units` (`dialect.to_units`)."""
|
|
61
|
+
try:
|
|
62
|
+
translated = translate(pattern, normalize_flags(flags))
|
|
63
|
+
except PatternRejected as rejected:
|
|
64
|
+
return Refused("rejected", str(rejected))
|
|
65
|
+
result = await executor.find(translated, units, deadline=monotonic() + REGEX_TIMEOUT_S)
|
|
66
|
+
match result:
|
|
67
|
+
case Matches(candidates, truncated, too_long):
|
|
68
|
+
return Found([from_units(candidate) for candidate in candidates], truncated, too_long)
|
|
69
|
+
case Saturated():
|
|
70
|
+
# Its own reason: the refusal spends no time, so it must not borrow the timeout's voice.
|
|
71
|
+
return Refused("saturated", REGEX_POOL_SATURATED_REASON)
|
|
72
|
+
case Timeout():
|
|
73
|
+
return Refused("timeout", REGEX_TIMEOUT_REASON)
|
|
74
|
+
case Invalid(cause):
|
|
75
|
+
return Refused("worker_error", _UNAVAILABLE_REASONS[cause])
|