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
@@ -0,0 +1,447 @@
1
+ """Tool argument validation with the reference's observable behavior: zod 3 run by the TS MCP SDK 1.30.
2
+
3
+ The published `inputSchema` (draft-07, as `zod-to-json-schema` printed it) drives validation, so the
4
+ schema a client sees is the one enforced. `compile_argument_schema` turns it into an immutable
5
+ parser once, at `Toolset` construction (ADR-0022): every keyword is consumed by the node that
6
+ parses it, and a keyword nothing consumes fails construction instead of being silently ignored.
7
+
8
+ zod semantics that the JSON Schema does not state are reproduced here:
9
+
10
+ - Unknown object keys are stripped, not rejected, although the schema says `additionalProperties: false`.
11
+ - A parsed object holds its schema's keys in schema order, only those the caller sent.
12
+ - A failed check either aborts its value (wrong type, missing, failed union) or only dirties it
13
+ (length, bounds, pattern, integer); an object or array with an aborted member is aborted. A union
14
+ returns its first valid option, else the issues of its first dirty option, else `Invalid input`.
15
+ - Checks run in zod's order: integer, minimum, maximum; minLength, pattern, maxLength; array
16
+ lengths before items. Lengths are UTF-16 units (ADR-0005).
17
+ - The SDK parses asynchronously. Union issues, and the refinements that run after a union, reach the
18
+ error list after every synchronous issue of the whole call, so they are listed last.
19
+
20
+ The error text is the SDK's: `MCP error -32602: Input validation error: Invalid arguments for tool
21
+ {name}: {issues}`, one `{message} at {path}` per issue, joined by newlines (`zod-compat.js:122-166`).
22
+ """
23
+
24
+ import math
25
+ import re
26
+ from collections.abc import Callable, Mapping, Sequence
27
+ from dataclasses import dataclass, field
28
+ from typing import Any, Final, Literal, NoReturn, cast
29
+
30
+ from jev_mcp.serialize import number_to_string
31
+ from jev_mcp.text import length
32
+
33
+ type Path = tuple[str | int, ...]
34
+ type Status = Literal["valid", "dirty", "aborted"]
35
+ type Schema = Mapping[str, Any]
36
+
37
+ INVALID_PARAMS: Final = -32602
38
+
39
+ _MISSING: Final = object()
40
+ """A key the caller did not send: JS `undefined`, which zod reports as `Required`."""
41
+
42
+ _ANNOTATIONS: Final[frozenset[str]] = frozenset({"$schema", "title", "description"})
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Issue:
47
+ message: str
48
+ path: Path
49
+
50
+ def render(self) -> str:
51
+ """`getParseErrorMessage` for one issue: the bare message at the root, else `{message} at {path}`."""
52
+ if not self.path:
53
+ return self.message
54
+ dotted = str(self.path[0])
55
+ for segment in self.path[1:]:
56
+ dotted += f"[{segment}]" if isinstance(segment, int) else f".{segment}"
57
+ return f"{self.message} at {dotted}"
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Refinement:
62
+ """A zod `.refine(check, {message})` on one top-level property; it runs on the parsed value unless aborted."""
63
+
64
+ check: Callable[[object], bool]
65
+ message: str
66
+
67
+
68
+ class ArgumentsError(Exception):
69
+ """The arguments failed validation; `str()` is the SDK's tool-error text."""
70
+
71
+ def __init__(self, tool: str, issues: Sequence[Issue]) -> None:
72
+ rendered = "\n".join(issue.render() for issue in issues)
73
+ super().__init__(
74
+ f"MCP error {INVALID_PARAMS}: Input validation error: Invalid arguments for tool {tool}: {rendered}"
75
+ )
76
+ self.issues = list(issues)
77
+
78
+
79
+ class SchemaUnfaithful(Exception):
80
+ """A published schema uses something the argument parser cannot faithfully enforce (ADR-0022)."""
81
+
82
+
83
+ @dataclass(slots=True)
84
+ class _Sink:
85
+ issues: list[Issue] = field(default_factory=list[Issue])
86
+ deferred: list[Issue] = field(default_factory=list[Issue])
87
+ """Issues zod adds from a promise callback: after every synchronous issue of the parse."""
88
+
89
+ def ordered(self) -> list[Issue]:
90
+ return self.issues + self.deferred
91
+
92
+
93
+ def _received(value: object) -> str:
94
+ """zod `getParsedType` over parsed JSON."""
95
+ if value is _MISSING:
96
+ return "undefined"
97
+ if value is None:
98
+ return "null"
99
+ if isinstance(value, bool):
100
+ return "boolean"
101
+ if isinstance(value, int | float):
102
+ return "number"
103
+ if isinstance(value, str):
104
+ return "string"
105
+ if isinstance(value, list):
106
+ return "array"
107
+ return "object"
108
+
109
+
110
+ def _invalid_type(expected: str, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
111
+ received = _received(value)
112
+ message = "Required" if received == "undefined" else f"Expected {expected}, received {received}"
113
+ sink.issues.append(Issue(message, path))
114
+ return "aborted", None
115
+
116
+
117
+ def _merge(status: Status, other: Status) -> Status:
118
+ if "aborted" in (status, other):
119
+ return "aborted"
120
+ return "dirty" if "dirty" in (status, other) else "valid"
121
+
122
+
123
+ @dataclass(frozen=True, slots=True)
124
+ class _String:
125
+ min_length: int | None
126
+ pattern: re.Pattern[str] | None
127
+ max_length: int | None
128
+
129
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
130
+ if not isinstance(value, str):
131
+ return _invalid_type("string", value, path, sink)
132
+ status: Status = "valid"
133
+ units = length(value)
134
+ if self.min_length is not None and units < self.min_length:
135
+ sink.issues.append(Issue(f"String must contain at least {self.min_length} character(s)", path))
136
+ status = "dirty"
137
+ if self.pattern is not None and not self.pattern.search(value):
138
+ sink.issues.append(Issue("Invalid", path))
139
+ status = "dirty"
140
+ if self.max_length is not None and units > self.max_length:
141
+ sink.issues.append(Issue(f"String must contain at most {self.max_length} character(s)", path))
142
+ status = "dirty"
143
+ return status, value
144
+
145
+
146
+ @dataclass(frozen=True, slots=True)
147
+ class _Number:
148
+ integer: bool
149
+ minimum: int | float | None
150
+ maximum: int | float | None
151
+
152
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
153
+ if isinstance(value, bool) or not isinstance(value, int | float):
154
+ return _invalid_type("number", value, path, sink)
155
+ status: Status = "valid"
156
+ if self.integer and isinstance(value, float) and not value.is_integer():
157
+ sink.issues.append(Issue("Expected integer, received float", path))
158
+ status = "dirty"
159
+ if self.minimum is not None and value < self.minimum:
160
+ bound = number_to_string(self.minimum)
161
+ sink.issues.append(Issue(f"Number must be greater than or equal to {bound}", path))
162
+ status = "dirty"
163
+ if self.maximum is not None and value > self.maximum:
164
+ bound = number_to_string(self.maximum)
165
+ sink.issues.append(Issue(f"Number must be less than or equal to {bound}", path))
166
+ status = "dirty"
167
+ return status, value
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class _Boolean:
172
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
173
+ return ("valid", value) if isinstance(value, bool) else _invalid_type("boolean", value, path, sink)
174
+
175
+
176
+ @dataclass(frozen=True, slots=True)
177
+ class _Array:
178
+ items: "_Node"
179
+ min_items: int | None
180
+ max_items: int | None
181
+
182
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
183
+ if not isinstance(value, list):
184
+ return _invalid_type("array", value, path, sink)
185
+ items = cast(list[object], value)
186
+ status: Status = "valid"
187
+ if self.min_items is not None and len(items) < self.min_items:
188
+ sink.issues.append(Issue(f"Array must contain at least {self.min_items} element(s)", path))
189
+ status = "dirty"
190
+ if self.max_items is not None and len(items) > self.max_items:
191
+ sink.issues.append(Issue(f"Array must contain at most {self.max_items} element(s)", path))
192
+ status = "dirty"
193
+ parsed: list[object] = []
194
+ for index, item in enumerate(items):
195
+ item_status, item_value = self.items.parse(item, (*path, index), sink)
196
+ status = _merge(status, item_status)
197
+ parsed.append(item_value)
198
+ return status, parsed
199
+
200
+
201
+ @dataclass(frozen=True, slots=True)
202
+ class _Property:
203
+ key: str
204
+ node: "_Node"
205
+ required: bool
206
+ refinement: Refinement | None
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class _Object:
211
+ properties: tuple[_Property, ...]
212
+
213
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
214
+ if not isinstance(value, dict):
215
+ return _invalid_type("object", value, path, sink)
216
+ record = cast(dict[str, object], value)
217
+ status: Status = "valid"
218
+ parsed: dict[str, object] = {}
219
+ for prop in self.properties:
220
+ item = record.get(prop.key, _MISSING)
221
+ if item is _MISSING and not prop.required:
222
+ continue
223
+ item_status, item_value = prop.node.parse(item, (*path, prop.key), sink)
224
+ if prop.refinement is not None and item_status != "aborted" and not prop.refinement.check(item_value):
225
+ sink.deferred.append(Issue(prop.refinement.message, (*path, prop.key)))
226
+ item_status = "dirty"
227
+ status = _merge(status, item_status)
228
+ parsed[prop.key] = item_value
229
+ return status, parsed
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class _Record:
234
+ """z.record(z.any()): any object, kept whole."""
235
+
236
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
237
+ if not isinstance(value, dict):
238
+ return _invalid_type("object", value, path, sink)
239
+ return "valid", dict(cast(dict[str, object], value))
240
+
241
+
242
+ @dataclass(frozen=True, slots=True)
243
+ class _Union:
244
+ options: tuple["_Node", ...]
245
+
246
+ def parse(self, value: object, path: Path, sink: _Sink) -> tuple[Status, object]:
247
+ results: list[tuple[Status, object, _Sink]] = []
248
+ for option in self.options:
249
+ option_sink = _Sink()
250
+ option_status, option_value = option.parse(value, path, option_sink)
251
+ if option_status == "valid":
252
+ return option_status, option_value
253
+ results.append((option_status, option_value, option_sink))
254
+ for option_status, option_value, option_sink in results:
255
+ if option_status == "dirty":
256
+ sink.deferred.extend(option_sink.ordered())
257
+ return option_status, option_value
258
+ sink.deferred.append(Issue("Invalid input", path))
259
+ return "aborted", None
260
+
261
+
262
+ type _Node = _String | _Number | _Boolean | _Array | _Object | _Record | _Union
263
+
264
+
265
+ @dataclass(frozen=True, slots=True)
266
+ class ArgumentParser:
267
+ """A tool's compiled argument schema. Calling it returns the parsed arguments or raises `ArgumentsError`."""
268
+
269
+ tool: str
270
+ root: _Object
271
+
272
+ def __call__(self, arguments: Mapping[str, object] | None) -> dict[str, object]:
273
+ """The parsed arguments, or `ArgumentsError` with every issue in the order the reference reports them.
274
+
275
+ `None` is arguments the caller did not send (JS `undefined`), reported at the root as `Required`.
276
+ """
277
+ sink = _Sink()
278
+ status, value = self.root.parse(_MISSING if arguments is None else arguments, (), sink)
279
+ if status != "valid":
280
+ raise ArgumentsError(self.tool, sink.ordered())
281
+ return cast(dict[str, object], value)
282
+
283
+
284
+ def compile_argument_schema(
285
+ tool: str, schema: Schema, refinements: Mapping[str, Refinement] | None = None
286
+ ) -> ArgumentParser:
287
+ """Compile `schema` into its parser, or raise `SchemaUnfaithful` naming the tool and schema path.
288
+
289
+ The root is an object with properties; each refinement must name one of them. Every other
290
+ keyword is consumed by the node that enforces it; a leftover keyword is a construction failure.
291
+ """
292
+ compiler = _Compiler(tool)
293
+ root = compiler.node(schema, "")
294
+ if not isinstance(root, _Object):
295
+ compiler.fail("", "the root must be an object with properties")
296
+ refinements = refinements or {}
297
+ keys = {prop.key for prop in root.properties}
298
+ for key in refinements:
299
+ if key not in keys:
300
+ compiler.fail("", f"refinement {key!r} names no root property")
301
+ properties = tuple(
302
+ _Property(prop.key, prop.node, prop.required, refinements.get(prop.key)) for prop in root.properties
303
+ )
304
+ return ArgumentParser(tool, _Object(properties))
305
+
306
+
307
+ def parse_arguments(
308
+ tool: str,
309
+ schema: Schema,
310
+ arguments: Mapping[str, object] | None,
311
+ refinements: Mapping[str, Refinement] | None = None,
312
+ ) -> dict[str, object]:
313
+ """Compile `schema` and parse `arguments` once; a served tool calls the parser its `Toolset` compiled."""
314
+ return compile_argument_schema(tool, schema, refinements)(arguments)
315
+
316
+
317
+ def _js_pattern(pattern: str) -> re.Pattern[str]:
318
+ """The schema's JS regex. Its `$` is the end of input: Python's would also match before a final newline."""
319
+ if not pattern.startswith("^") or not pattern.endswith("$") or not pattern.isascii():
320
+ raise ValueError(f"Unsupported schema pattern {pattern!r}.")
321
+ return re.compile(r"\A" + pattern[1:-1] + r"\Z")
322
+
323
+
324
+ def _kind(node: _Node) -> str:
325
+ """The zod type a node's value must have: number and integer share one, as do object and record."""
326
+ match node:
327
+ case _Number():
328
+ return "number"
329
+ case _Object() | _Record():
330
+ return "object"
331
+ case _:
332
+ return type(node).__name__
333
+
334
+
335
+ @dataclass(frozen=True, slots=True)
336
+ class _Compiler:
337
+ tool: str
338
+
339
+ def fail(self, path: str, why: str) -> NoReturn:
340
+ raise SchemaUnfaithful(f"{self.tool} inputSchema at {path or 'root'}: {why}")
341
+
342
+ def node(self, schema: object, path: str) -> _Node:
343
+ if not isinstance(schema, Mapping):
344
+ self.fail(path, "a schema must be an object")
345
+ rest = {key: value for key, value in cast(Schema, schema).items() if key not in _ANNOTATIONS}
346
+ node = self._consume(rest, path)
347
+ if rest:
348
+ self.fail(path, f"unsupported keyword(s) {sorted(rest)}: the parser would not enforce them")
349
+ return node
350
+
351
+ def _consume(self, rest: dict[str, Any], path: str) -> _Node:
352
+ if "anyOf" in rest:
353
+ return self._union(rest.pop("anyOf"), path)
354
+ if "type" not in rest:
355
+ self.fail(path, "neither a type nor anyOf: the parser would accept anything here")
356
+ match rest.pop("type"):
357
+ case "string":
358
+ pattern = rest.pop("pattern", _MISSING)
359
+ return _String(
360
+ self._count(rest, "minLength", path, 1),
361
+ None if pattern is _MISSING else self._pattern(pattern, path),
362
+ self._count(rest, "maxLength", path, 0),
363
+ )
364
+ case ("number" | "integer") as kind:
365
+ minimum, maximum = self._bound(rest, "minimum", path), self._bound(rest, "maximum", path)
366
+ return _Number(kind == "integer", minimum, maximum)
367
+ case "boolean":
368
+ return _Boolean()
369
+ case "array":
370
+ if "items" not in rest:
371
+ self.fail(path, "an array schema needs items")
372
+ items = rest.pop("items")
373
+ if not isinstance(items, Mapping):
374
+ self.fail(path, "items must be a single schema (draft-07 tuple form is not supported)")
375
+ return _Array(
376
+ self.node(cast(Schema, items), f"{path}[]"),
377
+ self._count(rest, "minItems", path, 1),
378
+ self._count(rest, "maxItems", path, 0),
379
+ )
380
+ case "object":
381
+ return self._object(rest, path)
382
+ case other:
383
+ self.fail(path, f"unsupported type {other!r}")
384
+
385
+ def _object(self, rest: dict[str, Any], path: str) -> _Object | _Record:
386
+ additional = rest.pop("additionalProperties", _MISSING)
387
+ if "properties" not in rest:
388
+ if not (additional is _MISSING or additional is True or additional == {}):
389
+ self.fail(path, "additionalProperties constraint on a keep-whole object: the parser would ignore it")
390
+ return _Record()
391
+ if additional is not False:
392
+ self.fail(path, "additionalProperties must be false: unknown keys are stripped, matching the reference")
393
+ properties = rest.pop("properties")
394
+ if not isinstance(properties, Mapping):
395
+ self.fail(path, "properties must be an object")
396
+ subs = cast(Schema, properties)
397
+ required: object = rest.pop("required", [])
398
+ if not isinstance(required, list) or not all(isinstance(name, str) for name in cast(list[object], required)):
399
+ self.fail(path, "required must be a list of property names")
400
+ names = cast(list[str], required)
401
+ for name in names:
402
+ if name not in subs:
403
+ self.fail(path, f"required key {name!r} has no property")
404
+ return _Object(
405
+ tuple(
406
+ _Property(key, self.node(sub, f"{path}.{key}" if path else key), key in names, None)
407
+ for key, sub in subs.items()
408
+ )
409
+ )
410
+
411
+ def _union(self, options: object, path: str) -> _Union:
412
+ if not isinstance(options, list) or not options:
413
+ self.fail(path, "anyOf must be a non-empty list of schemas")
414
+ nodes: list[_Node] = []
415
+ for index, option in enumerate(cast(list[object], options)):
416
+ node = self.node(option, f"{path}/anyOf[{index}]")
417
+ if isinstance(node, _Union):
418
+ self.fail(path, f"anyOf[{index}] is itself a union: flatten it")
419
+ if any(_kind(node) == _kind(earlier) for earlier in nodes):
420
+ self.fail(path, f"anyOf[{index}] shares its type with an earlier option, which would shadow it")
421
+ nodes.append(node)
422
+ return _Union(tuple(nodes))
423
+
424
+ def _pattern(self, pattern: object, path: str) -> re.Pattern[str]:
425
+ if not isinstance(pattern, str):
426
+ self.fail(path, "pattern must be a string")
427
+ try:
428
+ return _js_pattern(pattern)
429
+ except (ValueError, re.error) as error:
430
+ self.fail(path, str(error))
431
+
432
+ def _count(self, rest: dict[str, Any], key: str, path: str, floor: int) -> int | None:
433
+ """A length bound. A lower bound of 0 can never fail, so it must be at least 1."""
434
+ value = rest.pop(key, _MISSING)
435
+ if value is _MISSING:
436
+ return None
437
+ if isinstance(value, bool) or not isinstance(value, int) or value < floor:
438
+ self.fail(path, f"{key} must be an integer of at least {floor}")
439
+ return value
440
+
441
+ def _bound(self, rest: dict[str, Any], key: str, path: str) -> int | float | None:
442
+ value = rest.pop(key, _MISSING)
443
+ if value is _MISSING:
444
+ return None
445
+ if isinstance(value, bool) or not isinstance(value, int | float) or not math.isfinite(value):
446
+ self.fail(path, f"{key} must be a finite number")
447
+ return value
jev_mcp/tools/base.py ADDED
@@ -0,0 +1,153 @@
1
+ """What every tool shares: its published definition, the runtime it asks Jev through, and its result shape.
2
+
3
+ A tool validates its arguments against its published schema (`arguments.py`), builds state and
4
+ questions, asks Jev once at most, validates each answer, applies policy, and returns a payload that
5
+ is serialized as `JSON.stringify(payload, null, 2)` (ADR-0006).
6
+ """
7
+
8
+ from collections.abc import Awaitable, Callable, Iterable, Mapping
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, cast
11
+
12
+ from mcp.types import Tool
13
+
14
+ from jev_mcp.domain import JsonValue, Question
15
+ from jev_mcp.extract.executor import RegexExecutor
16
+ from jev_mcp.extract.worker import ProcessRegexExecutor
17
+ from jev_mcp.policy import Action
18
+ from jev_mcp.providers import Evaluation, JevProvider, resolve_model, resolve_provider
19
+ from jev_mcp.settings import Settings
20
+ from jev_mcp.telemetry import ACTIONS, Telemetry
21
+ from jev_mcp.tools.arguments import Refinement
22
+ from jev_mcp.tools.observed import worst_action
23
+ from jev_mcp.validation.caps import CapScope
24
+
25
+ type Payload = Mapping[str, object]
26
+
27
+
28
+ class ToolError(Exception):
29
+ """A handler failure. Its message is the whole `isError` text, as a thrown `Error` is in the reference."""
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ToolResult:
34
+ """A tool's reply: the payload to serialize, and whether the reference marks it `isError`.
35
+
36
+ `action`, `item_actions` and `truncated` are for telemetry only, never serialized: the call's
37
+ one headline auto/review/escalate Action (`None` when the payload carries none), the per-item
38
+ Actions of a tool that judges items one by one, and the scopes of every cut the call's
39
+ `CapLedger` made.
40
+ """
41
+
42
+ payload: Payload
43
+ is_error: bool = False
44
+ action: Action | None = None
45
+ item_actions: tuple[Action, ...] = ()
46
+ truncated: frozenset[CapScope] = frozenset()
47
+
48
+
49
+ def caller_actions(values: Iterable[object]) -> tuple[Action, ...]:
50
+ """The auto/review/escalate values among `values`, for `ToolResult.item_actions`."""
51
+ return tuple(value for value in values if value in ACTIONS)
52
+
53
+
54
+ def headline(item_actions: tuple[Action, ...]) -> Action | None:
55
+ """A per-item tool's headline: its worst item Action, or `None` when no item carries one."""
56
+ return worst_action(item_actions) if item_actions else None
57
+
58
+
59
+ def frame(
60
+ tool: str, evaluation: Evaluation | None, body: Mapping[str, object], *, model: str | None = None
61
+ ) -> dict[str, object]:
62
+ """A success payload: `tool, model, provider`, then `body` in its own order, then `usage`.
63
+
64
+ With no `evaluation` the call never asked Jev (jev_extract with no candidates): the caller names
65
+ the configured `model`, the provider is `"none"`, and usage is `null`. The gate's `isError`
66
+ refusal (`{tool, error}`) is not a success payload and is never framed.
67
+ """
68
+ if evaluation is None:
69
+ return {"tool": tool, "model": model, "provider": "none", **body, "usage": None}
70
+ return {
71
+ "tool": tool,
72
+ "model": evaluation.model,
73
+ "provider": evaluation.provider,
74
+ **body,
75
+ "usage": evaluation.usage.to_wire(),
76
+ }
77
+
78
+
79
+ class Runtime:
80
+ """Per-server access to Jev: the configured model and a provider resolved on first use.
81
+
82
+ Resolution waits for the first question, as the reference resolves inside `askJev`
83
+ (`provider.ts:104-106`): a call that never asks (jev_extract with no candidates) never fails on
84
+ provider configuration. A failed resolution raises again on every call that asks.
85
+ """
86
+
87
+ def __init__(
88
+ self,
89
+ settings: Settings,
90
+ provider_factory: Callable[[Settings], JevProvider] = resolve_provider,
91
+ regex_executor: RegexExecutor | None = None,
92
+ ) -> None:
93
+ self.settings = settings
94
+ self.model = resolve_model(settings)
95
+ self._provider_factory = provider_factory
96
+ self._provider: JevProvider | None = None
97
+ self._regex_executor = regex_executor or ProcessRegexExecutor()
98
+ self.telemetry = Telemetry(payloads=settings.telemetry_payloads)
99
+
100
+ @property
101
+ def regex_executor(self) -> RegexExecutor:
102
+ """Where jev_extract's patterns run: worker processes unless the server was built with another."""
103
+ return self._regex_executor
104
+
105
+ async def ask(self, state: Payload, questions: Mapping[str, Question]) -> Evaluation:
106
+ """Ask Jev `questions` about `state`. Raises `ProviderError` with redacted text.
107
+
108
+ No deadline: the reference sets none on any provider request. The `jev.evaluate` span
109
+ includes provider resolution, so a configuration error counts as a provider error.
110
+ """
111
+ with self.telemetry.span("jev.evaluate", questions=len(questions)) as span:
112
+ if self._provider is None:
113
+ self._provider = self._provider_factory(self.settings)
114
+ span.attributes["provider"] = self._provider.name
115
+ evaluation = await self._provider.evaluate(cast(JsonValue, state), questions, self.model, None)
116
+ span.attributes["input_tokens"] = evaluation.usage.input_tokens
117
+ span.attributes["output_tokens"] = evaluation.usage.output_tokens
118
+ return evaluation
119
+
120
+ async def aclose(self) -> None:
121
+ await self._regex_executor.aclose()
122
+ if self._provider is not None:
123
+ await self._provider.aclose()
124
+ self._provider = None
125
+
126
+
127
+ type Handler = Callable[[dict[str, Any], Runtime], Awaitable[ToolResult]]
128
+
129
+
130
+ @dataclass(frozen=True, slots=True)
131
+ class JevTool:
132
+ """A published tool: its `tools/list` definition, argument refinements, and handler."""
133
+
134
+ definition: Tool
135
+ handler: Handler
136
+ refinements: Mapping[str, Refinement] = field(default_factory=dict[str, Refinement])
137
+
138
+ @property
139
+ def name(self) -> str:
140
+ return self.definition.name
141
+
142
+
143
+ def define(name: str, title: str, description: str, input_schema: dict[str, Any]) -> Tool:
144
+ """A snapshot-shaped definition: draft-07 schema and `execution.taskSupport: forbidden` (ADR-0010)."""
145
+ return Tool.model_validate(
146
+ {
147
+ "name": name,
148
+ "title": title,
149
+ "description": description,
150
+ "inputSchema": {**input_schema, "$schema": "http://json-schema.org/draft-07/schema#"},
151
+ "execution": {"taskSupport": "forbidden"},
152
+ }
153
+ )