mcp4api 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.
- mcp4api/__init__.py +1 -0
- mcp4api/__main__.py +5 -0
- mcp4api/cassettes.py +96 -0
- mcp4api/cli.py +665 -0
- mcp4api/curate/__init__.py +3 -0
- mcp4api/curate/agent.py +850 -0
- mcp4api/curate/criteria.md +21 -0
- mcp4api/curate/draft.py +606 -0
- mcp4api/curate/models.py +614 -0
- mcp4api/curate/rules.py +1059 -0
- mcp4api/emit/__init__.py +205 -0
- mcp4api/emit/catalog.py +717 -0
- mcp4api/emit/render.py +917 -0
- mcp4api/emit/templates/README.md.j2 +58 -0
- mcp4api/emit/templates/client.py.j2 +305 -0
- mcp4api/emit/templates/conftest.py.j2 +20 -0
- mcp4api/emit/templates/file.py.j2 +4 -0
- mcp4api/emit/templates/header.py.j2 +2 -0
- mcp4api/emit/templates/main.py.j2 +99 -0
- mcp4api/emit/templates/pyproject.toml.j2 +24 -0
- mcp4api/emit/templates/test_tools.py.j2 +40 -0
- mcp4api/errors.py +35 -0
- mcp4api/ingest/__init__.py +3 -0
- mcp4api/ingest/build.py +1064 -0
- mcp4api/ingest/convert.py +569 -0
- mcp4api/ingest/load.py +603 -0
- mcp4api/model.py +324 -0
- mcp4api/py.typed +0 -0
- mcp4api/report.py +330 -0
- mcp4api/suite.py +1384 -0
- mcp4api/verify.py +1114 -0
- mcp4api-0.1.0.dist-info/METADATA +157 -0
- mcp4api-0.1.0.dist-info/RECORD +36 -0
- mcp4api-0.1.0.dist-info/WHEEL +4 -0
- mcp4api-0.1.0.dist-info/entry_points.txt +2 -0
- mcp4api-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp4api/curate/rules.py
ADDED
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from jsonschema import Draft202012Validator
|
|
7
|
+
from jsonschema.exceptions import SchemaError, ValidationError
|
|
8
|
+
|
|
9
|
+
from mcp4api.curate.models import (
|
|
10
|
+
_NOTES,
|
|
11
|
+
Annotations,
|
|
12
|
+
Bind,
|
|
13
|
+
DetailDecision,
|
|
14
|
+
DetailResult,
|
|
15
|
+
ExampleDraft,
|
|
16
|
+
Excluded,
|
|
17
|
+
ExclusionReason,
|
|
18
|
+
FixedParam,
|
|
19
|
+
InputDraft,
|
|
20
|
+
Manifest,
|
|
21
|
+
MapsTo,
|
|
22
|
+
ParameterIn,
|
|
23
|
+
PlanDecision,
|
|
24
|
+
PlanResult,
|
|
25
|
+
PlanTool,
|
|
26
|
+
Step,
|
|
27
|
+
Tool,
|
|
28
|
+
ToolDraft,
|
|
29
|
+
ToolInput,
|
|
30
|
+
ToolOutput,
|
|
31
|
+
Verify,
|
|
32
|
+
annotations_for,
|
|
33
|
+
apply_namespace,
|
|
34
|
+
name_error,
|
|
35
|
+
parse_location,
|
|
36
|
+
parse_reason,
|
|
37
|
+
parse_scalar,
|
|
38
|
+
)
|
|
39
|
+
from mcp4api.model import (
|
|
40
|
+
ApiSpec,
|
|
41
|
+
Endpoint,
|
|
42
|
+
JsonObject,
|
|
43
|
+
JsonSchema,
|
|
44
|
+
JsonValue,
|
|
45
|
+
Parameter,
|
|
46
|
+
SecurityScheme,
|
|
47
|
+
bundle_schema,
|
|
48
|
+
expect_json,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
_FIELD_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
_INPUT_REF = re.compile(r"^\$input\.([A-Za-z_][A-Za-z0-9_]*)$")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_STEP_REF = re.compile(r"^\$steps\[(\d+)\]\.body((?:\.[A-Za-z_][A-Za-z0-9_]*|\[\])*)$")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
_BULK = re.compile(r"bulk|batch|purge", re.IGNORECASE)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_DEFS = "#/$defs/"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def exclusion_reason(
|
|
67
|
+
endpoint: Endpoint,
|
|
68
|
+
schemes: dict[str, SecurityScheme],
|
|
69
|
+
include_tags: tuple[str, ...],
|
|
70
|
+
) -> ExclusionReason | None:
|
|
71
|
+
included = {tag.lower() for tag in include_tags}
|
|
72
|
+
if _is_admin(endpoint) and not _waived("admin", endpoint, included):
|
|
73
|
+
return "admin"
|
|
74
|
+
if _is_internal(endpoint) and not _waived("internal", endpoint, included):
|
|
75
|
+
return "internal"
|
|
76
|
+
if endpoint.deprecated and "deprecated" not in included:
|
|
77
|
+
return "deprecated"
|
|
78
|
+
if _is_webhook(endpoint) and not _waived("webhook-management", endpoint, included):
|
|
79
|
+
return "webhook-management"
|
|
80
|
+
if _is_bulk(endpoint) and not _waived("destructive-bulk", endpoint, included):
|
|
81
|
+
return "destructive-bulk"
|
|
82
|
+
if not _auth_supported(endpoint, schemes):
|
|
83
|
+
return "unsupported"
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def endpoint_index_document(spec: ApiSpec, include_tags: tuple[str, ...]) -> str:
|
|
88
|
+
forced = {item.endpoint_id: item.reason for item in forced_exclusions(spec, include_tags)}
|
|
89
|
+
rows: list[JsonObject] = []
|
|
90
|
+
for endpoint in sorted(spec.endpoints, key=lambda item: item.id):
|
|
91
|
+
rows.append(
|
|
92
|
+
{
|
|
93
|
+
"description": _shield_doc(endpoint.description or ""),
|
|
94
|
+
"eligible": endpoint.id not in forced,
|
|
95
|
+
"exclusion": forced.get(endpoint.id, ""),
|
|
96
|
+
"id": endpoint.id,
|
|
97
|
+
"method": endpoint.method,
|
|
98
|
+
"parameters": [
|
|
99
|
+
{"in": parameter.location, "name": parameter.name}
|
|
100
|
+
for parameter in endpoint.parameters
|
|
101
|
+
],
|
|
102
|
+
"summary": _shield_doc(endpoint.summary or ""),
|
|
103
|
+
"tags": [_shield_doc(tag) for tag in endpoint.tags or []],
|
|
104
|
+
}
|
|
105
|
+
)
|
|
106
|
+
return _shield_doc(json.dumps(rows, ensure_ascii=False, indent=2, sort_keys=True))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def forced_exclusions(spec: ApiSpec, include_tags: tuple[str, ...]) -> list[Excluded]:
|
|
110
|
+
found: list[Excluded] = []
|
|
111
|
+
for endpoint in spec.endpoints:
|
|
112
|
+
reason = exclusion_reason(endpoint, spec.security_schemes, include_tags)
|
|
113
|
+
if reason is None:
|
|
114
|
+
continue
|
|
115
|
+
found.append(Excluded(endpoint_id=endpoint.id, reason=reason, note=_NOTES[reason]))
|
|
116
|
+
return found
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def interpret_plan(
|
|
120
|
+
spec: ApiSpec,
|
|
121
|
+
decision: PlanDecision,
|
|
122
|
+
include_tags: tuple[str, ...],
|
|
123
|
+
) -> PlanResult:
|
|
124
|
+
forced = {item.endpoint_id for item in forced_exclusions(spec, include_tags)}
|
|
125
|
+
known = {endpoint.id for endpoint in spec.endpoints}
|
|
126
|
+
errors: list[str] = []
|
|
127
|
+
placed: dict[str, str] = {}
|
|
128
|
+
duplicates: set[str] = set()
|
|
129
|
+
excluded: list[Excluded] = []
|
|
130
|
+
for item in decision.excluded:
|
|
131
|
+
if item.endpoint_id not in known:
|
|
132
|
+
errors.append(f"unknown endpoint {item.endpoint_id}")
|
|
133
|
+
continue
|
|
134
|
+
if item.endpoint_id in forced or item.endpoint_id in placed:
|
|
135
|
+
continue
|
|
136
|
+
reason = parse_reason(item.reason)
|
|
137
|
+
if reason is None:
|
|
138
|
+
errors.append(f"unknown exclusion reason {item.reason}")
|
|
139
|
+
continue
|
|
140
|
+
placed[item.endpoint_id] = ""
|
|
141
|
+
note = item.note.strip() or _NOTES[reason]
|
|
142
|
+
excluded.append(Excluded(endpoint_id=item.endpoint_id, reason=reason, note=note))
|
|
143
|
+
tools: list[PlanTool] = []
|
|
144
|
+
for tool in decision.tools:
|
|
145
|
+
kept: list[str] = []
|
|
146
|
+
for endpoint_id in tool.endpoint_ids:
|
|
147
|
+
if endpoint_id not in known:
|
|
148
|
+
errors.append(f"unknown endpoint {endpoint_id}")
|
|
149
|
+
continue
|
|
150
|
+
if endpoint_id in forced:
|
|
151
|
+
continue
|
|
152
|
+
if endpoint_id in placed:
|
|
153
|
+
errors.append(f"duplicate endpoint {endpoint_id}")
|
|
154
|
+
duplicates.add(endpoint_id)
|
|
155
|
+
continue
|
|
156
|
+
placed[endpoint_id] = tool.name
|
|
157
|
+
kept.append(endpoint_id)
|
|
158
|
+
if kept:
|
|
159
|
+
tools.append(PlanTool(name=tool.name, endpoint_ids=kept, rationale=tool.rationale))
|
|
160
|
+
if duplicates:
|
|
161
|
+
tools = [
|
|
162
|
+
PlanTool(
|
|
163
|
+
name=tool.name,
|
|
164
|
+
endpoint_ids=[item for item in tool.endpoint_ids if item not in duplicates],
|
|
165
|
+
rationale=tool.rationale,
|
|
166
|
+
)
|
|
167
|
+
for tool in tools
|
|
168
|
+
if any(item not in duplicates for item in tool.endpoint_ids)
|
|
169
|
+
]
|
|
170
|
+
for endpoint_id in duplicates:
|
|
171
|
+
placed.pop(endpoint_id, None)
|
|
172
|
+
uncovered: list[str] = []
|
|
173
|
+
for endpoint in spec.endpoints:
|
|
174
|
+
if endpoint.id in forced or endpoint.id in placed:
|
|
175
|
+
continue
|
|
176
|
+
uncovered.append(endpoint.id)
|
|
177
|
+
errors.append(f"missing endpoint {endpoint.id}")
|
|
178
|
+
return PlanResult(
|
|
179
|
+
tools=tuple(tool for tool in tools if tool.endpoint_ids),
|
|
180
|
+
excluded=tuple(excluded),
|
|
181
|
+
uncovered=tuple(uncovered),
|
|
182
|
+
errors=tuple(errors),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def interpret_detail(
|
|
187
|
+
spec: ApiSpec,
|
|
188
|
+
decision: DetailDecision,
|
|
189
|
+
*,
|
|
190
|
+
allowed: set[str],
|
|
191
|
+
namespace: str,
|
|
192
|
+
include_tags: tuple[str, ...],
|
|
193
|
+
accepted_names: set[str],
|
|
194
|
+
) -> DetailResult:
|
|
195
|
+
forced = {item.endpoint_id: item for item in forced_exclusions(spec, include_tags)}
|
|
196
|
+
errors: list[str] = []
|
|
197
|
+
tools: list[Tool] = []
|
|
198
|
+
excluded: list[Excluded] = []
|
|
199
|
+
names = {apply_namespace(draft.name, namespace) for draft in decision.tools}
|
|
200
|
+
names.update(accepted_names)
|
|
201
|
+
seen_names: set[str] = set()
|
|
202
|
+
for draft in decision.tools:
|
|
203
|
+
tool, draft_errors = _assemble_draft(
|
|
204
|
+
spec,
|
|
205
|
+
draft,
|
|
206
|
+
allowed=allowed,
|
|
207
|
+
namespace=namespace,
|
|
208
|
+
forced=set(forced),
|
|
209
|
+
known_names=names,
|
|
210
|
+
)
|
|
211
|
+
errors.extend(draft_errors)
|
|
212
|
+
if tool is None:
|
|
213
|
+
continue
|
|
214
|
+
if tool.name in seen_names or tool.name in accepted_names:
|
|
215
|
+
errors.append(f"duplicate tool name {tool.name}")
|
|
216
|
+
continue
|
|
217
|
+
seen_names.add(tool.name)
|
|
218
|
+
tools.append(tool)
|
|
219
|
+
for item in decision.excluded:
|
|
220
|
+
if item.endpoint_id not in allowed or item.endpoint_id in forced:
|
|
221
|
+
continue
|
|
222
|
+
reason = parse_reason(item.reason)
|
|
223
|
+
if reason is None:
|
|
224
|
+
errors.append(f"unknown exclusion reason {item.reason}")
|
|
225
|
+
continue
|
|
226
|
+
note = item.note.strip() or _NOTES[reason]
|
|
227
|
+
excluded.append(Excluded(endpoint_id=item.endpoint_id, reason=reason, note=note))
|
|
228
|
+
seen_endpoints: set[str] = set()
|
|
229
|
+
for tool in tools:
|
|
230
|
+
for step in tool.steps:
|
|
231
|
+
if step.endpoint_id in seen_endpoints:
|
|
232
|
+
errors.append(f"duplicate endpoint {step.endpoint_id}")
|
|
233
|
+
seen_endpoints.add(step.endpoint_id)
|
|
234
|
+
if _has_cycle(tools):
|
|
235
|
+
errors.append("tool dependencies contain a cycle")
|
|
236
|
+
if errors:
|
|
237
|
+
return DetailResult(tools=(), excluded=tuple(excluded), errors=tuple(errors))
|
|
238
|
+
return DetailResult(tools=tuple(tools), excluded=tuple(excluded), errors=())
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def validation_errors(
|
|
242
|
+
manifest: Manifest,
|
|
243
|
+
spec: ApiSpec,
|
|
244
|
+
*,
|
|
245
|
+
max_tools: int,
|
|
246
|
+
include_tags: tuple[str, ...],
|
|
247
|
+
) -> list[str]:
|
|
248
|
+
try:
|
|
249
|
+
return _validation_errors(manifest, spec, max_tools=max_tools, include_tags=include_tags)
|
|
250
|
+
except (ValueError, KeyError, TypeError, RecursionError, SchemaError, ValidationError) as exc:
|
|
251
|
+
return [exc.__class__.__name__]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _validation_errors(
|
|
255
|
+
manifest: Manifest,
|
|
256
|
+
spec: ApiSpec,
|
|
257
|
+
*,
|
|
258
|
+
max_tools: int,
|
|
259
|
+
include_tags: tuple[str, ...],
|
|
260
|
+
) -> list[str]:
|
|
261
|
+
errors: list[str] = []
|
|
262
|
+
by_id = {endpoint.id: endpoint for endpoint in spec.endpoints}
|
|
263
|
+
forced = {item.endpoint_id: item.reason for item in forced_exclusions(spec, include_tags)}
|
|
264
|
+
seen_names: set[str] = set()
|
|
265
|
+
coverage: dict[str, str] = {}
|
|
266
|
+
for tool in manifest.tools:
|
|
267
|
+
problem = name_error(tool.name, namespace=manifest.api.namespace)
|
|
268
|
+
if problem:
|
|
269
|
+
errors.append(problem)
|
|
270
|
+
if tool.name in seen_names:
|
|
271
|
+
errors.append(f"duplicate tool name {tool.name}")
|
|
272
|
+
seen_names.add(tool.name)
|
|
273
|
+
if not tool.steps:
|
|
274
|
+
errors.append(f"{tool.name} has no steps")
|
|
275
|
+
methods: list[str] = []
|
|
276
|
+
for step in tool.steps:
|
|
277
|
+
endpoint = by_id.get(step.endpoint_id)
|
|
278
|
+
if endpoint is None:
|
|
279
|
+
errors.append(f"unknown endpoint {step.endpoint_id}")
|
|
280
|
+
continue
|
|
281
|
+
if step.endpoint_id in coverage:
|
|
282
|
+
errors.append(f"duplicate endpoint {step.endpoint_id}")
|
|
283
|
+
coverage[step.endpoint_id] = tool.name
|
|
284
|
+
reason = forced.get(step.endpoint_id)
|
|
285
|
+
if reason is not None:
|
|
286
|
+
errors.append(f"{step.endpoint_id} must stay excluded ({reason})")
|
|
287
|
+
methods.append(endpoint.method)
|
|
288
|
+
errors.extend(_bind_errors(spec, tool, step, endpoint))
|
|
289
|
+
errors.extend(_coverage_errors(spec, tool, step, endpoint))
|
|
290
|
+
if methods and tool.annotations != annotations_for(methods):
|
|
291
|
+
errors.append(f"{tool.name} annotations do not match its methods")
|
|
292
|
+
errors.extend(_input_errors(spec, tool))
|
|
293
|
+
errors.extend(_output_errors(spec, tool))
|
|
294
|
+
errors.extend(_example_errors(tool))
|
|
295
|
+
for dep in tool.verify.depends_on:
|
|
296
|
+
if dep == tool.name or dep not in {item.name for item in manifest.tools}:
|
|
297
|
+
errors.append(f"{tool.name} depends on unknown tool {dep}")
|
|
298
|
+
if _has_cycle(manifest.tools):
|
|
299
|
+
errors.append("tool dependencies contain a cycle")
|
|
300
|
+
for item in manifest.excluded:
|
|
301
|
+
if item.endpoint_id not in by_id:
|
|
302
|
+
errors.append(f"unknown endpoint {item.endpoint_id}")
|
|
303
|
+
continue
|
|
304
|
+
if item.endpoint_id in coverage:
|
|
305
|
+
errors.append(f"duplicate endpoint {item.endpoint_id}")
|
|
306
|
+
coverage[item.endpoint_id] = ""
|
|
307
|
+
expected = forced.get(item.endpoint_id)
|
|
308
|
+
if expected is not None and item.reason != expected:
|
|
309
|
+
errors.append(f"{item.endpoint_id} exclusion reason must be {expected}")
|
|
310
|
+
for endpoint in spec.endpoints:
|
|
311
|
+
if endpoint.id not in coverage:
|
|
312
|
+
errors.append(f"missing endpoint {endpoint.id}")
|
|
313
|
+
if len(manifest.tools) > max_tools and any(
|
|
314
|
+
not tool.rationale.strip() for tool in manifest.tools
|
|
315
|
+
):
|
|
316
|
+
errors.append("exceeding the tool target requires a rationale")
|
|
317
|
+
return errors
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _assemble_draft(
|
|
321
|
+
spec: ApiSpec,
|
|
322
|
+
draft: ToolDraft,
|
|
323
|
+
*,
|
|
324
|
+
allowed: set[str],
|
|
325
|
+
namespace: str,
|
|
326
|
+
forced: set[str],
|
|
327
|
+
known_names: set[str],
|
|
328
|
+
) -> tuple[Tool | None, list[str]]:
|
|
329
|
+
errors: list[str] = []
|
|
330
|
+
name = apply_namespace(draft.name, namespace)
|
|
331
|
+
problem = name_error(name, namespace=namespace)
|
|
332
|
+
if problem:
|
|
333
|
+
errors.append(problem)
|
|
334
|
+
steps: list[Step] = []
|
|
335
|
+
for step_draft in draft.steps:
|
|
336
|
+
if step_draft.endpoint_id in forced:
|
|
337
|
+
continue
|
|
338
|
+
if step_draft.endpoint_id not in allowed:
|
|
339
|
+
errors.append(f"{step_draft.endpoint_id} is outside this tool group")
|
|
340
|
+
continue
|
|
341
|
+
binds: list[Bind] = []
|
|
342
|
+
for bind in step_draft.binds:
|
|
343
|
+
location = parse_location(bind.in_)
|
|
344
|
+
if location is None:
|
|
345
|
+
errors.append(f"unknown parameter location {bind.in_}")
|
|
346
|
+
continue
|
|
347
|
+
binds.append(Bind(param=bind.param, in_=location, ref=bind.ref))
|
|
348
|
+
steps.append(Step(endpoint_id=step_draft.endpoint_id, bind=binds))
|
|
349
|
+
if not steps:
|
|
350
|
+
if errors:
|
|
351
|
+
return None, errors
|
|
352
|
+
return None, []
|
|
353
|
+
inputs: list[ToolInput] = []
|
|
354
|
+
for input_draft in draft.inputs:
|
|
355
|
+
location = parse_location(input_draft.in_)
|
|
356
|
+
if location is None:
|
|
357
|
+
errors.append(f"unknown parameter location {input_draft.in_}")
|
|
358
|
+
continue
|
|
359
|
+
schema = _input_schema(spec, input_draft, steps)
|
|
360
|
+
if schema is None:
|
|
361
|
+
errors.append(f"input {input_draft.name} does not map to a parameter")
|
|
362
|
+
continue
|
|
363
|
+
inputs.append(
|
|
364
|
+
ToolInput(
|
|
365
|
+
name=input_draft.name,
|
|
366
|
+
schema_=schema,
|
|
367
|
+
description=input_draft.description,
|
|
368
|
+
required=input_draft.required,
|
|
369
|
+
maps_to=MapsTo(
|
|
370
|
+
endpoint_id=input_draft.endpoint_id,
|
|
371
|
+
param=input_draft.param,
|
|
372
|
+
in_=location,
|
|
373
|
+
),
|
|
374
|
+
)
|
|
375
|
+
)
|
|
376
|
+
fixed: list[FixedParam] = []
|
|
377
|
+
for fixed_draft in draft.fixed:
|
|
378
|
+
location = parse_location(fixed_draft.in_)
|
|
379
|
+
if location is None:
|
|
380
|
+
errors.append(f"unknown parameter location {fixed_draft.in_}")
|
|
381
|
+
continue
|
|
382
|
+
value, value_error = parse_scalar(fixed_draft.value_json)
|
|
383
|
+
if value_error:
|
|
384
|
+
errors.append(value_error)
|
|
385
|
+
continue
|
|
386
|
+
fixed.append(
|
|
387
|
+
FixedParam(
|
|
388
|
+
endpoint_id=fixed_draft.endpoint_id,
|
|
389
|
+
param=fixed_draft.param,
|
|
390
|
+
in_=location,
|
|
391
|
+
value=value,
|
|
392
|
+
reason=fixed_draft.reason,
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
output_schema, output_error = _draft_output(
|
|
396
|
+
spec, steps, draft.output_from_step, draft.output_fields
|
|
397
|
+
)
|
|
398
|
+
if output_error:
|
|
399
|
+
errors.append(output_error)
|
|
400
|
+
examples, example_error = _draft_examples(draft.examples)
|
|
401
|
+
if example_error:
|
|
402
|
+
errors.append(example_error)
|
|
403
|
+
for dep in draft.depends_on:
|
|
404
|
+
if dep not in known_names or dep == name:
|
|
405
|
+
errors.append(f"{name} depends on unknown tool {dep}")
|
|
406
|
+
if errors or output_schema is None:
|
|
407
|
+
return None, errors
|
|
408
|
+
inputs = sorted(inputs, key=lambda item: (not item.required, item.name))
|
|
409
|
+
tool = Tool(
|
|
410
|
+
name=name,
|
|
411
|
+
title=draft.title,
|
|
412
|
+
description=draft.description,
|
|
413
|
+
intent=draft.intent,
|
|
414
|
+
annotations=Annotations(
|
|
415
|
+
readOnlyHint=draft.annotations.readOnlyHint,
|
|
416
|
+
destructiveHint=draft.annotations.destructiveHint,
|
|
417
|
+
idempotentHint=draft.annotations.idempotentHint,
|
|
418
|
+
openWorldHint=draft.annotations.openWorldHint,
|
|
419
|
+
),
|
|
420
|
+
inputs=inputs,
|
|
421
|
+
fixed=fixed,
|
|
422
|
+
steps=steps,
|
|
423
|
+
output=ToolOutput(
|
|
424
|
+
from_step=draft.output_from_step,
|
|
425
|
+
fields=list(draft.output_fields),
|
|
426
|
+
schema_=output_schema,
|
|
427
|
+
),
|
|
428
|
+
verify=Verify(example_args=examples, depends_on=list(draft.depends_on)),
|
|
429
|
+
rationale=draft.rationale,
|
|
430
|
+
)
|
|
431
|
+
errors.extend(_tool_errors(spec, tool))
|
|
432
|
+
if errors:
|
|
433
|
+
return None, errors
|
|
434
|
+
return tool, []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _tool_errors(spec: ApiSpec, tool: Tool) -> list[str]:
|
|
438
|
+
errors: list[str] = []
|
|
439
|
+
by_id = {endpoint.id: endpoint for endpoint in spec.endpoints}
|
|
440
|
+
methods: list[str] = []
|
|
441
|
+
for step in tool.steps:
|
|
442
|
+
endpoint = by_id.get(step.endpoint_id)
|
|
443
|
+
if endpoint is None:
|
|
444
|
+
errors.append(f"unknown endpoint {step.endpoint_id}")
|
|
445
|
+
continue
|
|
446
|
+
methods.append(endpoint.method)
|
|
447
|
+
errors.extend(_bind_errors(spec, tool, step, endpoint))
|
|
448
|
+
errors.extend(_coverage_errors(spec, tool, step, endpoint))
|
|
449
|
+
if methods and tool.annotations != annotations_for(methods):
|
|
450
|
+
errors.append(f"{tool.name} annotations do not match its methods")
|
|
451
|
+
errors.extend(_input_errors(spec, tool))
|
|
452
|
+
errors.extend(_output_errors(spec, tool))
|
|
453
|
+
errors.extend(_example_errors(tool))
|
|
454
|
+
return errors
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _input_errors(spec: ApiSpec, tool: Tool) -> list[str]:
|
|
458
|
+
errors: list[str] = []
|
|
459
|
+
referenced = _referenced_inputs(tool)
|
|
460
|
+
seen: set[str] = set()
|
|
461
|
+
for item in tool.inputs:
|
|
462
|
+
if item.name in seen:
|
|
463
|
+
errors.append(f"duplicate input {item.name}")
|
|
464
|
+
seen.add(item.name)
|
|
465
|
+
mapped = _target_exists(
|
|
466
|
+
spec,
|
|
467
|
+
item.maps_to.endpoint_id,
|
|
468
|
+
item.maps_to.param,
|
|
469
|
+
item.maps_to.in_,
|
|
470
|
+
)
|
|
471
|
+
if not mapped and item.name not in referenced:
|
|
472
|
+
errors.append(f"input {item.name} does not map to a parameter")
|
|
473
|
+
continue
|
|
474
|
+
expected = _input_schema_for(
|
|
475
|
+
spec, item.maps_to.endpoint_id, item.maps_to.param, item.maps_to.in_, tool
|
|
476
|
+
)
|
|
477
|
+
if expected is not None and not _same_schema(item.schema_, expected):
|
|
478
|
+
errors.append(f"input {item.name} schema was not copied from the endpoint")
|
|
479
|
+
return errors
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _output_errors(spec: ApiSpec, tool: Tool) -> list[str]:
|
|
483
|
+
if tool.output.from_step < 0 or tool.output.from_step >= len(tool.steps):
|
|
484
|
+
return [f"{tool.name} output step is outside the tool"]
|
|
485
|
+
endpoint_id = tool.steps[tool.output.from_step].endpoint_id
|
|
486
|
+
endpoint = _endpoint(spec, endpoint_id)
|
|
487
|
+
if endpoint is None:
|
|
488
|
+
return []
|
|
489
|
+
try:
|
|
490
|
+
expected = projected_schema(success_schema(spec, endpoint), tool.output.fields)
|
|
491
|
+
except ValueError as exc:
|
|
492
|
+
return [str(exc)]
|
|
493
|
+
if not _same_schema(tool.output.schema_, expected):
|
|
494
|
+
return [f"{tool.name} output schema was not copied from the endpoint"]
|
|
495
|
+
return []
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _example_errors(tool: Tool) -> list[str]:
|
|
499
|
+
errors: list[str] = []
|
|
500
|
+
names = {item.name for item in tool.inputs}
|
|
501
|
+
for name in tool.verify.example_args:
|
|
502
|
+
if name not in names:
|
|
503
|
+
errors.append(f"example {name} is not an input")
|
|
504
|
+
for item in tool.inputs:
|
|
505
|
+
if item.name not in tool.verify.example_args:
|
|
506
|
+
errors.append(f"example {item.name} is missing")
|
|
507
|
+
continue
|
|
508
|
+
if not _accepts(item.schema_, tool.verify.example_args[item.name]):
|
|
509
|
+
errors.append(f"example {item.name} does not match its schema")
|
|
510
|
+
return errors
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _bind_errors(spec: ApiSpec, tool: Tool, step: Step, endpoint: Endpoint) -> list[str]:
|
|
514
|
+
errors: list[str] = []
|
|
515
|
+
index = tool.steps.index(step)
|
|
516
|
+
inputs = {item.name for item in tool.inputs}
|
|
517
|
+
for bind in step.bind:
|
|
518
|
+
input_match = _INPUT_REF.fullmatch(bind.ref)
|
|
519
|
+
step_match = _STEP_REF.fullmatch(bind.ref)
|
|
520
|
+
if input_match:
|
|
521
|
+
if input_match.group(1) not in inputs:
|
|
522
|
+
errors.append(f"bind {bind.ref} does not match an input")
|
|
523
|
+
continue
|
|
524
|
+
if step_match is None:
|
|
525
|
+
errors.append(f"bind {bind.ref} is not allowed")
|
|
526
|
+
continue
|
|
527
|
+
source = int(step_match.group(1))
|
|
528
|
+
if source >= index:
|
|
529
|
+
errors.append(f"bind {bind.ref} does not point at an earlier step")
|
|
530
|
+
continue
|
|
531
|
+
source_endpoint = _endpoint(spec, tool.steps[source].endpoint_id)
|
|
532
|
+
if source_endpoint is None:
|
|
533
|
+
errors.append(f"bind {bind.ref} does not point at an earlier step")
|
|
534
|
+
continue
|
|
535
|
+
if not _response_path_exists(spec, source_endpoint, step_match.group(2)):
|
|
536
|
+
errors.append(f"bind {bind.ref} does not match a response field")
|
|
537
|
+
if not _target_exists(spec, endpoint.id, bind.param, bind.in_):
|
|
538
|
+
errors.append(f"bind {bind.param} is not a parameter of {endpoint.id}")
|
|
539
|
+
return errors
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def _coverage_errors(spec: ApiSpec, tool: Tool, step: Step, endpoint: Endpoint) -> list[str]:
|
|
543
|
+
covered = _covered(tool, step)
|
|
544
|
+
errors: list[str] = []
|
|
545
|
+
for parameter in endpoint.parameters:
|
|
546
|
+
if parameter.required and (parameter.location, parameter.name) not in covered:
|
|
547
|
+
errors.append(f"required parameter {parameter.name} is not covered")
|
|
548
|
+
body = endpoint.request_body
|
|
549
|
+
if body is None or not body.required:
|
|
550
|
+
return errors
|
|
551
|
+
properties = _body_properties(spec, endpoint)
|
|
552
|
+
required = [
|
|
553
|
+
name for name in _required_names(body_schema(spec, endpoint) or {}) if name in properties
|
|
554
|
+
]
|
|
555
|
+
if not required:
|
|
556
|
+
if not any(location == "body" for location, _name in covered):
|
|
557
|
+
errors.append(f"required body of {endpoint.id} is not covered")
|
|
558
|
+
return errors
|
|
559
|
+
if ("body", "$body") in covered:
|
|
560
|
+
return errors
|
|
561
|
+
for name in required:
|
|
562
|
+
if ("body", name) not in covered:
|
|
563
|
+
errors.append(f"required body property {name} is not covered")
|
|
564
|
+
return errors
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _covered(tool: Tool, step: Step) -> set[tuple[str, str]]:
|
|
568
|
+
covered: set[tuple[str, str]] = set()
|
|
569
|
+
for item in tool.inputs:
|
|
570
|
+
if item.maps_to.endpoint_id == step.endpoint_id:
|
|
571
|
+
covered.add((item.maps_to.in_, item.maps_to.param))
|
|
572
|
+
for fixed in tool.fixed:
|
|
573
|
+
if fixed.endpoint_id == step.endpoint_id:
|
|
574
|
+
covered.add((fixed.in_, fixed.param))
|
|
575
|
+
for bind in step.bind:
|
|
576
|
+
covered.add((bind.in_, bind.param))
|
|
577
|
+
return covered
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _referenced_inputs(tool: Tool) -> set[str]:
|
|
581
|
+
found: set[str] = set()
|
|
582
|
+
for step in tool.steps:
|
|
583
|
+
for bind in step.bind:
|
|
584
|
+
match = _INPUT_REF.fullmatch(bind.ref)
|
|
585
|
+
if match:
|
|
586
|
+
found.add(match.group(1))
|
|
587
|
+
return found
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _input_schema(spec: ApiSpec, raw: InputDraft, steps: list[Step]) -> JsonSchema | None:
|
|
591
|
+
if raw.endpoint_id and _target_exists(
|
|
592
|
+
spec, raw.endpoint_id, raw.param, parse_location(raw.in_) or "query"
|
|
593
|
+
):
|
|
594
|
+
location = parse_location(raw.in_)
|
|
595
|
+
if location is None:
|
|
596
|
+
return None
|
|
597
|
+
return _copied_target(spec, raw.endpoint_id, raw.param, location)
|
|
598
|
+
tool_inputs = {raw.name}
|
|
599
|
+
for step in steps:
|
|
600
|
+
for bind in step.bind:
|
|
601
|
+
match = _INPUT_REF.fullmatch(bind.ref)
|
|
602
|
+
if match and match.group(1) in tool_inputs:
|
|
603
|
+
return _copied_target(spec, step.endpoint_id, bind.param, bind.in_)
|
|
604
|
+
return None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _input_schema_for(
|
|
608
|
+
spec: ApiSpec,
|
|
609
|
+
endpoint_id: str,
|
|
610
|
+
param: str,
|
|
611
|
+
location: ParameterIn,
|
|
612
|
+
tool: Tool,
|
|
613
|
+
) -> JsonSchema | None:
|
|
614
|
+
if _target_exists(spec, endpoint_id, param, location):
|
|
615
|
+
return _copied_target(spec, endpoint_id, param, location)
|
|
616
|
+
for step in tool.steps:
|
|
617
|
+
for bind in step.bind:
|
|
618
|
+
match = _INPUT_REF.fullmatch(bind.ref)
|
|
619
|
+
if match is None:
|
|
620
|
+
continue
|
|
621
|
+
return _copied_target(spec, step.endpoint_id, bind.param, bind.in_)
|
|
622
|
+
return None
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _copied_target(
|
|
626
|
+
spec: ApiSpec, endpoint_id: str, param: str, location: ParameterIn
|
|
627
|
+
) -> JsonSchema | None:
|
|
628
|
+
endpoint = _endpoint(spec, endpoint_id)
|
|
629
|
+
if endpoint is None:
|
|
630
|
+
return None
|
|
631
|
+
if location == "body":
|
|
632
|
+
schema = body_schema(spec, endpoint)
|
|
633
|
+
if schema is None:
|
|
634
|
+
return None
|
|
635
|
+
if param == "$body":
|
|
636
|
+
return schema
|
|
637
|
+
properties = _body_properties(spec, endpoint)
|
|
638
|
+
child = properties.get(param)
|
|
639
|
+
if child is None:
|
|
640
|
+
return None
|
|
641
|
+
return _attach_defs(_detach(child), _defs_of(schema))
|
|
642
|
+
parameter = _parameter(endpoint, param, location)
|
|
643
|
+
if parameter is None:
|
|
644
|
+
return None
|
|
645
|
+
return bundle_schema(spec, parameter.schema_)
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _target_exists(spec: ApiSpec, endpoint_id: str, param: str, location: ParameterIn) -> bool:
|
|
649
|
+
endpoint = _endpoint(spec, endpoint_id)
|
|
650
|
+
if endpoint is None:
|
|
651
|
+
return False
|
|
652
|
+
if location == "body":
|
|
653
|
+
if endpoint.request_body is None:
|
|
654
|
+
return False
|
|
655
|
+
if param == "$body":
|
|
656
|
+
return True
|
|
657
|
+
return param in _body_properties(spec, endpoint)
|
|
658
|
+
return _parameter(endpoint, param, location) is not None
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _draft_output(
|
|
662
|
+
spec: ApiSpec,
|
|
663
|
+
steps: list[Step],
|
|
664
|
+
index: int,
|
|
665
|
+
fields: list[str],
|
|
666
|
+
) -> tuple[JsonSchema | None, str | None]:
|
|
667
|
+
if index < 0 or index >= len(steps):
|
|
668
|
+
return None, "output step is outside the tool"
|
|
669
|
+
endpoint = _endpoint(spec, steps[index].endpoint_id)
|
|
670
|
+
if endpoint is None:
|
|
671
|
+
return None, f"unknown endpoint {steps[index].endpoint_id}"
|
|
672
|
+
try:
|
|
673
|
+
return projected_schema(success_schema(spec, endpoint), fields), None
|
|
674
|
+
except ValueError as exc:
|
|
675
|
+
return None, str(exc)
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def _draft_examples(examples: list[ExampleDraft]) -> tuple[JsonObject, str | None]:
|
|
679
|
+
payload: JsonObject = {}
|
|
680
|
+
for item in examples:
|
|
681
|
+
value, error = parse_scalar(item.value_json)
|
|
682
|
+
if error:
|
|
683
|
+
return {}, error
|
|
684
|
+
payload[item.name] = value
|
|
685
|
+
return payload, None
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def success_schema(spec: ApiSpec, endpoint: Endpoint) -> JsonSchema:
|
|
689
|
+
chosen: tuple[int, JsonSchema] | None = None
|
|
690
|
+
for code, response in endpoint.responses.items():
|
|
691
|
+
if response.schema_ is None or not code.isdigit():
|
|
692
|
+
continue
|
|
693
|
+
status = int(code)
|
|
694
|
+
if status < 200 or status > 299:
|
|
695
|
+
continue
|
|
696
|
+
if chosen is None or status < chosen[0]:
|
|
697
|
+
chosen = (status, response.schema_)
|
|
698
|
+
if chosen is None:
|
|
699
|
+
return {"type": "object", "additionalProperties": False}
|
|
700
|
+
return bundle_schema(spec, chosen[1])
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def body_schema(spec: ApiSpec, endpoint: Endpoint) -> JsonSchema | None:
|
|
704
|
+
if endpoint.request_body is None:
|
|
705
|
+
return None
|
|
706
|
+
return bundle_schema(spec, endpoint.request_body.schema_)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def projected_schema(schema: JsonSchema, fields: list[str]) -> JsonSchema:
|
|
710
|
+
if isinstance(schema, bool) or not fields:
|
|
711
|
+
return schema
|
|
712
|
+
parsed = [_parse_field(field) for field in fields]
|
|
713
|
+
defs = _defs_of(schema)
|
|
714
|
+
projected = _project_object(schema, parsed, defs)
|
|
715
|
+
return _attach_defs(projected, defs)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _project_object(
|
|
719
|
+
schema: JsonSchema,
|
|
720
|
+
paths: list[list[tuple[str, bool]]],
|
|
721
|
+
defs: JsonObject,
|
|
722
|
+
) -> JsonSchema:
|
|
723
|
+
properties = _properties(schema, defs)
|
|
724
|
+
required = set(_required_names(schema, defs))
|
|
725
|
+
grouped: dict[tuple[str, bool], list[list[tuple[str, bool]]]] = {}
|
|
726
|
+
order: list[tuple[str, bool]] = []
|
|
727
|
+
for path in paths:
|
|
728
|
+
if not path:
|
|
729
|
+
raise ValueError("bad output field")
|
|
730
|
+
head = path[0]
|
|
731
|
+
if head not in grouped:
|
|
732
|
+
order.append(head)
|
|
733
|
+
grouped[head] = []
|
|
734
|
+
if path[1:]:
|
|
735
|
+
grouped[head].append(path[1:])
|
|
736
|
+
projected: JsonObject = {}
|
|
737
|
+
new_required: list[str] = []
|
|
738
|
+
for head in order:
|
|
739
|
+
name, is_array = head
|
|
740
|
+
if name not in properties:
|
|
741
|
+
raise ValueError(f"missing output field {name}")
|
|
742
|
+
child = properties[name]
|
|
743
|
+
rests = grouped[head]
|
|
744
|
+
if is_array:
|
|
745
|
+
items = _items(child, defs)
|
|
746
|
+
if items is None:
|
|
747
|
+
raise ValueError(f"missing output field {name}")
|
|
748
|
+
projected_items = _project_object(items, rests, defs) if rests else _detach(items)
|
|
749
|
+
value: JsonSchema = {"type": "array", "items": projected_items}
|
|
750
|
+
elif rests:
|
|
751
|
+
value = _project_object(child, rests, defs)
|
|
752
|
+
else:
|
|
753
|
+
value = _detach(child)
|
|
754
|
+
projected[name] = value
|
|
755
|
+
if name in required:
|
|
756
|
+
new_required.append(name)
|
|
757
|
+
result: JsonObject = {
|
|
758
|
+
"additionalProperties": False,
|
|
759
|
+
"properties": projected,
|
|
760
|
+
"type": "object",
|
|
761
|
+
}
|
|
762
|
+
if new_required:
|
|
763
|
+
required_values: list[JsonValue] = []
|
|
764
|
+
required_values.extend(new_required)
|
|
765
|
+
result["required"] = required_values
|
|
766
|
+
return result
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def _parse_field(path: str) -> list[tuple[str, bool]]:
|
|
770
|
+
if not path or ".." in path or path.startswith(".") or path.endswith("."):
|
|
771
|
+
raise ValueError(f"bad output field {path}")
|
|
772
|
+
segments: list[tuple[str, bool]] = []
|
|
773
|
+
for piece in path.split("."):
|
|
774
|
+
array = piece.endswith("[]")
|
|
775
|
+
name = piece[:-2] if array else piece
|
|
776
|
+
if _FIELD_NAME.fullmatch(name) is None:
|
|
777
|
+
raise ValueError(f"bad output field {path}")
|
|
778
|
+
segments.append((name, array))
|
|
779
|
+
return segments
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _response_path_exists(spec: ApiSpec, endpoint: Endpoint, suffix: str) -> bool:
|
|
783
|
+
schema = success_schema(spec, endpoint)
|
|
784
|
+
defs = _defs_of(schema)
|
|
785
|
+
node: JsonSchema = schema
|
|
786
|
+
index = 0
|
|
787
|
+
while index < len(suffix):
|
|
788
|
+
if suffix.startswith("[]", index):
|
|
789
|
+
items = _items(node, defs)
|
|
790
|
+
if items is None:
|
|
791
|
+
return False
|
|
792
|
+
node = items
|
|
793
|
+
index += 2
|
|
794
|
+
continue
|
|
795
|
+
if suffix[index] != ".":
|
|
796
|
+
return False
|
|
797
|
+
index += 1
|
|
798
|
+
match = _FIELD_NAME.match(suffix, index)
|
|
799
|
+
if match is None:
|
|
800
|
+
return False
|
|
801
|
+
properties = _properties(node, defs)
|
|
802
|
+
name = match.group(0)
|
|
803
|
+
if name not in properties:
|
|
804
|
+
return False
|
|
805
|
+
node = properties[name]
|
|
806
|
+
index = match.end()
|
|
807
|
+
return True
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _properties(schema: JsonSchema, defs: JsonObject) -> dict[str, JsonSchema]:
|
|
811
|
+
resolved = _deref(schema, defs, set())
|
|
812
|
+
if not isinstance(resolved, dict):
|
|
813
|
+
return {}
|
|
814
|
+
merged: dict[str, JsonSchema] = {}
|
|
815
|
+
raw = resolved.get("properties")
|
|
816
|
+
if isinstance(raw, dict):
|
|
817
|
+
for key, value in raw.items():
|
|
818
|
+
if isinstance(value, (dict, bool)):
|
|
819
|
+
merged[key] = value
|
|
820
|
+
branches = resolved.get("allOf")
|
|
821
|
+
if isinstance(branches, list):
|
|
822
|
+
for branch in branches:
|
|
823
|
+
if isinstance(branch, (dict, bool)):
|
|
824
|
+
merged.update(_properties(branch, defs))
|
|
825
|
+
return merged
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
def _required_names(schema: JsonSchema, defs: JsonObject | None = None) -> list[str]:
|
|
829
|
+
store = defs if defs is not None else _defs_of(schema)
|
|
830
|
+
resolved = _deref(schema, store, set())
|
|
831
|
+
if not isinstance(resolved, dict):
|
|
832
|
+
return []
|
|
833
|
+
found: list[str] = []
|
|
834
|
+
raw = resolved.get("required")
|
|
835
|
+
if isinstance(raw, list):
|
|
836
|
+
for item in raw:
|
|
837
|
+
if isinstance(item, str) and item not in found:
|
|
838
|
+
found.append(item)
|
|
839
|
+
branches = resolved.get("allOf")
|
|
840
|
+
if isinstance(branches, list):
|
|
841
|
+
for branch in branches:
|
|
842
|
+
if isinstance(branch, (dict, bool)):
|
|
843
|
+
for item in _required_names(branch, store):
|
|
844
|
+
if item not in found:
|
|
845
|
+
found.append(item)
|
|
846
|
+
return found
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _items(schema: JsonSchema, defs: JsonObject) -> JsonSchema | None:
|
|
850
|
+
resolved = _deref(schema, defs, set())
|
|
851
|
+
if not isinstance(resolved, dict):
|
|
852
|
+
return None
|
|
853
|
+
items = resolved.get("items")
|
|
854
|
+
if isinstance(items, (dict, bool)):
|
|
855
|
+
return items
|
|
856
|
+
return None
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _deref(schema: JsonSchema, defs: JsonObject, seen: set[str]) -> JsonSchema:
|
|
860
|
+
if not isinstance(schema, dict):
|
|
861
|
+
return schema
|
|
862
|
+
ref = schema.get("$ref")
|
|
863
|
+
if not isinstance(ref, str) or not ref.startswith(_DEFS):
|
|
864
|
+
return schema
|
|
865
|
+
name = ref[len(_DEFS) :]
|
|
866
|
+
if name in seen:
|
|
867
|
+
return schema
|
|
868
|
+
target = defs.get(name)
|
|
869
|
+
if not isinstance(target, (dict, bool)):
|
|
870
|
+
return schema
|
|
871
|
+
return _deref(target, defs, seen | {name})
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _defs_of(schema: JsonSchema) -> JsonObject:
|
|
875
|
+
if isinstance(schema, dict):
|
|
876
|
+
raw = schema.get("$defs")
|
|
877
|
+
if isinstance(raw, dict):
|
|
878
|
+
return raw
|
|
879
|
+
return {}
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _detach(schema: JsonSchema) -> JsonSchema:
|
|
883
|
+
copied = _copy_schema(schema)
|
|
884
|
+
if isinstance(copied, dict):
|
|
885
|
+
return {key: value for key, value in copied.items() if key != "$defs"}
|
|
886
|
+
return copied
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _attach_defs(schema: JsonSchema, defs: JsonObject) -> JsonSchema:
|
|
890
|
+
if not isinstance(schema, dict):
|
|
891
|
+
return schema
|
|
892
|
+
used: list[str] = []
|
|
893
|
+
seen: set[str] = set()
|
|
894
|
+
|
|
895
|
+
def walk(node: JsonValue) -> None:
|
|
896
|
+
if isinstance(node, dict):
|
|
897
|
+
ref = node.get("$ref")
|
|
898
|
+
if isinstance(ref, str) and ref.startswith(_DEFS):
|
|
899
|
+
name = ref[len(_DEFS) :]
|
|
900
|
+
if name not in seen:
|
|
901
|
+
seen.add(name)
|
|
902
|
+
used.append(name)
|
|
903
|
+
target = defs.get(name)
|
|
904
|
+
if isinstance(target, (dict, bool)):
|
|
905
|
+
walk(target)
|
|
906
|
+
for value in node.values():
|
|
907
|
+
walk(value)
|
|
908
|
+
return
|
|
909
|
+
if isinstance(node, list):
|
|
910
|
+
for item in node:
|
|
911
|
+
walk(item)
|
|
912
|
+
|
|
913
|
+
walk(schema)
|
|
914
|
+
if not used:
|
|
915
|
+
return schema
|
|
916
|
+
attached = dict(schema)
|
|
917
|
+
bundled: JsonObject = {}
|
|
918
|
+
for name in used:
|
|
919
|
+
target = defs.get(name)
|
|
920
|
+
if isinstance(target, (dict, bool)):
|
|
921
|
+
bundled[name] = target
|
|
922
|
+
attached["$defs"] = bundled
|
|
923
|
+
return attached
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
def _copy_schema(schema: JsonSchema) -> JsonSchema:
|
|
927
|
+
parsed = expect_json(json.loads(json.dumps(schema)))
|
|
928
|
+
if isinstance(parsed, (dict, bool)):
|
|
929
|
+
return parsed
|
|
930
|
+
raise TypeError("schema copy failed")
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _same_schema(left: JsonSchema, right: JsonSchema) -> bool:
|
|
934
|
+
return json.dumps(left, sort_keys=True) == json.dumps(right, sort_keys=True)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _accepts(schema: JsonSchema, value: JsonValue) -> bool:
|
|
938
|
+
if schema is True:
|
|
939
|
+
return True
|
|
940
|
+
if schema is False:
|
|
941
|
+
return False
|
|
942
|
+
try:
|
|
943
|
+
Draft202012Validator(schema).validate(value)
|
|
944
|
+
except (ValidationError, SchemaError):
|
|
945
|
+
return False
|
|
946
|
+
return True
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _has_cycle(tools: list[Tool]) -> bool:
|
|
950
|
+
graph = {tool.name: tool.verify.depends_on for tool in tools}
|
|
951
|
+
visiting: set[str] = set()
|
|
952
|
+
visited: set[str] = set()
|
|
953
|
+
|
|
954
|
+
def walk(name: str) -> bool:
|
|
955
|
+
if name in visiting:
|
|
956
|
+
return True
|
|
957
|
+
if name in visited or name not in graph:
|
|
958
|
+
return False
|
|
959
|
+
visiting.add(name)
|
|
960
|
+
for dep in graph[name]:
|
|
961
|
+
if walk(dep):
|
|
962
|
+
return True
|
|
963
|
+
visiting.remove(name)
|
|
964
|
+
visited.add(name)
|
|
965
|
+
return False
|
|
966
|
+
|
|
967
|
+
return any(walk(name) for name in graph)
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def _endpoint(spec: ApiSpec, endpoint_id: str) -> Endpoint | None:
|
|
971
|
+
for endpoint in spec.endpoints:
|
|
972
|
+
if endpoint.id == endpoint_id:
|
|
973
|
+
return endpoint
|
|
974
|
+
return None
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _parameter(endpoint: Endpoint, name: str, location: str) -> Parameter | None:
|
|
978
|
+
for parameter in endpoint.parameters:
|
|
979
|
+
if parameter.name == name and parameter.location == location:
|
|
980
|
+
return parameter
|
|
981
|
+
return None
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def _body_properties(spec: ApiSpec, endpoint: Endpoint) -> dict[str, JsonSchema]:
|
|
985
|
+
schema = body_schema(spec, endpoint)
|
|
986
|
+
if schema is None:
|
|
987
|
+
return {}
|
|
988
|
+
return _properties(schema, _defs_of(schema))
|
|
989
|
+
|
|
990
|
+
|
|
991
|
+
def _shield_doc(value: str) -> str:
|
|
992
|
+
cleaned = value.replace("<<<UNTRUSTED DOC>>>", "[[[UNTRUSTED DOC]]]").replace(
|
|
993
|
+
"<<<END UNTRUSTED DOC>>>",
|
|
994
|
+
"[[[END UNTRUSTED DOC]]]",
|
|
995
|
+
)
|
|
996
|
+
return f"<<<UNTRUSTED DOC>>>\n{cleaned}\n<<<END UNTRUSTED DOC>>>"
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _is_admin(endpoint: Endpoint) -> bool:
|
|
1000
|
+
return "admin" in _tags(endpoint) or "admin" in _segments(endpoint.path)
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _is_internal(endpoint: Endpoint) -> bool:
|
|
1004
|
+
return "internal" in _tags(endpoint) or "internal" in _segments(endpoint.path)
|
|
1005
|
+
|
|
1006
|
+
|
|
1007
|
+
def _is_webhook(endpoint: Endpoint) -> bool:
|
|
1008
|
+
return any("webhook" in tag for tag in _tags(endpoint)) or any(
|
|
1009
|
+
"webhook" in segment for segment in _segments(endpoint.path)
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
|
|
1013
|
+
def _is_bulk(endpoint: Endpoint) -> bool:
|
|
1014
|
+
text = " ".join(
|
|
1015
|
+
(
|
|
1016
|
+
endpoint.path,
|
|
1017
|
+
" ".join(endpoint.tags or []),
|
|
1018
|
+
endpoint.operation_id or "",
|
|
1019
|
+
endpoint.summary or "",
|
|
1020
|
+
)
|
|
1021
|
+
)
|
|
1022
|
+
if _BULK.search(text):
|
|
1023
|
+
return True
|
|
1024
|
+
return endpoint.method.upper() == "DELETE" and "{" not in endpoint.path
|
|
1025
|
+
|
|
1026
|
+
|
|
1027
|
+
def _waived(reason: ExclusionReason, endpoint: Endpoint, include_tags: set[str]) -> bool:
|
|
1028
|
+
if reason in include_tags:
|
|
1029
|
+
return True
|
|
1030
|
+
causes = {tag for tag in _tags(endpoint) if reason == "admin" and tag == "admin"}
|
|
1031
|
+
causes.update(tag for tag in _tags(endpoint) if reason == "internal" and tag == "internal")
|
|
1032
|
+
if reason == "webhook-management":
|
|
1033
|
+
causes.update(tag for tag in _tags(endpoint) if "webhook" in tag)
|
|
1034
|
+
if reason == "destructive-bulk":
|
|
1035
|
+
causes.update(tag for tag in _tags(endpoint) if _BULK.search(tag))
|
|
1036
|
+
return bool(causes & include_tags)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _tags(endpoint: Endpoint) -> set[str]:
|
|
1040
|
+
return {tag.lower() for tag in endpoint.tags or []}
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _segments(path: str) -> set[str]:
|
|
1044
|
+
return {part.lower() for part in path.split("/") if part and not part.startswith("{")}
|
|
1045
|
+
|
|
1046
|
+
|
|
1047
|
+
def _auth_supported(endpoint: Endpoint, schemes: dict[str, SecurityScheme]) -> bool:
|
|
1048
|
+
if not endpoint.security:
|
|
1049
|
+
return True
|
|
1050
|
+
for requirement in endpoint.security:
|
|
1051
|
+
if all(name in schemes and _scheme_supported(schemes[name]) for name in requirement):
|
|
1052
|
+
return True
|
|
1053
|
+
return False
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def _scheme_supported(scheme: SecurityScheme) -> bool:
|
|
1057
|
+
if scheme.type == "apiKey":
|
|
1058
|
+
return True
|
|
1059
|
+
return scheme.type == "http" and (scheme.scheme or "").lower() == "bearer"
|