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.
@@ -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"