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.
Files changed (65) hide show
  1. jev_mcp/__init__.py +1 -0
  2. jev_mcp/__main__.py +3 -0
  3. jev_mcp/domain/__init__.py +32 -0
  4. jev_mcp/domain/answers.py +25 -0
  5. jev_mcp/domain/json.py +49 -0
  6. jev_mcp/domain/questions.py +75 -0
  7. jev_mcp/domain/usage.py +16 -0
  8. jev_mcp/errors.py +59 -0
  9. jev_mcp/extract/__init__.py +1 -0
  10. jev_mcp/extract/candidates.py +75 -0
  11. jev_mcp/extract/dialect.py +400 -0
  12. jev_mcp/extract/executor.py +118 -0
  13. jev_mcp/extract/worker.py +198 -0
  14. jev_mcp/ids.py +49 -0
  15. jev_mcp/limits.py +218 -0
  16. jev_mcp/policy/__init__.py +98 -0
  17. jev_mcp/policy/actions.py +41 -0
  18. jev_mcp/policy/claims.py +103 -0
  19. jev_mcp/policy/extract.py +73 -0
  20. jev_mcp/policy/ranking.py +41 -0
  21. jev_mcp/policy/review.py +73 -0
  22. jev_mcp/policy/screen.py +48 -0
  23. jev_mcp/policy/thresholds.py +74 -0
  24. jev_mcp/providers/__init__.py +26 -0
  25. jev_mcp/providers/base.py +236 -0
  26. jev_mcp/providers/cloudflare.py +59 -0
  27. jev_mcp/providers/compatible.py +43 -0
  28. jev_mcp/providers/openrouter.py +47 -0
  29. jev_mcp/providers/resolver.py +106 -0
  30. jev_mcp/providers/typesafe.py +127 -0
  31. jev_mcp/py.typed +0 -0
  32. jev_mcp/serialize.py +199 -0
  33. jev_mcp/server.py +176 -0
  34. jev_mcp/settings.py +73 -0
  35. jev_mcp/stdio.py +99 -0
  36. jev_mcp/telemetry.py +223 -0
  37. jev_mcp/text.py +42 -0
  38. jev_mcp/tools/__init__.py +20 -0
  39. jev_mcp/tools/arguments.py +447 -0
  40. jev_mcp/tools/base.py +153 -0
  41. jev_mcp/tools/classify.py +187 -0
  42. jev_mcp/tools/common.py +96 -0
  43. jev_mcp/tools/compare.py +143 -0
  44. jev_mcp/tools/decide.py +206 -0
  45. jev_mcp/tools/extract.py +262 -0
  46. jev_mcp/tools/find.py +113 -0
  47. jev_mcp/tools/gate.py +236 -0
  48. jev_mcp/tools/observed.py +69 -0
  49. jev_mcp/tools/rerank.py +139 -0
  50. jev_mcp/tools/review.py +236 -0
  51. jev_mcp/tools/screen.py +126 -0
  52. jev_mcp/tools/toolset.py +92 -0
  53. jev_mcp/tools/verify.py +141 -0
  54. jev_mcp/validation/__init__.py +25 -0
  55. jev_mcp/validation/caps.py +93 -0
  56. jev_mcp/validation/choice.py +65 -0
  57. jev_mcp/validation/extract.py +48 -0
  58. jev_mcp/validation/noul.py +15 -0
  59. jev_mcp/validation/numbers.py +21 -0
  60. jev_mcp/validation/score.py +20 -0
  61. jev_mcp_python-0.1.0.dist-info/METADATA +18 -0
  62. jev_mcp_python-0.1.0.dist-info/RECORD +65 -0
  63. jev_mcp_python-0.1.0.dist-info/WHEEL +4 -0
  64. jev_mcp_python-0.1.0.dist-info/entry_points.txt +2 -0
  65. 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,3 @@
1
+ from jev_mcp.server import main
2
+
3
+ main()
@@ -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()}
@@ -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])