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,614 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import math
6
+ import re
7
+ from dataclasses import dataclass
8
+ from enum import StrEnum
9
+ from typing import Annotated, Literal, TypeAlias
10
+
11
+ import yaml
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from mcp4api.model import (
15
+ ApiSpec,
16
+ JsonObject,
17
+ JsonObjectField,
18
+ JsonSchema,
19
+ JsonSchemaField,
20
+ JsonValue,
21
+ JsonValueField,
22
+ Parameter,
23
+ bundle_schema,
24
+ expect_json,
25
+ )
26
+
27
+ ExclusionReason: TypeAlias = Literal[
28
+ "admin",
29
+ "internal",
30
+ "deprecated",
31
+ "duplicate",
32
+ "webhook-management",
33
+ "destructive-bulk",
34
+ "low-value",
35
+ "unsupported",
36
+ "invalid-curation",
37
+ ]
38
+
39
+
40
+ class CurationMode(StrEnum):
41
+ AGENT = "agent"
42
+ HEURISTIC = "heuristic"
43
+ MANUAL = "manual"
44
+
45
+
46
+ ParameterIn: TypeAlias = Literal["path", "query", "header", "cookie", "body"]
47
+
48
+
49
+ _FROZEN = ConfigDict(frozen=True, populate_by_name=True, extra="forbid")
50
+
51
+
52
+ _DECISION = ConfigDict(extra="ignore")
53
+
54
+
55
+ _NAME = re.compile(r"^[a-zA-Z0-9_-]{1,64}$")
56
+
57
+
58
+ _NAMESPACE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
59
+
60
+
61
+ _VERSION_TOKEN = re.compile(r"^v\d+$", re.IGNORECASE)
62
+
63
+
64
+ _HTTP = frozenset({"get", "post", "put", "patch", "delete", "head", "options", "trace"})
65
+
66
+
67
+ _SAFE = frozenset({"GET", "HEAD", "OPTIONS"})
68
+
69
+
70
+ _IDEMPOTENT = _SAFE | {"PUT", "DELETE"}
71
+
72
+
73
+ _NOTES: dict[ExclusionReason, str] = {
74
+ "admin": "Admin operations are excluded unless the matching tag is included.",
75
+ "internal": "Internal operations are excluded unless the matching tag is included.",
76
+ "deprecated": "Deprecated operations are excluded unless the matching tag is included.",
77
+ "webhook-management": "Webhook management is excluded unless the matching tag is included.",
78
+ "destructive-bulk": (
79
+ "Bulk destructive operations are excluded unless the matching tag is included."
80
+ ),
81
+ "unsupported": "The endpoint requires an authentication scheme the server cannot send.",
82
+ "invalid-curation": "The tool failed validation and was not emitted.",
83
+ "low-value": "Omitted because it does not map to a primary user intent.",
84
+ "duplicate": "Omitted because another tool already covers it.",
85
+ }
86
+
87
+
88
+ class Annotations(BaseModel):
89
+ model_config = _FROZEN
90
+
91
+ readOnlyHint: bool
92
+ destructiveHint: bool
93
+ idempotentHint: bool
94
+ openWorldHint: bool
95
+
96
+
97
+ class MapsTo(BaseModel):
98
+ model_config = _FROZEN
99
+
100
+ endpoint_id: str
101
+ param: str
102
+ in_: Annotated[ParameterIn, Field(alias="in")]
103
+
104
+
105
+ class ToolInput(BaseModel):
106
+ model_config = _FROZEN
107
+
108
+ name: str
109
+ schema_: Annotated[JsonSchemaField, Field(alias="schema")]
110
+ description: str
111
+ required: bool
112
+ maps_to: MapsTo
113
+
114
+
115
+ class FixedParam(BaseModel):
116
+ model_config = _FROZEN
117
+
118
+ endpoint_id: str
119
+ param: str
120
+ in_: Annotated[ParameterIn, Field(alias="in")]
121
+ value: JsonValueField
122
+ reason: str
123
+
124
+
125
+ class Bind(BaseModel):
126
+ model_config = _FROZEN
127
+
128
+ param: str
129
+ in_: Annotated[ParameterIn, Field(alias="in")]
130
+ ref: str
131
+
132
+
133
+ class Step(BaseModel):
134
+ model_config = _FROZEN
135
+
136
+ endpoint_id: str
137
+ bind: list[Bind]
138
+
139
+
140
+ class ToolOutput(BaseModel):
141
+ model_config = _FROZEN
142
+
143
+ from_step: int
144
+ fields: list[str]
145
+ schema_: Annotated[JsonSchemaField, Field(alias="schema")]
146
+
147
+
148
+ class Verify(BaseModel):
149
+ model_config = _FROZEN
150
+
151
+ example_args: JsonObjectField
152
+ depends_on: list[str]
153
+
154
+
155
+ class Tool(BaseModel):
156
+ model_config = _FROZEN
157
+
158
+ name: str
159
+ title: str
160
+ description: str
161
+ intent: str
162
+ annotations: Annotations
163
+ inputs: list[ToolInput]
164
+ fixed: list[FixedParam]
165
+ steps: list[Step]
166
+ output: ToolOutput
167
+ verify: Verify
168
+ rationale: str
169
+
170
+
171
+ class Excluded(BaseModel):
172
+ model_config = _FROZEN
173
+
174
+ endpoint_id: str
175
+ reason: ExclusionReason
176
+ note: str
177
+
178
+
179
+ class ApiInfo(BaseModel):
180
+ model_config = _FROZEN
181
+
182
+ title: str
183
+ base_url: str
184
+ namespace: str
185
+
186
+
187
+ class Usage(BaseModel):
188
+ model_config = _FROZEN
189
+
190
+ input_tokens: int = 0
191
+ output_tokens: int = 0
192
+ cache_read_input_tokens: int = 0
193
+ cache_write_input_tokens: int = 0
194
+ estimated_usd: float = 0
195
+
196
+
197
+ class CurationInfo(BaseModel):
198
+ model_config = _FROZEN
199
+
200
+ mode: CurationMode
201
+ model: str
202
+ input_hash: str
203
+ usage: Usage
204
+ fallback_tools: list[str]
205
+
206
+
207
+ class Manifest(BaseModel):
208
+ model_config = _FROZEN
209
+
210
+ api: ApiInfo
211
+ tools: list[Tool]
212
+ excluded: list[Excluded]
213
+ curation: CurationInfo
214
+
215
+
216
+ class AnnotationDraft(BaseModel):
217
+ model_config = _DECISION
218
+
219
+ readOnlyHint: bool
220
+ destructiveHint: bool
221
+ idempotentHint: bool
222
+ openWorldHint: bool
223
+
224
+
225
+ class InputDraft(BaseModel):
226
+ model_config = _DECISION
227
+
228
+ name: str
229
+ description: str
230
+ required: bool
231
+ endpoint_id: str
232
+ param: str
233
+ in_: Annotated[str, Field(alias="in")]
234
+
235
+
236
+ class FixedDraft(BaseModel):
237
+ model_config = _DECISION
238
+
239
+ endpoint_id: str
240
+ param: str
241
+ in_: Annotated[str, Field(alias="in")]
242
+ value_json: str
243
+ reason: str
244
+
245
+
246
+ class BindDraft(BaseModel):
247
+ model_config = _DECISION
248
+
249
+ param: str
250
+ in_: Annotated[str, Field(alias="in")]
251
+ ref: str
252
+
253
+
254
+ class StepDraft(BaseModel):
255
+ model_config = _DECISION
256
+
257
+ endpoint_id: str
258
+ binds: list[BindDraft]
259
+
260
+
261
+ class ExampleDraft(BaseModel):
262
+ model_config = _DECISION
263
+
264
+ name: str
265
+ value_json: str
266
+
267
+
268
+ class ToolDraft(BaseModel):
269
+ model_config = _DECISION
270
+
271
+ name: str
272
+ title: str
273
+ description: str
274
+ intent: str
275
+ annotations: AnnotationDraft
276
+ inputs: list[InputDraft]
277
+ fixed: list[FixedDraft]
278
+ steps: list[StepDraft]
279
+ output_from_step: int
280
+ output_fields: list[str]
281
+ examples: list[ExampleDraft]
282
+ depends_on: list[str]
283
+ rationale: str
284
+
285
+
286
+ class PlanExclusion(BaseModel):
287
+ model_config = _DECISION
288
+
289
+ endpoint_id: str
290
+ reason: str
291
+ note: str
292
+
293
+
294
+ class PlanTool(BaseModel):
295
+ model_config = _DECISION
296
+
297
+ name: str
298
+ endpoint_ids: list[str]
299
+ rationale: str
300
+
301
+
302
+ class PlanDecision(BaseModel):
303
+ model_config = _DECISION
304
+
305
+ tools: list[PlanTool]
306
+ excluded: list[PlanExclusion]
307
+
308
+
309
+ class DetailDecision(BaseModel):
310
+ model_config = _DECISION
311
+
312
+ tools: list[ToolDraft]
313
+ excluded: list[PlanExclusion]
314
+
315
+
316
+ class ExcludeOverride(BaseModel):
317
+ model_config = _DECISION
318
+
319
+ endpoint_id: str
320
+ reason: str
321
+ note: str = ""
322
+
323
+
324
+ class FixedOverride(BaseModel):
325
+ model_config = _DECISION
326
+
327
+ tool: str
328
+ endpoint_id: str
329
+ param: str
330
+ in_: Annotated[ParameterIn, Field(alias="in")]
331
+ value: JsonValueField
332
+ reason: str
333
+
334
+
335
+ class Overrides(BaseModel):
336
+ model_config = _DECISION
337
+
338
+ renames: dict[str, str] = Field(default_factory=dict)
339
+ descriptions: dict[str, str] = Field(default_factory=dict)
340
+ exclude_endpoints: list[ExcludeOverride] = Field(default_factory=list)
341
+ include_endpoints: list[str] = Field(default_factory=list)
342
+ fixed: list[FixedOverride] = Field(default_factory=list)
343
+ outputs: dict[str, list[str]] = Field(default_factory=dict)
344
+
345
+
346
+ @dataclass(frozen=True)
347
+ class PlanResult:
348
+ tools: tuple[PlanTool, ...]
349
+ excluded: tuple[Excluded, ...]
350
+ uncovered: tuple[str, ...]
351
+ errors: tuple[str, ...]
352
+
353
+
354
+ @dataclass(frozen=True)
355
+ class DetailResult:
356
+ tools: tuple[Tool, ...]
357
+ excluded: tuple[Excluded, ...]
358
+ errors: tuple[str, ...]
359
+
360
+
361
+ def zero_usage() -> Usage:
362
+ return Usage()
363
+
364
+
365
+ def base_url(spec: ApiSpec) -> str:
366
+ if not spec.servers:
367
+ return ""
368
+ server = spec.servers[0]
369
+ url = server.url
370
+ for name, variable in (server.variables or {}).items():
371
+ url = url.replace("{" + name + "}", variable.default)
372
+ return url
373
+
374
+
375
+ def minor_version(version: str) -> str:
376
+ numbers: list[str] = []
377
+ for part in version.split(".")[:2]:
378
+ digits = ""
379
+ for char in part:
380
+ if not char.isdigit():
381
+ break
382
+ digits += char
383
+ numbers.append(digits or "0")
384
+ if len(numbers) < 2:
385
+ numbers.append("0")
386
+ return ".".join(numbers)
387
+
388
+
389
+ def canonical_flags(
390
+ *,
391
+ mode: str,
392
+ include_tags: tuple[str, ...],
393
+ max_tools: int,
394
+ namespace: str,
395
+ ) -> str:
396
+ tags = ",".join(sorted(include_tags))
397
+ return f"curation={mode};include_tag={tags};max_tools={max_tools};namespace={namespace}"
398
+
399
+
400
+ def curation_hash(
401
+ endpoints: bytes,
402
+ *,
403
+ prompt: str,
404
+ model: str,
405
+ mode: str,
406
+ include_tags: tuple[str, ...],
407
+ max_tools: int,
408
+ namespace: str,
409
+ version: str,
410
+ ) -> str:
411
+ payload = b"\0".join(
412
+ (
413
+ endpoints,
414
+ prompt.encode("utf-8"),
415
+ model.encode("utf-8"),
416
+ minor_version(version).encode("utf-8"),
417
+ canonical_flags(
418
+ mode=mode,
419
+ include_tags=include_tags,
420
+ max_tools=max_tools,
421
+ namespace=namespace,
422
+ ).encode("utf-8"),
423
+ )
424
+ )
425
+ return hashlib.sha256(payload).hexdigest()
426
+
427
+
428
+ def namespace_error(namespace: str) -> str | None:
429
+ if namespace and _NAMESPACE.fullmatch(namespace) is None:
430
+ return f"namespace {namespace} is not allowed"
431
+ return None
432
+
433
+
434
+ def name_error(name: str, *, namespace: str = "") -> str | None:
435
+ if _NAME.fullmatch(name) is None:
436
+ return f"tool name {name} is not allowed"
437
+ short = name
438
+ verb_at = 0
439
+ if namespace and name.startswith(f"{namespace}_"):
440
+ short = name[len(namespace) + 1 :]
441
+ verb_at = len([token for token in re.split(r"[_-]", namespace) if token])
442
+ if short.startswith("api_"):
443
+ return f"tool name {name} uses an api_ prefix"
444
+ tokens = [token for token in re.split(r"[_-]", name) if token]
445
+ for index, token in enumerate(tokens):
446
+ if _VERSION_TOKEN.fullmatch(token):
447
+ return f"tool name {name} contains a version segment"
448
+ lowered = token.lower()
449
+ if lowered in _HTTP and not (index == verb_at and lowered in {"get", "delete"}):
450
+ return f"tool name {name} contains an HTTP verb"
451
+ return None
452
+
453
+
454
+ def apply_namespace(name: str, namespace: str) -> str:
455
+ if not namespace:
456
+ return name
457
+ prefix = f"{namespace}_"
458
+ short = name[len(prefix) :] if name.startswith(prefix) else name
459
+ return f"{prefix}{short}"
460
+
461
+
462
+ def annotations_for(methods: list[str]) -> Annotations:
463
+ upper = [method.upper() for method in methods] or ["GET"]
464
+ return Annotations(
465
+ readOnlyHint=all(method in _SAFE for method in upper),
466
+ destructiveHint=any(method == "DELETE" for method in upper),
467
+ idempotentHint=all(method in _IDEMPOTENT for method in upper),
468
+ openWorldHint=True,
469
+ )
470
+
471
+
472
+ def parse_reason(value: str) -> ExclusionReason | None:
473
+ if value == "admin":
474
+ return "admin"
475
+ if value == "internal":
476
+ return "internal"
477
+ if value == "deprecated":
478
+ return "deprecated"
479
+ if value == "duplicate":
480
+ return "duplicate"
481
+ if value == "webhook-management":
482
+ return "webhook-management"
483
+ if value == "destructive-bulk":
484
+ return "destructive-bulk"
485
+ if value == "low-value":
486
+ return "low-value"
487
+ if value == "unsupported":
488
+ return "unsupported"
489
+ if value == "invalid-curation":
490
+ return "invalid-curation"
491
+ return None
492
+
493
+
494
+ def parse_location(value: str) -> ParameterIn | None:
495
+ if value == "path":
496
+ return "path"
497
+ if value == "query":
498
+ return "query"
499
+ if value == "header":
500
+ return "header"
501
+ if value == "cookie":
502
+ return "cookie"
503
+ if value == "body":
504
+ return "body"
505
+ return None
506
+
507
+
508
+ def bundle_parameter(spec: ApiSpec, parameter: Parameter) -> JsonSchema:
509
+ return bundle_schema(spec, parameter.schema_)
510
+
511
+
512
+ def dump_manifest(manifest: Manifest) -> str:
513
+ ordered = manifest.model_copy(
514
+ update={
515
+ "tools": sorted(manifest.tools, key=lambda tool: tool.name),
516
+ "excluded": sorted(manifest.excluded, key=lambda item: item.endpoint_id),
517
+ }
518
+ )
519
+ payload = _object(ordered.model_dump(mode="json", by_alias=True))
520
+ text = yaml.safe_dump(payload, sort_keys=False, allow_unicode=True, width=4096)
521
+ if not text.endswith("\n"):
522
+ text += "\n"
523
+ return text.replace("\r\n", "\n").replace("\r", "\n")
524
+
525
+
526
+ def load_manifest(text: str) -> Manifest:
527
+ return Manifest.model_validate(_yaml_object(text))
528
+
529
+
530
+ def load_overrides(text: str) -> Overrides:
531
+ if not text.strip():
532
+ return Overrides()
533
+ return Overrides.model_validate(_yaml_object(text))
534
+
535
+
536
+ def build_manifest(
537
+ spec: ApiSpec,
538
+ tools: list[Tool],
539
+ excluded: list[Excluded],
540
+ *,
541
+ namespace: str,
542
+ mode: CurationMode,
543
+ model: str,
544
+ digest: str,
545
+ usage: Usage,
546
+ fallback_tools: list[str],
547
+ max_tools: int,
548
+ ) -> Manifest:
549
+ ready: list[Tool] = []
550
+ over_target = len(tools) > max_tools
551
+ for tool in tools:
552
+ rationale = tool.rationale.strip()
553
+ if over_target and not rationale:
554
+ rationale = "Exceeds the tool target."
555
+ elif not rationale:
556
+ rationale = tool.rationale
557
+ ready.append(tool.model_copy(update={"rationale": rationale or "Curated tool."}))
558
+ claimed: set[str] = set()
559
+ unique_excluded: list[Excluded] = []
560
+ for item in excluded:
561
+ if item.endpoint_id in claimed:
562
+ continue
563
+ claimed.add(item.endpoint_id)
564
+ unique_excluded.append(item)
565
+ return Manifest(
566
+ api=ApiInfo(title=spec.title, base_url=base_url(spec), namespace=namespace),
567
+ tools=ready,
568
+ excluded=unique_excluded,
569
+ curation=CurationInfo(
570
+ mode=mode,
571
+ model=model,
572
+ input_hash=digest,
573
+ usage=usage,
574
+ fallback_tools=sorted(set(fallback_tools)),
575
+ ),
576
+ )
577
+
578
+
579
+ def invalid_exclusions(endpoint_ids: list[str], note: str) -> list[Excluded]:
580
+ text = note.strip() or _NOTES["invalid-curation"]
581
+ return [
582
+ Excluded(endpoint_id=endpoint_id, reason="invalid-curation", note=text)
583
+ for endpoint_id in endpoint_ids
584
+ ]
585
+
586
+
587
+ def parse_scalar(text: str) -> tuple[JsonValue, str | None]:
588
+ try:
589
+ loaded = json.loads(text)
590
+ except json.JSONDecodeError:
591
+ return None, "fixed value is not JSON"
592
+ try:
593
+ parsed = expect_json(loaded)
594
+ except TypeError:
595
+ return None, "fixed value must be a scalar"
596
+ if isinstance(parsed, float) and not math.isfinite(parsed):
597
+ return None, "fixed value is not a finite number"
598
+ if parsed is None or isinstance(parsed, (str, bool, int)):
599
+ return parsed, None
600
+ if isinstance(parsed, float):
601
+ return parsed, None
602
+ return None, "fixed value must be a scalar"
603
+
604
+
605
+ def _yaml_object(text: str) -> JsonObject:
606
+ loaded = yaml.safe_load(text)
607
+ return _object(loaded)
608
+
609
+
610
+ def _object(value: object) -> JsonObject:
611
+ parsed = expect_json(value)
612
+ if isinstance(parsed, dict):
613
+ return parsed
614
+ raise ValueError("YAML must be an object")