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
|
@@ -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
|
+
)
|