pi-codemcp 0.1.0

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,838 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import keyword
6
+ import re
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ from fastmcp.utilities.json_schema_type import json_schema_to_type
10
+ from mcp import types as mcp_types
11
+ from pydantic import BaseModel, ConfigDict, TypeAdapter
12
+ from pydantic_core import to_jsonable_python
13
+ from rapidfuzz import fuzz, process, utils
14
+
15
+ from .json_types import (
16
+ JSON_OBJECT_ADAPTER,
17
+ JSON_VALUE_ADAPTER,
18
+ JsonObject,
19
+ JsonSchema,
20
+ JsonValue,
21
+ )
22
+ from .models import ToolSchemaView
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Iterable
26
+
27
+ from .chains import SavedChainManifest
28
+
29
+ # A 50-point partial match is generic half-string overlap; require evidence above it.
30
+ SEARCH_SCORE_CUTOFF = 51
31
+ STUB_IMPORTS = "from typing import Literal, Never, NotRequired, TypeAlias, TypedDict"
32
+ JSON_TYPE_STUBS = (
33
+ "JsonScalar: TypeAlias = bool | int | float | str | None",
34
+ 'JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]',
35
+ )
36
+
37
+
38
+ class ToolSpec(BaseModel):
39
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid", strict=True)
40
+
41
+ name: str
42
+ backend_name: str
43
+ server: str
44
+ namespace: str
45
+ method: str
46
+ call: str
47
+ external_name: str
48
+ short_name: str
49
+ description: str | None
50
+ input_schema: JsonObject
51
+ output_schema: JsonObject | None
52
+ input_type_name: str
53
+ output_type_name: str
54
+ signature: str
55
+ stub: str
56
+ search_blob: str
57
+ input_adapter: TypeAdapter[object]
58
+ kind: Literal["mcp_tool", "saved_chain"] = "mcp_tool"
59
+ chain_id: str | None = None
60
+ schema_fingerprint: str = ""
61
+ output_adapter: TypeAdapter[object] | None = None
62
+ wrapped_output_adapter: TypeAdapter[object] | None = None
63
+ output_wrap_result: bool = False
64
+
65
+
66
+ class ToolCatalog(BaseModel):
67
+ model_config = ConfigDict(extra="forbid", strict=True)
68
+
69
+ fingerprint: str
70
+ tools: dict[str, ToolSpec]
71
+ type_stubs: str
72
+ servers: tuple[str, ...]
73
+ server_aliases: dict[str, str]
74
+ facade_calls: dict[tuple[str, str], str]
75
+
76
+ @classmethod
77
+ def from_server_tools(
78
+ cls,
79
+ server_tools: dict[str, list[mcp_types.Tool]],
80
+ server_names: Iterable[str] | None = None,
81
+ saved_chains: Iterable[SavedChainManifest] = (),
82
+ ) -> ToolCatalog:
83
+ server_name_list = tuple(server_names or server_tools.keys())
84
+ namespace_aliases = _unique_python_aliases(
85
+ server_name_list,
86
+ reserved={"chains"},
87
+ )
88
+ prepared: list[
89
+ tuple[
90
+ mcp_types.Tool,
91
+ str,
92
+ str,
93
+ str,
94
+ str,
95
+ Literal["mcp_tool", "saved_chain"],
96
+ str | None,
97
+ ]
98
+ ] = []
99
+ for server in server_name_list:
100
+ tools = sorted(server_tools.get(server, []), key=lambda item: item.name)
101
+ method_aliases = _unique_python_aliases(tool.name for tool in tools)
102
+ for tool in tools:
103
+ public_name = f"{server}_{tool.name}"
104
+ prepared.append((
105
+ tool,
106
+ public_name,
107
+ server,
108
+ namespace_aliases[server],
109
+ method_aliases[tool.name],
110
+ "mcp_tool",
111
+ None,
112
+ ))
113
+
114
+ chains = sorted(saved_chains, key=lambda chain: chain.name)
115
+ prepared.extend(
116
+ (
117
+ mcp_types.Tool(
118
+ name=chain.name,
119
+ description=chain.description,
120
+ inputSchema=chain.input_schema,
121
+ outputSchema=chain.output_schema,
122
+ ),
123
+ chain.public_name,
124
+ "chains",
125
+ "chains",
126
+ chain.name,
127
+ "saved_chain",
128
+ chain.id,
129
+ )
130
+ for chain in chains
131
+ )
132
+ catalog_servers = server_name_list + (("chains",) if chains else ())
133
+ return cls._from_prepared(prepared, catalog_servers, namespace_aliases)
134
+
135
+ @classmethod
136
+ def from_mcp_tools(
137
+ cls,
138
+ tools: Iterable[mcp_types.Tool],
139
+ server_names: Iterable[str],
140
+ ) -> ToolCatalog:
141
+ """Compatibility constructor for already namespaced aggregate catalogs."""
142
+ names = tuple(server_names)
143
+ grouped: dict[str, list[mcp_types.Tool]] = {name: [] for name in names}
144
+ for tool in tools:
145
+ server = _extract_server_name(tool.name, names)
146
+ if server is None:
147
+ if len(names) != 1:
148
+ raise ValueError(f"Cannot determine server for MCP tool {tool.name!r}")
149
+ server = names[0]
150
+ backend_name = tool.name
151
+ else:
152
+ backend_name = tool.name[len(server) + 1 :]
153
+ grouped[server].append(tool.model_copy(update={"name": backend_name}))
154
+ return cls.from_server_tools(grouped, names)
155
+
156
+ @classmethod
157
+ def _from_prepared(
158
+ cls,
159
+ prepared: list[
160
+ tuple[
161
+ mcp_types.Tool,
162
+ str,
163
+ str,
164
+ str,
165
+ str,
166
+ Literal["mcp_tool", "saved_chain"],
167
+ str | None,
168
+ ]
169
+ ],
170
+ server_names: tuple[str, ...],
171
+ server_aliases: dict[str, str],
172
+ ) -> ToolCatalog:
173
+ fingerprint_source = [
174
+ {
175
+ "name": public_name,
176
+ "backendName": tool.name,
177
+ "server": server,
178
+ "call": f"{namespace}.{method}",
179
+ "kind": kind,
180
+ "chainId": chain_id,
181
+ "description": tool.description,
182
+ "inputSchema": tool.inputSchema,
183
+ "outputSchema": tool.outputSchema,
184
+ }
185
+ for tool, public_name, server, namespace, method, kind, chain_id in prepared
186
+ ]
187
+ fingerprint = hashlib.sha256(
188
+ json.dumps(fingerprint_source, sort_keys=True, default=str).encode()
189
+ ).hexdigest()
190
+
191
+ specs: dict[str, ToolSpec] = {}
192
+ definitions: list[str] = []
193
+ facade_methods: dict[str, list[str]] = {}
194
+ facade_classes: dict[str, str] = {}
195
+ facade_calls: dict[tuple[str, str], str] = {}
196
+
197
+ for tool, public_name, server, namespace, method, kind, chain_id in prepared:
198
+ if public_name in specs:
199
+ raise ValueError(f"Duplicate MCP tool name after namespacing: {public_name}")
200
+ input_schema = JSON_OBJECT_ADAPTER.validate_python(tool.inputSchema)
201
+ output_schema = (
202
+ JSON_OBJECT_ADAPTER.validate_python(tool.outputSchema)
203
+ if tool.outputSchema is not None
204
+ else None
205
+ )
206
+ builder = StubBuilder(
207
+ public_name,
208
+ input_schema,
209
+ output_schema,
210
+ normalize_json_string_output=kind == "mcp_tool",
211
+ )
212
+ input_type, output_type, tool_definitions = builder.build()
213
+ call = f"{namespace}.{method}"
214
+ signature = f"await {call}(arguments: {input_type}) -> {output_type}"
215
+ wrap_output = bool(
216
+ kind == "mcp_tool" and output_schema and output_schema.get("x-fastmcp-wrap-result")
217
+ )
218
+ adapter_schema: JsonSchema | None = output_schema
219
+ wrapped_schema: JsonSchema | None = None
220
+ if output_schema:
221
+ raw_wrapped_schema = _object_property(output_schema, "result")
222
+ if isinstance(raw_wrapped_schema, (dict, bool)):
223
+ wrapped_schema = raw_wrapped_schema
224
+ if wrap_output and output_schema:
225
+ adapter_schema = wrapped_schema or True
226
+ external_name = f"__codemcp_{hashlib.sha256(public_name.encode()).hexdigest()[:16]}"
227
+
228
+ spec = ToolSpec(
229
+ name=public_name,
230
+ backend_name=tool.name,
231
+ server=server,
232
+ namespace=namespace,
233
+ method=method,
234
+ call=call,
235
+ external_name=external_name,
236
+ short_name=tool.name,
237
+ description=tool.description,
238
+ input_schema=input_schema,
239
+ output_schema=output_schema,
240
+ input_type_name=input_type,
241
+ output_type_name=output_type,
242
+ signature=signature,
243
+ stub="\n\n".join([*JSON_TYPE_STUBS, *tool_definitions]),
244
+ search_blob=_build_search_blob(
245
+ public_name,
246
+ call,
247
+ server,
248
+ tool.name,
249
+ tool.description,
250
+ input_schema,
251
+ ),
252
+ input_adapter=_schema_adapter(input_schema),
253
+ kind=kind,
254
+ chain_id=chain_id,
255
+ schema_fingerprint=_schema_fingerprint({
256
+ "input_schema": input_schema,
257
+ "output_schema": output_schema,
258
+ }),
259
+ output_adapter=(
260
+ _schema_adapter(adapter_schema) if adapter_schema is not None else None
261
+ ),
262
+ wrapped_output_adapter=(
263
+ _schema_adapter(wrapped_schema) if wrapped_schema is not None else None
264
+ ),
265
+ output_wrap_result=wrap_output,
266
+ )
267
+ specs[public_name] = spec
268
+ definitions.extend(tool_definitions)
269
+ class_name = facade_classes.setdefault(
270
+ namespace,
271
+ f"_{_pascal_case(namespace)}Sdk",
272
+ )
273
+ facade_methods.setdefault(namespace, []).append(
274
+ f" async def {method}(self, arguments: {input_type}) -> {output_type}: ..."
275
+ )
276
+ facade_calls[namespace, method] = public_name
277
+
278
+ facade_stubs: list[str] = []
279
+ for namespace in sorted(facade_methods):
280
+ class_name = facade_classes[namespace]
281
+ facade_stubs.extend((
282
+ "\n".join([f"class {class_name}:", *facade_methods[namespace]]),
283
+ f"{namespace}: {class_name}",
284
+ ))
285
+
286
+ type_stubs = "\n\n".join([
287
+ STUB_IMPORTS,
288
+ *JSON_TYPE_STUBS,
289
+ *_dedupe(definitions),
290
+ *facade_stubs,
291
+ ])
292
+ return cls(
293
+ fingerprint=fingerprint,
294
+ tools=specs,
295
+ type_stubs=type_stubs,
296
+ servers=server_names,
297
+ server_aliases=server_aliases,
298
+ facade_calls=facade_calls,
299
+ )
300
+
301
+ def search(
302
+ self,
303
+ query: str,
304
+ limit: int = 5,
305
+ *,
306
+ server: str | None = None,
307
+ ) -> list[ToolSchemaView]:
308
+ candidates = {
309
+ spec.name: spec.search_blob
310
+ for spec in sorted(self.tools.values(), key=lambda item: item.name)
311
+ if server is None or spec.server == server
312
+ }
313
+ ranked = process.extract(
314
+ query,
315
+ candidates,
316
+ scorer=fuzz.partial_ratio,
317
+ processor=utils.default_process,
318
+ score_cutoff=SEARCH_SCORE_CUTOFF,
319
+ limit=limit,
320
+ )
321
+ return [
322
+ ToolSchemaView(
323
+ name=self.tools[name].name,
324
+ call=self.tools[name].call,
325
+ source=self.tools[name].kind,
326
+ server=self.tools[name].server,
327
+ description=_short_description(self.tools[name].description),
328
+ signature=self.tools[name].signature,
329
+ stub=self.tools[name].stub,
330
+ )
331
+ for _, _, name in ranked
332
+ ]
333
+
334
+ def counts_by_server(self) -> dict[str, int]:
335
+ counts = dict.fromkeys(self.servers, 0)
336
+ for spec in self.tools.values():
337
+ if spec.server:
338
+ counts[spec.server] = counts.get(spec.server, 0) + 1
339
+ return counts
340
+
341
+ def validate_arguments(self, tool_name: str, arguments: JsonObject) -> JsonObject:
342
+ spec = self.tools[tool_name]
343
+ value = spec.input_adapter.validate_python(arguments)
344
+ dumped = JSON_VALUE_ADAPTER.validate_python(
345
+ spec.input_adapter.dump_python(
346
+ value,
347
+ mode="json",
348
+ exclude_unset=True,
349
+ )
350
+ )
351
+ if not isinstance(dumped, dict):
352
+ raise TypeError(f"{tool_name}: arguments did not validate as an object")
353
+ projected = _project_to_input_shape(dumped, arguments)
354
+ if not isinstance(projected, dict):
355
+ raise TypeError(f"{tool_name}: arguments did not normalize as an object")
356
+ return projected
357
+
358
+ def validate_saved_chain_result(self, tool_name: str, result: JsonValue) -> JsonValue:
359
+ spec = self.tools[tool_name]
360
+ if spec.kind != "saved_chain" or spec.output_adapter is None:
361
+ raise TypeError(f"{tool_name} is not a saved chain with an output contract")
362
+ validated = spec.output_adapter.validate_python(result)
363
+ return JSON_VALUE_ADAPTER.validate_python(
364
+ spec.output_adapter.dump_python(validated, mode="json")
365
+ )
366
+
367
+ def normalize_result(self, tool_name: str, result: mcp_types.CallToolResult) -> JsonValue:
368
+ spec = self.tools[tool_name]
369
+ if result.isError:
370
+ message = "Upstream MCP tool returned an error"
371
+ if result.content and isinstance(result.content[0], mcp_types.TextContent):
372
+ message = result.content[0].text
373
+ raise RuntimeError(f"{tool_name}: {message}")
374
+
375
+ if spec.output_schema:
376
+ if result.structuredContent is None:
377
+ raise RuntimeError(
378
+ f"{tool_name}: upstream returned no structuredContent "
379
+ "for its declared output schema"
380
+ )
381
+ structured = JSON_VALUE_ADAPTER.validate_python(result.structuredContent)
382
+ raw_meta: object = (result.meta or {}).get("fastmcp")
383
+ wrap_from_meta = isinstance(raw_meta, dict) and bool(raw_meta.get("wrap_result"))
384
+ if spec.output_wrap_result or wrap_from_meta:
385
+ if not isinstance(structured, dict) or "result" not in structured:
386
+ raise RuntimeError(f"{tool_name}: wrapped output omitted the result field")
387
+ structured = structured["result"]
388
+ adapter = spec.output_adapter
389
+ if wrap_from_meta and not spec.output_wrap_result:
390
+ adapter = spec.wrapped_output_adapter
391
+ if adapter is None:
392
+ return _normalize_json_value(
393
+ JSON_VALUE_ADAPTER.validate_python(to_jsonable_python(structured))
394
+ )
395
+ validated = adapter.validate_python(structured)
396
+ return _normalize_json_value(
397
+ JSON_VALUE_ADAPTER.validate_python(adapter.dump_python(validated, mode="json"))
398
+ )
399
+
400
+ if result.structuredContent is not None:
401
+ return _normalize_json_value(
402
+ JSON_VALUE_ADAPTER.validate_python(to_jsonable_python(result.structuredContent))
403
+ )
404
+ if len(result.content) == 1 and isinstance(result.content[0], mcp_types.TextContent):
405
+ return _normalize_text_result(result.content[0].text)
406
+ return [
407
+ JSON_OBJECT_ADAPTER.validate_python(block.model_dump(mode="json", by_alias=True))
408
+ for block in result.content
409
+ ]
410
+
411
+
412
+ def _project_to_input_shape(normalized: JsonValue, supplied: JsonValue) -> JsonValue:
413
+ if isinstance(normalized, dict) and isinstance(supplied, dict):
414
+ return {
415
+ key: _project_to_input_shape(normalized[key], supplied_value)
416
+ for key, supplied_value in supplied.items()
417
+ if key in normalized
418
+ }
419
+ if isinstance(normalized, list) and isinstance(supplied, list):
420
+ return [
421
+ _project_to_input_shape(item, supplied[index])
422
+ for index, item in enumerate(normalized)
423
+ if index < len(supplied)
424
+ ]
425
+ return normalized
426
+
427
+
428
+ def _normalize_json_value(value: JsonValue) -> JsonValue:
429
+ if isinstance(value, str):
430
+ return _normalize_text_result(value)
431
+ if isinstance(value, dict):
432
+ return {key: _normalize_json_value(item) for key, item in value.items()}
433
+ if isinstance(value, list):
434
+ return [_normalize_json_value(item) for item in value]
435
+ return value
436
+
437
+
438
+ def _normalize_text_result(text: str) -> JsonValue:
439
+ stripped = text.strip()
440
+ if stripped in {"null", "true", "false"}:
441
+ return JSON_VALUE_ADAPTER.validate_json(stripped)
442
+ if not stripped.startswith(("{", "[")):
443
+ return text
444
+ try:
445
+ parsed = json.loads(stripped)
446
+ except json.JSONDecodeError:
447
+ return text
448
+ return (
449
+ _normalize_json_value(JSON_VALUE_ADAPTER.validate_python(parsed))
450
+ if isinstance(parsed, (dict, list))
451
+ else text
452
+ )
453
+
454
+
455
+ def _as_schema(value: JsonValue | None) -> JsonSchema:
456
+ return value if isinstance(value, (dict, bool)) else True
457
+
458
+
459
+ def _object_property(schema: JsonObject, name: str) -> JsonValue | None:
460
+ properties = schema.get("properties")
461
+ return properties.get(name) if isinstance(properties, dict) else None
462
+
463
+
464
+ def _schema_adapter(schema: JsonSchema) -> TypeAdapter[object]:
465
+ return TypeAdapter(json_schema_to_type(schema))
466
+
467
+
468
+ class StubBuilder:
469
+ def __init__(
470
+ self,
471
+ tool_name: str,
472
+ input_schema: JsonObject,
473
+ output_schema: JsonObject | None,
474
+ *,
475
+ normalize_json_string_output: bool = True,
476
+ ) -> None:
477
+ self.tool_name = tool_name
478
+ self.input_schema = input_schema
479
+ self.output_schema = output_schema
480
+ self.normalize_json_string_output = normalize_json_string_output
481
+ self._definitions: list[str] = []
482
+ self._seen: dict[tuple[str, str], str] = {}
483
+ self._active_refs: set[str] = set()
484
+
485
+ def build(self) -> tuple[str, str, list[str]]:
486
+ input_type = self._ensure_named_type(
487
+ f"{_pascal_case(self.tool_name)}Args",
488
+ self.input_schema,
489
+ self.input_schema,
490
+ )
491
+ output_schema: JsonSchema = self.output_schema or True
492
+ if (
493
+ self.normalize_json_string_output
494
+ and self.output_schema
495
+ and self.output_schema.get("x-fastmcp-wrap-result")
496
+ ):
497
+ output_schema = _as_schema(_object_property(self.output_schema, "result"))
498
+ if (
499
+ self.normalize_json_string_output
500
+ and isinstance(output_schema, dict)
501
+ and output_schema.get("type") == "string"
502
+ ):
503
+ # Runtime normalization promotes JSON object/array text to native values.
504
+ # A plain `str` annotation would therefore be a false guarantee.
505
+ output_schema = True
506
+ output_type = self._ensure_named_type(
507
+ f"{_pascal_case(self.tool_name)}Result",
508
+ output_schema,
509
+ self.output_schema or output_schema,
510
+ )
511
+ return input_type, output_type, _dedupe(self._definitions)
512
+
513
+ def _ensure_named_type(
514
+ self,
515
+ name: str,
516
+ schema: JsonSchema,
517
+ root: JsonSchema,
518
+ ) -> str:
519
+ expression = self._type_expr(schema, root, name)
520
+ if expression == name:
521
+ return name
522
+ key = (name, _schema_fingerprint(schema))
523
+ if key not in self._seen:
524
+ self._seen[key] = name
525
+ self._definitions.append(f"{name}: TypeAlias = {expression}")
526
+ return name
527
+
528
+ def _type_expr(
529
+ self,
530
+ schema: JsonSchema,
531
+ root: JsonSchema,
532
+ name: str,
533
+ ) -> str:
534
+ if isinstance(schema, bool):
535
+ return "JsonValue" if schema else "Never"
536
+ ref = schema.get("$ref")
537
+ if isinstance(ref, str):
538
+ return self._resolved_ref_type(ref, root, name)
539
+ constant = schema.get("const")
540
+ if isinstance(constant, (bool, int, float, str)) or (
541
+ constant is None and "const" in schema
542
+ ):
543
+ return f"Literal[{constant!r}]"
544
+ enum_values = schema.get("enum")
545
+ if (
546
+ isinstance(enum_values, list)
547
+ and enum_values
548
+ and all(
549
+ isinstance(value, (bool, int, float, str)) or value is None for value in enum_values
550
+ )
551
+ ):
552
+ return f"Literal[{', '.join(repr(value) for value in enum_values)}]"
553
+ alternatives = schema.get("anyOf") or schema.get("oneOf")
554
+ if isinstance(alternatives, list):
555
+ rendered = [
556
+ self._type_expr(member, root, f"{name}Option{index}")
557
+ for index, member in enumerate(alternatives, start=1)
558
+ if isinstance(member, (dict, bool))
559
+ ]
560
+ return " | ".join(dict.fromkeys(rendered)) or "JsonValue"
561
+ if "allOf" in schema:
562
+ merged = _merge_all_of(schema, root)
563
+ return "JsonValue" if merged is None else self._type_expr(merged, root, name)
564
+
565
+ raw_type = schema.get("type")
566
+ if isinstance(raw_type, list):
567
+ rendered = [
568
+ self._type_expr({**schema, "type": member}, root, name)
569
+ for member in raw_type
570
+ if isinstance(member, str)
571
+ ]
572
+ return " | ".join(dict.fromkeys(rendered)) or "JsonValue"
573
+ if raw_type is None:
574
+ if "properties" in schema or "additionalProperties" in schema:
575
+ raw_type = "object"
576
+ elif "items" in schema:
577
+ raw_type = "array"
578
+ else:
579
+ return "JsonValue"
580
+
581
+ primitives = {
582
+ "string": "str",
583
+ "integer": "int",
584
+ "number": "float",
585
+ "boolean": "bool",
586
+ "null": "None",
587
+ }
588
+ if isinstance(raw_type, str) and raw_type in primitives:
589
+ return primitives[raw_type]
590
+ if raw_type == "array":
591
+ items = schema.get("items", True)
592
+ if isinstance(items, list):
593
+ members = [
594
+ self._type_expr(
595
+ _as_schema(item),
596
+ root,
597
+ f"{name}Item{index}",
598
+ )
599
+ for index, item in enumerate(items, start=1)
600
+ ]
601
+ return f"tuple[{', '.join(members)}]"
602
+ return f"list[{self._type_expr(_as_schema(items), root, f'{name}Item')}]"
603
+ if raw_type == "object":
604
+ raw_properties = schema.get("properties")
605
+ properties = raw_properties if isinstance(raw_properties, dict) else {}
606
+ if properties:
607
+ if any(not _valid_identifier(prop) for prop in properties):
608
+ return "dict[str, JsonValue]"
609
+ key = (name, _schema_fingerprint(schema))
610
+ if key in self._seen:
611
+ return self._seen[key]
612
+ self._seen[key] = name
613
+ raw_required = schema.get("required")
614
+ required = (
615
+ {item for item in raw_required if isinstance(item, str)}
616
+ if isinstance(raw_required, list)
617
+ else set()
618
+ )
619
+ lines = [f"class {name}(TypedDict):"]
620
+ for prop, prop_schema in properties.items():
621
+ child_schema = _as_schema(prop_schema)
622
+ prop_type = self._type_expr(
623
+ child_schema,
624
+ root,
625
+ f"{name}{_pascal_case(prop)}",
626
+ )
627
+ wrapper = prop_type if prop in required else f"NotRequired[{prop_type}]"
628
+ comment = _field_comment(child_schema)
629
+ suffix = f" # {comment}" if comment else ""
630
+ lines.append(f" {prop}: {wrapper}{suffix}")
631
+ self._definitions.append("\n".join(lines))
632
+ return name
633
+ additional = _as_schema(schema.get("additionalProperties", True))
634
+ return f"dict[str, {self._type_expr(additional, root, f'{name}Value')}]"
635
+ return "JsonValue"
636
+
637
+ def _resolved_ref_type(self, ref: str, root: JsonSchema, name: str) -> str:
638
+ if ref in self._active_refs:
639
+ return "JsonValue"
640
+ self._active_refs.add(ref)
641
+ try:
642
+ return self._type_expr(_resolve_ref(ref, root), root, name)
643
+ finally:
644
+ self._active_refs.remove(ref)
645
+
646
+
647
+ def _short_description(description: str | None, limit: int = 240) -> str | None:
648
+ if description is None:
649
+ return None
650
+ first_paragraph = description.split("\n\n", 1)[0]
651
+ compact = " ".join(first_paragraph.split())
652
+ if len(compact) <= limit:
653
+ return compact
654
+ return f"{compact[: limit - 1].rstrip()}…"
655
+
656
+
657
+ def _field_comment(schema: JsonSchema) -> str | None:
658
+ if isinstance(schema, bool):
659
+ return None
660
+ notes: list[str] = []
661
+ raw_description = schema.get("description")
662
+ description = _short_description(
663
+ raw_description if isinstance(raw_description, str) else None,
664
+ limit=120,
665
+ )
666
+ if description:
667
+ notes.append(description)
668
+ constraints = (
669
+ ("minimum", ">="),
670
+ ("exclusiveMinimum", ">"),
671
+ ("maximum", "<="),
672
+ ("exclusiveMaximum", "<"),
673
+ ("minLength", "min length"),
674
+ ("maxLength", "max length"),
675
+ ("minItems", "min items"),
676
+ ("maxItems", "max items"),
677
+ ("pattern", "pattern"),
678
+ ("format", "format"),
679
+ )
680
+ for key, label in constraints:
681
+ if key in schema:
682
+ notes.append(f"{label} {schema[key]}")
683
+ if "default" in schema:
684
+ notes.append(f"default {schema['default']!r}")
685
+ return "; ".join(notes) or None
686
+
687
+
688
+ def _build_search_blob(
689
+ public_name: str,
690
+ call: str,
691
+ server: str,
692
+ short_name: str,
693
+ description: str | None,
694
+ input_schema: JsonObject,
695
+ ) -> str:
696
+ return " ".join([
697
+ public_name,
698
+ call,
699
+ server,
700
+ short_name,
701
+ description or "",
702
+ *_collect_property_names(input_schema),
703
+ ])
704
+
705
+
706
+ def _collect_property_names(schema: JsonSchema) -> list[str]:
707
+ if isinstance(schema, bool):
708
+ return []
709
+ names: list[str] = []
710
+ raw_properties = schema.get("properties")
711
+ properties = raw_properties if isinstance(raw_properties, dict) else {}
712
+ for prop, child in properties.items():
713
+ names.append(prop)
714
+ if isinstance(child, dict):
715
+ names.extend(_collect_property_names(child))
716
+ items = schema.get("items")
717
+ if isinstance(items, dict):
718
+ names.extend(_collect_property_names(items))
719
+ elif isinstance(items, list):
720
+ for item in items:
721
+ if isinstance(item, dict):
722
+ names.extend(_collect_property_names(item))
723
+ return names
724
+
725
+
726
+ def _extract_server_name(tool_name: str, server_names: Iterable[str]) -> str | None:
727
+ for server in sorted(server_names, key=len, reverse=True):
728
+ if tool_name.startswith(f"{server}_"):
729
+ return server
730
+ return None
731
+
732
+
733
+ def _unique_python_aliases(
734
+ values: Iterable[str],
735
+ *,
736
+ reserved: set[str] | None = None,
737
+ ) -> dict[str, str]:
738
+ originals = list(values)
739
+ aliases: dict[str, str] = {}
740
+ used: set[str] = set(reserved or ())
741
+ for original in sorted(originals):
742
+ base = _python_identifier(original)
743
+ alias = base
744
+ if alias in used:
745
+ suffix = hashlib.sha256(original.encode("utf-8")).hexdigest()[:6]
746
+ alias = f"{base}_{suffix}"
747
+ counter = 2
748
+ while alias in used:
749
+ alias = f"{base}_{counter}"
750
+ counter += 1
751
+ aliases[original] = alias
752
+ used.add(alias)
753
+ return aliases
754
+
755
+
756
+ def _python_identifier(value: str) -> str:
757
+ identifier = re.sub(r"[^a-zA-Z0-9_]", "_", value).strip("_").lower()
758
+ identifier = re.sub(r"_+", "_", identifier) or "mcp"
759
+ if identifier[0].isdigit():
760
+ identifier = f"mcp_{identifier}"
761
+ if keyword.iskeyword(identifier):
762
+ identifier = f"{identifier}_"
763
+ return identifier
764
+
765
+
766
+ def _pascal_case(value: str) -> str:
767
+ parts = [part for part in re.split(r"[^a-zA-Z0-9]+", value) if part]
768
+ rendered = "".join(part[:1].upper() + part[1:] for part in parts) or "Anonymous"
769
+ return f"T{rendered}" if rendered[0].isdigit() else rendered
770
+
771
+
772
+ def _valid_identifier(value: str) -> bool:
773
+ return value.isidentifier() and not keyword.iskeyword(value)
774
+
775
+
776
+ def _schema_fingerprint(schema: JsonSchema) -> str:
777
+ return hashlib.sha256(json.dumps(schema, sort_keys=True, default=str).encode()).hexdigest()
778
+
779
+
780
+ def _resolve_ref(ref: str, root: JsonSchema) -> JsonObject:
781
+ if isinstance(root, bool) or not ref.startswith("#/"):
782
+ return {}
783
+ current: JsonValue = root
784
+ for raw_part in ref[2:].split("/"):
785
+ part = raw_part.replace("~1", "/").replace("~0", "~")
786
+ if not isinstance(current, dict):
787
+ return {}
788
+ current = current.get(part, {})
789
+ return current if isinstance(current, dict) else {}
790
+
791
+
792
+ def _merge_all_of(
793
+ schema: JsonObject,
794
+ root: JsonSchema,
795
+ ) -> JsonObject | None:
796
+ merged_properties: JsonObject = {}
797
+ merged_required: list[JsonValue] = []
798
+ additional_properties: JsonValue = True
799
+ has_additional_properties = False
800
+ raw_members = schema.get("allOf")
801
+ if not isinstance(raw_members, list):
802
+ return None
803
+ for member in raw_members:
804
+ if not isinstance(member, dict):
805
+ return None
806
+ ref = member.get("$ref")
807
+ resolved = _resolve_ref(ref, root) if isinstance(ref, str) else member
808
+ if resolved.get("type") not in {None, "object"}:
809
+ return None
810
+ raw_properties = resolved.get("properties")
811
+ if isinstance(raw_properties, dict):
812
+ merged_properties.update(raw_properties)
813
+ raw_required = resolved.get("required")
814
+ if isinstance(raw_required, list):
815
+ for required in raw_required:
816
+ if isinstance(required, str) and required not in merged_required:
817
+ merged_required.append(required)
818
+ if "additionalProperties" in resolved:
819
+ additional_properties = resolved["additionalProperties"]
820
+ has_additional_properties = True
821
+ merged: JsonObject = {
822
+ "type": "object",
823
+ "properties": merged_properties,
824
+ "required": merged_required,
825
+ }
826
+ if has_additional_properties:
827
+ merged["additionalProperties"] = additional_properties
828
+ return merged
829
+
830
+
831
+ def _dedupe(blocks: Iterable[str]) -> list[str]:
832
+ seen: set[str] = set()
833
+ result: list[str] = []
834
+ for block in blocks:
835
+ if block not in seen:
836
+ seen.add(block)
837
+ result.append(block)
838
+ return result