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/server.py ADDED
@@ -0,0 +1,176 @@
1
+ """MCP entry point: stdio by default, Streamable HTTP when `JEV_MCP_TRANSPORT=streamable-http`.
2
+
3
+ stdout belongs to the protocol. Logging goes to stderr, and SIGINT/SIGTERM stop the
4
+ server and exit 0 without a traceback.
5
+ """
6
+
7
+ import contextlib
8
+ import gc
9
+ import logging
10
+ import os
11
+ import signal
12
+ import sys
13
+ from collections.abc import Callable, Generator, Iterable
14
+ from importlib.metadata import version
15
+ from typing import Any, override
16
+
17
+ import anyio
18
+ import uvicorn
19
+ from mcp.server.mcpserver import Context, MCPServer
20
+ from mcp.shared.exceptions import MCPError
21
+ from mcp.types import INTERNAL_ERROR, CallToolResult, CancelledNotificationParams, NotificationParams, Tool
22
+
23
+ from jev_mcp.errors import RedactingFilter, Redactor
24
+ from jev_mcp.serialize import stringify
25
+ from jev_mcp.settings import LogLevel, Settings, load_settings
26
+ from jev_mcp.stdio import stdio_streams
27
+ from jev_mcp.tools import TOOLS, Runtime, Toolset
28
+
29
+ SERVER_NAME = "jev-mcp"
30
+ DISTRIBUTION = "jev-mcp-python"
31
+
32
+ logger = logging.getLogger("jev_mcp")
33
+
34
+ NULL_ARGUMENTS = stringify(
35
+ [
36
+ {
37
+ "expected": "record",
38
+ "code": "invalid_type",
39
+ "path": ["params", "arguments"],
40
+ "message": "Invalid input: expected record, received null",
41
+ }
42
+ ]
43
+ )
44
+ """The TS SDK's text for `"arguments": null`: its request schema's zod issues, as a -32603."""
45
+
46
+
47
+ class JevMCPServer(MCPServer):
48
+ """`MCPServer` whose `tools/list` and `tools/call` both go through one `Toolset` (ADR-0013).
49
+
50
+ The decorator path derives schemas from Python signatures: it adds `title` keys, an
51
+ `outputSchema`, and never emits `execution`, so it cannot reproduce the TS snapshot
52
+ (ADR-0006, ADR-0010). The Toolset is the single registry: what it publishes is what it
53
+ dispatches, and it returns the reference's result shapes itself.
54
+ """
55
+
56
+ def __init__(self, *, toolset: Toolset, log_level: LogLevel) -> None:
57
+ super().__init__(name=SERVER_NAME, version=version(DISTRIBUTION), log_level=log_level)
58
+ self.toolset = toolset
59
+ # The SDK acts on both before any handler runs (the dispatcher cancels the request, the
60
+ # runner marks the session initialized); without a handler it logs each as unhandled.
61
+ self._lowlevel_server.add_notification_handler(
62
+ "notifications/cancelled", CancelledNotificationParams, _already_handled
63
+ )
64
+ self._lowlevel_server.add_notification_handler(
65
+ "notifications/initialized", NotificationParams, _already_handled
66
+ )
67
+
68
+ @override
69
+ async def list_tools(self) -> list[Tool]:
70
+ return self.toolset.definitions()
71
+
72
+ @override
73
+ async def call_tool(
74
+ self, name: str, arguments: dict[str, Any], context: Context[Any, Any] | None = None
75
+ ) -> CallToolResult:
76
+ # The SDK hands a missing or null `arguments` over as `{}`. The raw params tell them apart:
77
+ # the reference validates a missing one as `undefined`, which reports the root instead of each
78
+ # required field, and its request schema rejects null before any tool.
79
+ params = {} if context is None else context.request_context.params or {}
80
+ if "arguments" in params and params["arguments"] is None:
81
+ raise MCPError(code=INTERNAL_ERROR, message=NULL_ARGUMENTS)
82
+ sent = context is None or "arguments" in params
83
+ return await self.toolset.call(name, arguments if sent else None)
84
+
85
+ async def run_stdio_async(self) -> None:
86
+ """The SDK's stdio loop over this server's own transport (`jev_mcp.stdio`)."""
87
+ async with stdio_streams() as (read_stream, write_stream):
88
+ lowlevel = self._lowlevel_server
89
+ await lowlevel.run(read_stream, write_stream, lowlevel.create_initialization_options())
90
+
91
+
92
+ async def _already_handled(ctx: object, params: object) -> None:
93
+ return None
94
+
95
+
96
+ def build_server(settings: Settings) -> JevMCPServer:
97
+ """The server with every tool; `tools/list` and `tools/call` share the Toolset's one registry."""
98
+ return JevMCPServer(toolset=Toolset(Runtime(settings), TOOLS), log_level=settings.log_level)
99
+
100
+
101
+ def configure_logging(level: LogLevel, secrets: Iterable[str] = ()) -> None:
102
+ """Log to stderr. Every record, from any logger, is redacted of `secrets` first (ADR-0008)."""
103
+ handler = logging.StreamHandler(sys.stderr)
104
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
105
+ handler.addFilter(RedactingFilter(Redactor(secrets)))
106
+ root = logging.getLogger()
107
+ root.handlers[:] = [handler]
108
+ root.setLevel(level)
109
+
110
+
111
+ class _UvicornServer(uvicorn.Server):
112
+ """Leaves signals to `serve`, which owns them for both transports."""
113
+
114
+ @contextlib.contextmanager
115
+ def capture_signals(self) -> Generator[None]:
116
+ yield
117
+
118
+
119
+ async def _stop_on_signal(stop: Callable[[], None]) -> None:
120
+ with anyio.open_signal_receiver(signal.SIGINT, signal.SIGTERM) as signals:
121
+ async for signum in signals:
122
+ logger.info("received %s, shutting down", signal.Signals(signum).name)
123
+ stop()
124
+ return
125
+
126
+
127
+ async def serve(server: JevMCPServer, settings: Settings) -> None:
128
+ try:
129
+ await _serve(server, settings)
130
+ finally:
131
+ with anyio.CancelScope(shield=True):
132
+ await server.toolset.aclose()
133
+
134
+
135
+ async def _serve(server: JevMCPServer, settings: Settings) -> None:
136
+ async with anyio.create_task_group() as tg:
137
+ if settings.transport == "stdio":
138
+ tg.start_soon(_stop_on_signal, tg.cancel_scope.cancel)
139
+ await server.run_stdio_async()
140
+ else:
141
+ app = server.streamable_http_app(host=settings.http_host)
142
+ # log_config=None: uvicorn's loggers propagate to the stderr root handler.
143
+ config = uvicorn.Config(app, host=settings.http_host, port=settings.http_port, log_config=None)
144
+ http = _UvicornServer(config)
145
+
146
+ def stop() -> None:
147
+ http.should_exit = True
148
+
149
+ tg.start_soon(_stop_on_signal, stop)
150
+ await http.serve()
151
+ tg.cancel_scope.cancel()
152
+
153
+
154
+ def freeze_startup_heap() -> None:
155
+ """Move everything allocated so far (modules, schemas, the toolset) out of the collector's reach.
156
+
157
+ None of it becomes garbage, and a full collection that rescans it pauses every in-flight call
158
+ for 10-15 ms, which alone breaks the P9 p95 budget at 64 concurrent calls (`make load`).
159
+ """
160
+ gc.collect()
161
+ gc.freeze()
162
+
163
+
164
+ def main() -> None:
165
+ settings = load_settings()
166
+ configure_logging(settings.log_level, settings.secret_values())
167
+ server = build_server(settings)
168
+ freeze_startup_heap()
169
+ anyio.run(serve, server, settings)
170
+ # A stdin read abandoned at shutdown still blocks one of anyio's non-daemon worker threads, and
171
+ # interpreter exit would join it until the client closes stdin. `serve` has already closed
172
+ # everything the server owns, so flush and leave without joining it.
173
+ logging.shutdown()
174
+ sys.stdout.flush()
175
+ sys.stderr.flush()
176
+ os._exit(0)
jev_mcp/settings.py ADDED
@@ -0,0 +1,73 @@
1
+ """Process configuration, read once from the environment at startup (ADR-0008).
2
+
3
+ The environment is the only source: no `.env` file, no secrets dir, no constructor
4
+ overrides, no CLI flags. Provider variables keep the reference's names exactly, so
5
+ each field carries its own alias and there is no prefix. Values are kept raw;
6
+ provider resolution and the model default belong to the resolver (P4).
7
+ """
8
+
9
+ from typing import Literal, cast, get_args
10
+
11
+ from pydantic import Field, SecretStr
12
+ from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict
13
+
14
+ Transport = Literal["stdio", "streamable-http"]
15
+ LogLevel = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
16
+
17
+
18
+ class Settings(BaseSettings):
19
+ model_config = SettingsConfigDict(case_sensitive=True, extra="ignore", frozen=True)
20
+
21
+ # Provider selection and model (reference names; resolved in P4).
22
+ jev_provider: str = Field(default="auto", validation_alias="JEV_PROVIDER")
23
+ jev_mcp_model: str | None = Field(default=None, validation_alias="JEV_MCP_MODEL")
24
+
25
+ # Credentials and endpoints. Base URLs are secrets too: they can carry userinfo (ADR-0008).
26
+ typesafe_api_key: SecretStr | None = Field(default=None, validation_alias="TYPESAFE_API_KEY")
27
+ typesafe_base_url: SecretStr | None = Field(default=None, validation_alias="TYPESAFE_BASE_URL")
28
+ openrouter_api_key: SecretStr | None = Field(default=None, validation_alias="OPENROUTER_API_KEY")
29
+ jev_cloudflare_api_token: SecretStr | None = Field(default=None, validation_alias="JEV_CLOUDFLARE_API_TOKEN")
30
+ cloudflare_api_token: SecretStr | None = Field(default=None, validation_alias="CLOUDFLARE_API_TOKEN")
31
+ cloudflare_account_id: str | None = Field(default=None, validation_alias="CLOUDFLARE_ACCOUNT_ID")
32
+ ai_gateway_api_key: SecretStr | None = Field(default=None, validation_alias="AI_GATEWAY_API_KEY")
33
+ jev_api_key: SecretStr | None = Field(default=None, validation_alias="JEV_API_KEY")
34
+ jev_api_base_url: SecretStr | None = Field(default=None, validation_alias="JEV_API_BASE_URL")
35
+
36
+ # Python-only process settings; the reference is stdio-only.
37
+ transport: Transport = Field(default="stdio", validation_alias="JEV_MCP_TRANSPORT")
38
+ http_host: str = Field(default="127.0.0.1", validation_alias="JEV_MCP_HTTP_HOST")
39
+ http_port: int = Field(default=8000, ge=1, le=65535, validation_alias="JEV_MCP_HTTP_PORT")
40
+ log_level: LogLevel = Field(default="INFO", validation_alias="JEV_MCP_LOG_LEVEL")
41
+ # Debug only: let telemetry spans record payload text (arguments, results, patterns). Off by default.
42
+ telemetry_payloads: bool = Field(default=False, validation_alias="JEV_MCP_TELEMETRY_PAYLOADS")
43
+
44
+ @classmethod
45
+ def settings_customise_sources(
46
+ cls,
47
+ settings_cls: type[BaseSettings],
48
+ init_settings: PydanticBaseSettingsSource,
49
+ env_settings: PydanticBaseSettingsSource,
50
+ dotenv_settings: PydanticBaseSettingsSource,
51
+ file_secret_settings: PydanticBaseSettingsSource,
52
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
53
+ return (env_settings,)
54
+
55
+ def secret_values(self) -> list[str]:
56
+ """Every configured secret value, for redaction — derived from the schema (ADR-0008, ADR-0017).
57
+
58
+ Every `SecretStr` field participates, so a new credential cannot be forgotten; the naming
59
+ guard in `tests/unit/test_settings.py` fails if a credential-named field is not `SecretStr`.
60
+ """
61
+ values: list[str] = []
62
+ for name, field in type(self).model_fields.items():
63
+ annotation = field.annotation
64
+ if annotation is not SecretStr and SecretStr not in get_args(annotation):
65
+ continue
66
+ secret = cast("SecretStr | None", getattr(self, name))
67
+ if secret is not None:
68
+ values.append(secret.get_secret_value())
69
+ return values
70
+
71
+
72
+ def load_settings() -> Settings:
73
+ return Settings()
jev_mcp/stdio.py ADDED
@@ -0,0 +1,99 @@
1
+ """The stdio transport: newline-delimited JSON-RPC on stdin and stdout, as the reference's SDK speaks it.
2
+
3
+ It replaces the SDK's `stdio_server` for what that one cannot do:
4
+
5
+ - `JSON.parse` accepts a lone surrogate escape (`"\\ud83d"`) and keeps it. The SDK's parser (jiter)
6
+ rejected the whole line, so the request got no reply; it also accepts `NaN` and `Infinity`, which
7
+ `JSON.parse` rejects. Lines are read with `decode_json` instead, which keeps a lone surrogate as one
8
+ code point, with those constants refused.
9
+ - `JSON.stringify` writes a lone surrogate as a `\\udxxx` escape. pydantic cannot encode one, and the
10
+ SDK's writer would fail on the first reply that echoes it; such a frame goes through `stringify_compact`.
11
+ - The stdin read runs in a thread that shutdown can abandon: a blocked `readline()` would otherwise
12
+ hold the server open after a signal.
13
+
14
+ Undecodable input bytes still become U+FFFD, as Node's `Buffer.toString('utf8')` makes them in the
15
+ reference. While serving, fd 1 points at stderr so stray writes by handlers or children miss the wire.
16
+ The streams are plain anyio memory streams, not the SDK's context-carrying ones: a stdin reader has
17
+ no per-message contextvars to hand over, and the dispatcher then runs handlers in its own context.
18
+ """
19
+
20
+ import contextlib
21
+ import os
22
+ import sys
23
+ from collections.abc import AsyncGenerator, Generator
24
+ from io import TextIOWrapper
25
+ from typing import BinaryIO
26
+
27
+ import anyio
28
+ import anyio.to_thread
29
+ from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream
30
+ from mcp.shared.message import SessionMessage
31
+ from mcp.types import JSONRPCMessage, jsonrpc_message_adapter
32
+ from pydantic_core import PydanticSerializationError
33
+
34
+ from jev_mcp.domain import decode_json
35
+ from jev_mcp.serialize import stringify_compact
36
+
37
+ type Streams = tuple[MemoryObjectReceiveStream[SessionMessage | Exception], MemoryObjectSendStream[SessionMessage]]
38
+
39
+
40
+ def parse_line(line: str) -> SessionMessage | Exception:
41
+ """One JSON-RPC message read as `JSON.parse` reads it, or the error for the dispatcher to drop."""
42
+ try:
43
+ value = decode_json(line)
44
+ return SessionMessage(jsonrpc_message_adapter.validate_python(value, by_name=False))
45
+ except (ValueError, RecursionError) as error: # a pydantic ValidationError is a ValueError
46
+ return error
47
+
48
+
49
+ def encode_frame(message: JSONRPCMessage) -> str:
50
+ """The message as one JSON line's text, with any lone surrogate escaped as `JSON.stringify` does."""
51
+ try:
52
+ return message.model_dump_json(by_alias=True, exclude_unset=True)
53
+ except PydanticSerializationError:
54
+ return stringify_compact(message.model_dump(mode="json", by_alias=True, exclude_unset=True))
55
+
56
+
57
+ class _AbandonableLines(anyio.AsyncFile[str]):
58
+ async def readline(self) -> str:
59
+ return await anyio.to_thread.run_sync(self.wrapped.readline, abandon_on_cancel=True)
60
+
61
+
62
+ @contextlib.asynccontextmanager
63
+ async def stdio_streams() -> AsyncGenerator[Streams]:
64
+ """Read and write streams for `Server.run` over the process's stdin and stdout."""
65
+ stdin = _AbandonableLines(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
66
+ with _claim_stdout() as wire:
67
+ stdout = anyio.wrap_file(TextIOWrapper(wire, encoding="utf-8"))
68
+ read_writer, read_stream = anyio.create_memory_object_stream[SessionMessage | Exception](0)
69
+ write_stream, write_reader = anyio.create_memory_object_stream[SessionMessage](0)
70
+
71
+ async def read() -> None:
72
+ async with read_writer:
73
+ async for line in stdin:
74
+ await read_writer.send(parse_line(line))
75
+
76
+ async def write() -> None:
77
+ async with write_reader:
78
+ async for session_message in write_reader:
79
+ await stdout.write(encode_frame(session_message.message) + "\n")
80
+ await stdout.flush()
81
+
82
+ async with anyio.create_task_group() as tg:
83
+ tg.start_soon(read)
84
+ tg.start_soon(write)
85
+ yield read_stream, write_stream
86
+
87
+
88
+ @contextlib.contextmanager
89
+ def _claim_stdout() -> Generator[BinaryIO]:
90
+ """The wire on a private descriptor while fd 1 points at stderr; fd 1 is restored on exit."""
91
+ sys.stdout.flush()
92
+ private = os.dup(1)
93
+ os.dup2(2, 1)
94
+ try:
95
+ with os.fdopen(private, "wb", closefd=False) as wire:
96
+ yield wire
97
+ finally:
98
+ os.dup2(private, 1)
99
+ os.close(private)
jev_mcp/telemetry.py ADDED
@@ -0,0 +1,223 @@
1
+ """In-process spans and metrics (ROADMAP P9). Nothing here writes to stdout or the wire.
2
+
3
+ A span carries its name, a duration, and attributes that are counts, flags, or enumerated labels —
4
+ never evidence, claims, diffs, candidate text, or keys. Payload text is recorded only when the
5
+ `JEV_MCP_TELEMETRY_PAYLOADS` debug flag is on, and only into the span's separate `payloads`.
6
+
7
+ Every finished span goes to two sinks: `SpanLog` keeps recent spans and logs each one at DEBUG
8
+ (stderr, through the redacting handler), and `Metrics` folds it into counters and duration
9
+ histograms. The span tree follows the call: `mcp.tool` is the root, and `jev.evaluate`,
10
+ `jev.validate`, `jev.policy`, and `regex.extract` open under it through a context variable, so the
11
+ pure layers stay free of any recorder.
12
+ """
13
+
14
+ import logging
15
+ import math
16
+ from bisect import bisect_left
17
+ from collections import deque
18
+ from collections.abc import Callable
19
+ from contextvars import ContextVar
20
+ from dataclasses import dataclass, field
21
+ from time import perf_counter
22
+ from types import TracebackType
23
+ from typing import Final, Literal, Protocol
24
+
25
+ type SpanName = Literal["mcp.tool", "jev.evaluate", "jev.validate", "jev.policy", "regex.extract"]
26
+ type Attribute = bool | int | float | str
27
+
28
+ logger = logging.getLogger("jev_mcp.telemetry")
29
+
30
+
31
+ @dataclass(slots=True, eq=False)
32
+ class Span:
33
+ """One timed step. `duration` is in seconds; `payloads` stays empty unless the debug flag is on."""
34
+
35
+ name: SpanName
36
+ attributes: dict[str, Attribute]
37
+ parent: "Span | None" = None
38
+ duration: float = 0.0
39
+ payloads: dict[str, str] = field(default_factory=dict[str, str])
40
+
41
+
42
+ class SpanSink(Protocol):
43
+ def record(self, span: Span) -> None: ...
44
+
45
+
46
+ class SpanLog:
47
+ """The most recent finished spans, oldest first. Each is also logged at DEBUG."""
48
+
49
+ def __init__(self, capacity: int = 1024) -> None:
50
+ self.spans: deque[Span] = deque(maxlen=capacity)
51
+
52
+ def record(self, span: Span) -> None:
53
+ self.spans.append(span)
54
+ if logger.isEnabledFor(logging.DEBUG):
55
+ logger.debug("span %s", describe(span))
56
+
57
+
58
+ def describe(span: Span) -> str:
59
+ """`name duration_ms k=v ...`, payloads last. The log handler redacts configured secrets."""
60
+ parts = [span.name, f"{span.duration * 1000:.3f}ms"]
61
+ parts += [f"{key}={value}" for key, value in span.attributes.items()]
62
+ parts += [f"{key}={value!r}" for key, value in span.payloads.items()]
63
+ return " ".join(parts)
64
+
65
+
66
+ DURATION_BUCKETS_MS: Final = (0.1, 0.5, 1.0, 2.5, 5.0, 10.0, 20.0, 50.0, 100.0, 250.0, 1000.0, 5000.0, math.inf)
67
+ ACTIONS: Final = ("auto", "review", "escalate")
68
+ CAP_SCOPES: Final = ("context", "item")
69
+
70
+
71
+ class Metrics:
72
+ """Counters and per-span duration histograms, keyed Prometheus-style: `name{label="value"}`.
73
+
74
+ - `calls{tool}` and `outcomes{tool,outcome}` per `mcp.tool`. `actions{action}` counts each
75
+ call's one headline auto/review/escalate Action (a call with none adds nothing);
76
+ `item_actions{tool,action}` counts the per-item Actions of verify, classify and extract.
77
+ `truncated{scope}` counts calls that cut a document the judgment is made over (`context`)
78
+ or a candidate's or class's own text (`item`, telemetry only), per `validation/caps.py`.
79
+ - `provider_errors{error}` and `tokens{direction}` per `jev.evaluate`.
80
+ - `fail_closed{kind}` counts answers validation rejected.
81
+ - `regex_timeouts` counts patterns that ran out of time.
82
+ - `duration_ms_bucket{span,le}` (cumulative), `duration_ms_sum{span}`, `duration_ms_count{span}`.
83
+ """
84
+
85
+ def __init__(self) -> None:
86
+ self._values: dict[str, float] = {}
87
+ self._durations: dict[SpanName, _Histogram] = {}
88
+
89
+ def record(self, span: Span) -> None:
90
+ attributes = span.attributes
91
+ match span.name:
92
+ case "mcp.tool":
93
+ self._add(f'calls{{tool="{attributes["tool"]}"}}')
94
+ self._add(f'outcomes{{tool="{attributes["tool"]}",outcome="{attributes.get("outcome", "none")}"}}')
95
+ headline = attributes.get("action")
96
+ for action in ACTIONS:
97
+ self._add(f'actions{{action="{action}"}}', int(headline == action))
98
+ items = _number(attributes.get(f"item_actions.{action}", 0))
99
+ if items:
100
+ self._add(f'item_actions{{tool="{attributes["tool"]}",action="{action}"}}', items)
101
+ for scope in CAP_SCOPES:
102
+ if attributes.get(f"truncated.{scope}") is True:
103
+ self._add(f'truncated{{scope="{scope}"}}')
104
+ case "jev.evaluate":
105
+ if "error" in attributes:
106
+ self._add(f'provider_errors{{error="{attributes["error"]}"}}')
107
+ self._add('tokens{direction="input"}', _number(attributes.get("input_tokens", 0)))
108
+ self._add('tokens{direction="output"}', _number(attributes.get("output_tokens", 0)))
109
+ case "jev.validate":
110
+ if attributes.get("valid") is False:
111
+ self._add(f'fail_closed{{kind="{attributes["kind"]}"}}')
112
+ case "regex.extract":
113
+ if attributes.get("outcome") == "timeout":
114
+ self._add("regex_timeouts")
115
+ case "jev.policy":
116
+ pass
117
+ self._observe(span.name, span.duration * 1000)
118
+
119
+ def _add(self, key: str, amount: float = 1) -> None:
120
+ self._values[key] = self._values.get(key, 0) + amount
121
+
122
+ def _observe(self, name: SpanName, ms: float) -> None:
123
+ histogram = self._durations.get(name)
124
+ if histogram is None:
125
+ histogram = self._durations[name] = _Histogram()
126
+ histogram.observe(ms)
127
+
128
+ def snapshot(self) -> dict[str, float]:
129
+ values = dict(self._values)
130
+ for name, histogram in self._durations.items():
131
+ cumulative = 0
132
+ for bound, count in zip(DURATION_BUCKETS_MS, histogram.counts, strict=True):
133
+ cumulative += count
134
+ if cumulative:
135
+ le = "+Inf" if bound == math.inf else f"{bound:g}"
136
+ values[f'duration_ms_bucket{{span="{name}",le="{le}"}}'] = cumulative
137
+ values[f'duration_ms_sum{{span="{name}"}}'] = histogram.total
138
+ values[f'duration_ms_count{{span="{name}"}}'] = histogram.count
139
+ return dict(sorted(values.items()))
140
+
141
+
142
+ class _Histogram:
143
+ """Per-bucket counts, cumulated only at snapshot time: observing is one bisect and three adds."""
144
+
145
+ __slots__ = ("count", "counts", "total")
146
+
147
+ def __init__(self) -> None:
148
+ self.counts = [0] * len(DURATION_BUCKETS_MS)
149
+ self.total = 0.0
150
+ self.count = 0
151
+
152
+ def observe(self, ms: float) -> None:
153
+ self.counts[bisect_left(DURATION_BUCKETS_MS, ms)] += 1
154
+ self.total += ms
155
+ self.count += 1
156
+
157
+
158
+ def _number(value: Attribute) -> float:
159
+ return value if isinstance(value, int | float) and not isinstance(value, bool) else 0
160
+
161
+
162
+ _active: ContextVar[tuple["Telemetry", Span] | None] = ContextVar("jev_mcp_telemetry_span", default=None)
163
+
164
+
165
+ class Telemetry:
166
+ """A server's recorder: opens spans and hands each finished one to the span log and metrics."""
167
+
168
+ def __init__(self, *, payloads: bool = False, capacity: int = 1024) -> None:
169
+ self.payloads = payloads
170
+ """The debug flag: whether spans may record payload text."""
171
+ self.spans = SpanLog(capacity)
172
+ self.metrics = Metrics()
173
+ self._sinks: tuple[SpanSink, ...] = (self.spans, self.metrics)
174
+
175
+ def span(self, name: SpanName, **attributes: Attribute) -> "_Scope":
176
+ """Time a `with` block as a child of the active span. An escaping exception is named, never quoted."""
177
+ return _Scope(self, name, attributes)
178
+
179
+ def record(self, span: Span) -> None:
180
+ for sink in self._sinks:
181
+ sink.record(span)
182
+
183
+ def payload(self, span: Span, key: str, text: Callable[[], str]) -> None:
184
+ """Record payload text on `span` only under the debug flag; `text` is not called otherwise."""
185
+ if self.payloads:
186
+ span.payloads[key] = text()
187
+
188
+
189
+ class _Scope:
190
+ """The `with` block behind a span; a class rather than a generator, as spans sit on the hot path."""
191
+
192
+ __slots__ = ("_start", "_telemetry", "_token", "span")
193
+
194
+ def __init__(self, telemetry: Telemetry | None, name: SpanName, attributes: dict[str, Attribute]) -> None:
195
+ self._telemetry = telemetry
196
+ self.span = Span(name, attributes)
197
+
198
+ def __enter__(self) -> Span:
199
+ if self._telemetry is not None:
200
+ active = _active.get()
201
+ self.span.parent = None if active is None else active[1]
202
+ self._token = _active.set((self._telemetry, self.span))
203
+ self._start = perf_counter()
204
+ return self.span
205
+
206
+ def __exit__(self, kind: type[BaseException] | None, error: BaseException | None, _: TracebackType | None) -> None:
207
+ if self._telemetry is None:
208
+ return
209
+ span = self.span
210
+ span.duration = perf_counter() - self._start
211
+ _active.reset(self._token)
212
+ if kind is not None:
213
+ if issubclass(kind, Exception):
214
+ span.attributes["error"] = kind.__name__
215
+ else:
216
+ span.attributes["cancelled"] = True
217
+ self._telemetry.record(span)
218
+
219
+
220
+ def span(name: SpanName, **attributes: Attribute) -> _Scope:
221
+ """A child of the active span, recorded by its telemetry. Outside any span, nothing is recorded."""
222
+ active = _active.get()
223
+ return _Scope(None if active is None else active[0], name, attributes)
jev_mcp/text.py ADDED
@@ -0,0 +1,42 @@
1
+ """UTF-16 `length` and `truncate` (ADR-0005).
2
+
3
+ Every cap in the reference is a JS `.length`, which counts UTF-16 code units. An astral
4
+ character (U+10000 and up) is two units there and one code point in Python.
5
+ """
6
+
7
+ TRUNCATION_MARKER = " […truncated]"
8
+
9
+ _HIGH_SURROGATES = range(0xD800, 0xDC00)
10
+ _LOW_SURROGATES = range(0xDC00, 0xE000)
11
+
12
+
13
+ def length(text: str) -> int:
14
+ """JS `text.length`: the number of UTF-16 code units. Lone surrogates count as one unit."""
15
+ return len(text.encode("utf-16-le", "surrogatepass")) // 2
16
+
17
+
18
+ def truncate(text: str, max_units: int) -> str:
19
+ """JS `truncate` (`lib.ts:47-50`) measured in UTF-16 code units.
20
+
21
+ When the cut splits a surrogate pair, the reference keeps the lone high surrogate (quirk Q8);
22
+ this drops it instead, so the result is one unit shorter (ADR-0005). A lone surrogate already
23
+ present in `text` is not a split pair and is kept.
24
+ """
25
+ if length(text) <= max_units:
26
+ return text
27
+ return head(text, max_units) + TRUNCATION_MARKER
28
+
29
+
30
+ def head(text: str, max_units: int) -> str:
31
+ """JS `text.slice(0, max_units)`, except that a split surrogate pair is dropped whole (ADR-0005)."""
32
+ units = 0
33
+ cut = 0
34
+ for index, char in enumerate(text):
35
+ units += 2 if ord(char) > 0xFFFF else 1
36
+ if units > max_units:
37
+ break # an astral character straddling the cut is the split pair: keep none of it
38
+ cut = index + 1
39
+ result = text[:cut]
40
+ if result and ord(result[-1]) in _HIGH_SURROGATES and cut < len(text) and ord(text[cut]) in _LOW_SURROGATES:
41
+ result = result[:-1] # a pair held as two code points is split the same way
42
+ return result
@@ -0,0 +1,20 @@
1
+ """The Jev MCP tools, published in the reference's registration order (`parity-manifest.json` `tools_list_order`)."""
2
+
3
+ from jev_mcp.tools import classify, compare, decide, extract, find, gate, rerank, review, screen, verify
4
+ from jev_mcp.tools.base import JevTool, Runtime, ToolError, ToolResult
5
+ from jev_mcp.tools.toolset import Toolset
6
+
7
+ TOOLS: tuple[JevTool, ...] = (
8
+ verify.TOOL,
9
+ screen.TOOL,
10
+ find.TOOL,
11
+ classify.TOOL,
12
+ decide.TOOL,
13
+ rerank.TOOL,
14
+ compare.TOOL,
15
+ extract.TOOL,
16
+ review.TOOL,
17
+ gate.TOOL,
18
+ )
19
+
20
+ __all__ = ["TOOLS", "JevTool", "Runtime", "ToolError", "ToolResult", "Toolset"]