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/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"]
|