mcp-as-code 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.
maco/codegen.py ADDED
@@ -0,0 +1,680 @@
1
+ """Generate Python code interfaces for MCP tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from collections import Counter
7
+ from dataclasses import dataclass
8
+ import json
9
+ import keyword
10
+ from pathlib import Path
11
+ import re
12
+ import shutil
13
+ from typing import Any, cast
14
+ from urllib.error import HTTPError, URLError
15
+ from urllib.request import Request, urlopen
16
+
17
+ from jinja2 import Environment, PackageLoader, StrictUndefined
18
+
19
+ from .config import MacoConfig
20
+ from .mcp_manager import MCPManager
21
+
22
+
23
+ _CODEGEN_TEMPLATES = Environment(
24
+ loader=PackageLoader("maco", "templates"),
25
+ trim_blocks=True,
26
+ lstrip_blocks=True,
27
+ keep_trailing_newline=True,
28
+ undefined=StrictUndefined,
29
+ )
30
+
31
+
32
+ def _pyrepr(value: Any) -> str:
33
+ return repr(value)
34
+
35
+
36
+ _CODEGEN_TEMPLATES.filters["pyrepr"] = _pyrepr
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class GenerationStats:
41
+ server_count: int
42
+ tool_count: int
43
+ workspace: Path
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class TypeSource:
48
+ """Generated Python type source for one JSON schema."""
49
+
50
+ source: str
51
+ type_expr: str
52
+ is_model: bool = False
53
+
54
+
55
+ async def generate_async(
56
+ config: MacoConfig,
57
+ workspace: str | Path = ".maco",
58
+ server_filter: str | None = None,
59
+ clean: bool = False,
60
+ ) -> GenerationStats:
61
+ """Generate Python wrappers for all configured MCP tools."""
62
+
63
+ async with MCPManager(config) as manager:
64
+ tools_by_server = await manager.list_tools(server_filter=server_filter)
65
+
66
+ return generate_from_catalog(
67
+ tools_by_server,
68
+ workspace=workspace,
69
+ clean=clean,
70
+ config_path=config.path,
71
+ )
72
+
73
+
74
+ def generate_from_catalog(
75
+ tools_by_server: dict[str, list[dict[str, Any]]],
76
+ *,
77
+ workspace: str | Path = ".maco",
78
+ clean: bool = False,
79
+ config_path: str | Path | None = None,
80
+ package_name: str = "maco_generated.servers",
81
+ client_module: str = "maco_generated.client",
82
+ package_docstring: str = "Generated MCP wrappers for maco.",
83
+ servers_docstring: str = "Generated MCP server packages.",
84
+ ) -> GenerationStats:
85
+ """Generate wrappers from an already-fetched MCP tool catalog."""
86
+
87
+ workspace_path = Path(workspace).expanduser().resolve()
88
+ if clean and workspace_path.exists():
89
+ shutil.rmtree(workspace_path)
90
+
91
+ package_parts = package_name.split(".")
92
+ if not package_parts or any(not part for part in package_parts):
93
+ raise ValueError("package_name must be a dotted Python package name")
94
+
95
+ client_parts = client_module.split(".")
96
+ if not client_parts or any(not part for part in client_parts):
97
+ raise ValueError("client_module must be a dotted Python module name")
98
+
99
+ generated_pkg = workspace_path / package_parts[0]
100
+ servers_pkg = workspace_path.joinpath(*package_parts)
101
+ servers_pkg.mkdir(parents=True, exist_ok=True)
102
+
103
+ _write_workspace_pyproject(workspace_path)
104
+ _write_template(
105
+ generated_pkg / "__init__.py",
106
+ "codegen/package_init.py.j2",
107
+ docstring=package_docstring,
108
+ )
109
+ for depth in range(1, max(len(package_parts) - 1, 1)):
110
+ package_path = workspace_path.joinpath(*package_parts[: depth + 1])
111
+ _write_template(
112
+ package_path / "__init__.py",
113
+ "codegen/package_init.py.j2",
114
+ docstring=servers_docstring,
115
+ )
116
+ if servers_pkg != generated_pkg:
117
+ _write_template(
118
+ servers_pkg / "__init__.py",
119
+ "codegen/package_init.py.j2",
120
+ docstring=servers_docstring,
121
+ )
122
+ (generated_pkg / "py.typed").write_text("", encoding="utf-8")
123
+ client_path = workspace_path.joinpath(*client_parts).with_suffix(".py")
124
+ _write_client(client_path)
125
+
126
+ manifest = {
127
+ "version": 1,
128
+ "config": str(config_path) if config_path is not None else None,
129
+ "package": package_name,
130
+ "servers": [],
131
+ }
132
+
133
+ server_module_names = _unique_sanitized_names(tools_by_server.keys())
134
+ server_count = 0
135
+ tool_count = 0
136
+
137
+ for server_name, tools in sorted(tools_by_server.items()):
138
+ server_module = server_module_names[server_name]
139
+ server_dir = servers_pkg / server_module
140
+ server_dir.mkdir(parents=True, exist_ok=True)
141
+ tool_module_names = _unique_sanitized_names(tool["name"] for tool in tools)
142
+
143
+ exports: list[str] = []
144
+ server_manifest = {
145
+ "name": server_name,
146
+ "module": server_module,
147
+ "tools": [],
148
+ }
149
+ for tool in sorted(tools, key=lambda item: item["name"]):
150
+ tool_name = tool["name"]
151
+ func_name = tool_module_names[tool_name]
152
+ module_path = server_dir / f"{func_name}.py"
153
+ _write_tool(module_path, server_name, tool, func_name, client_module)
154
+ exports.append(func_name)
155
+ server_manifest["tools"].append(
156
+ {
157
+ "name": tool_name,
158
+ "function": func_name,
159
+ "module": f"{package_name}.{server_module}.{func_name}",
160
+ "description": tool.get("description") or "",
161
+ }
162
+ )
163
+ tool_count += 1
164
+
165
+ _write_server_init(server_dir / "__init__.py", exports)
166
+ manifest["servers"].append(server_manifest)
167
+ server_count += 1
168
+
169
+ (workspace_path / "manifest.json").write_text(
170
+ json.dumps(manifest, indent=2, sort_keys=True) + "\n",
171
+ encoding="utf-8",
172
+ )
173
+
174
+ return GenerationStats(
175
+ server_count=server_count,
176
+ tool_count=tool_count,
177
+ workspace=workspace_path,
178
+ )
179
+
180
+
181
+ def generate(
182
+ config: MacoConfig,
183
+ workspace: str | Path = ".maco",
184
+ server_filter: str | None = None,
185
+ clean: bool = False,
186
+ ) -> GenerationStats:
187
+ return asyncio.run(generate_async(config, workspace, server_filter, clean))
188
+
189
+
190
+ def generate_sandbox_sdk(
191
+ tools_by_server: dict[str, list[dict[str, Any]]],
192
+ *,
193
+ workspace: str | Path,
194
+ clean: bool = True,
195
+ ) -> GenerationStats:
196
+ """Generate the sandbox-facing SDK package at ``tools.<server>``."""
197
+
198
+ return generate_from_catalog(
199
+ tools_by_server,
200
+ workspace=workspace,
201
+ clean=clean,
202
+ package_name="tools",
203
+ client_module="tools._client",
204
+ package_docstring="Generated sandbox tools for maco.",
205
+ servers_docstring="Generated sandbox tool modules.",
206
+ )
207
+
208
+
209
+ def generate_sandbox_sdk_from_gateway(
210
+ gateway_url: str,
211
+ *,
212
+ token: str | None = None,
213
+ workspace: str | Path,
214
+ clean: bool = True,
215
+ timeout: float | None = 30.0,
216
+ ) -> GenerationStats:
217
+ """Generate the sandbox SDK from a running gateway's live tool catalog."""
218
+
219
+ return generate_sandbox_sdk(
220
+ fetch_gateway_tools(gateway_url, token=token, timeout=timeout),
221
+ workspace=workspace,
222
+ clean=clean,
223
+ )
224
+
225
+
226
+ def fetch_gateway_tools(
227
+ gateway_url: str,
228
+ *,
229
+ token: str | None = None,
230
+ timeout: float | None = 30.0,
231
+ ) -> dict[str, list[dict[str, Any]]]:
232
+ """Fetch the live tool catalog from a running maco gateway."""
233
+
234
+ url = gateway_url.rstrip("/") + "/tools"
235
+ headers: dict[str, str] = {}
236
+ if token:
237
+ headers["Authorization"] = f"Bearer {token}"
238
+ request = Request(url, headers=headers, method="GET")
239
+ try:
240
+ with urlopen(request, timeout=timeout) as response:
241
+ payload = json.loads(response.read().decode("utf-8"))
242
+ except HTTPError as exc:
243
+ detail = exc.read().decode("utf-8", errors="replace")
244
+ raise RuntimeError(f"failed to fetch maco gateway tools: HTTP {exc.code}: {detail}") from exc
245
+ except URLError as exc:
246
+ raise RuntimeError(f"failed to connect to maco gateway at {url}: {exc}") from exc
247
+ if not isinstance(payload, dict) or not isinstance(payload.get("servers"), dict):
248
+ raise RuntimeError("maco gateway /tools response must contain a servers object")
249
+ result: dict[str, list[dict[str, Any]]] = {}
250
+ for server_name, tools in payload["servers"].items():
251
+ if not isinstance(server_name, str) or not isinstance(tools, list):
252
+ raise RuntimeError("maco gateway /tools response has an invalid server entry")
253
+ server_tools: list[dict[str, Any]] = []
254
+ for tool in tools:
255
+ if not isinstance(tool, dict):
256
+ raise RuntimeError("maco gateway /tools response has an invalid tool entry")
257
+ server_tools.append(tool)
258
+ result[server_name] = server_tools
259
+ return result
260
+
261
+
262
+ def server_module_names(server_names: Any) -> dict[str, str]:
263
+ """Return generated module names for configured MCP server names."""
264
+
265
+ return _unique_sanitized_names(server_names)
266
+
267
+
268
+ def _render_template(template_name: str, **context: Any) -> str:
269
+ return _CODEGEN_TEMPLATES.get_template(template_name).render(**context)
270
+
271
+
272
+ def _render_source(template_name: str, **context: Any) -> str:
273
+ return _render_template(template_name, **context).rstrip()
274
+
275
+
276
+ def _write_template(path: Path, template_name: str, **context: Any) -> None:
277
+ path.parent.mkdir(parents=True, exist_ok=True)
278
+ path.write_text(_render_template(template_name, **context), encoding="utf-8")
279
+
280
+
281
+ def _write_workspace_pyproject(workspace: Path) -> None:
282
+ _write_template(workspace / "pyproject.toml", "codegen/pyproject.toml.j2")
283
+
284
+
285
+ def _write_client(path: Path) -> None:
286
+ _write_template(path, "codegen/client.py.j2")
287
+
288
+
289
+ def _write_tool(
290
+ path: Path,
291
+ server_name: str,
292
+ tool: dict[str, Any],
293
+ func_name: str,
294
+ client_module: str,
295
+ ) -> None:
296
+ tool_name = tool["name"]
297
+ description = tool.get("description") or ""
298
+ input_schema = tool.get("inputSchema") or {"type": "object", "properties": {}}
299
+ output_schema = tool.get("outputSchema")
300
+ input_type = _schema_type_source(f"{_class_name(func_name)}Input", input_schema)
301
+ output_type = _schema_type_source(
302
+ f"{_class_name(func_name)}Output",
303
+ output_schema,
304
+ missing_type_expr="_t.Any",
305
+ )
306
+ return_expr = _return_expr(output_type)
307
+ _write_template(
308
+ path,
309
+ "codegen/tool.py.j2",
310
+ description=description,
311
+ docstring=_docstring(description, input_schema, output_schema),
312
+ func_name=func_name,
313
+ input_is_model=input_type.is_model,
314
+ input_type_expr=input_type.type_expr,
315
+ input_type_source=input_type.source,
316
+ output_type_expr=output_type.type_expr,
317
+ output_type_source=output_type.source,
318
+ return_expr=return_expr,
319
+ client_module=client_module,
320
+ server_name=server_name,
321
+ tool_name=tool_name,
322
+ )
323
+
324
+
325
+ def _write_server_init(path: Path, exports: list[str]) -> None:
326
+ _write_template(path, "codegen/server_init.py.j2", exports=exports)
327
+
328
+
329
+ def _typed_dict_source(class_name: str, schema: dict[str, Any]) -> str:
330
+ """Backward-compatible helper used by tests and older callers."""
331
+
332
+ return _schema_type_source(class_name, schema).source
333
+
334
+
335
+ def _schema_type_source(
336
+ root_name: str,
337
+ schema: Any,
338
+ *,
339
+ missing_type_expr: str = "dict[str, _t.Any]",
340
+ ) -> TypeSource:
341
+ if not isinstance(schema, dict):
342
+ root_type = _class_name(root_name)
343
+ return TypeSource(_render_type_alias(root_type, missing_type_expr), root_type)
344
+ used_names: set[str] = set()
345
+ return _schema_to_type(_class_name(root_name), schema, schema, used_names, define_named=True)
346
+
347
+
348
+ def _schema_to_type(
349
+ type_name: str,
350
+ schema: dict[str, Any],
351
+ root_schema: dict[str, Any],
352
+ used_names: set[str],
353
+ *,
354
+ define_named: bool = False,
355
+ ) -> TypeSource:
356
+ schema = _resolve_schema_ref(schema, root_schema)
357
+
358
+ if "const" in schema:
359
+ return _maybe_alias(type_name, _literal_type([schema["const"]]), used_names, define_named)
360
+ if isinstance(schema.get("enum"), list) and schema["enum"]:
361
+ return _maybe_alias(type_name, _literal_type(schema["enum"]), used_names, define_named)
362
+
363
+ for key in ("oneOf", "anyOf"):
364
+ variants = schema.get(key)
365
+ if isinstance(variants, list) and variants:
366
+ definitions: list[str] = []
367
+ type_exprs: list[str] = []
368
+ for index, variant in enumerate(variants, start=1):
369
+ if not isinstance(variant, dict):
370
+ type_exprs.append("_t.Any")
371
+ continue
372
+ variant_schema = cast("dict[str, Any]", variant)
373
+ variant_type = _schema_to_type(
374
+ f"{type_name}Variant{index}",
375
+ variant_schema,
376
+ root_schema,
377
+ used_names,
378
+ )
379
+ definitions.append(variant_type.source)
380
+ type_exprs.append(variant_type.type_expr)
381
+ return _maybe_alias(
382
+ type_name,
383
+ _union_type(type_exprs),
384
+ used_names,
385
+ define_named,
386
+ definitions,
387
+ )
388
+
389
+ all_of = schema.get("allOf")
390
+ if isinstance(all_of, list) and len(all_of) == 1 and isinstance(all_of[0], dict):
391
+ return _schema_to_type(type_name, all_of[0], root_schema, used_names, define_named=define_named)
392
+
393
+ schema_type = schema.get("type")
394
+ if isinstance(schema_type, list):
395
+ definitions = []
396
+ type_exprs = []
397
+ for item in schema_type:
398
+ item_schema = {**schema, "type": item}
399
+ item_type = _schema_to_type(type_name, item_schema, root_schema, used_names)
400
+ definitions.append(item_type.source)
401
+ type_exprs.append(item_type.type_expr)
402
+ return _maybe_alias(
403
+ type_name,
404
+ _union_type(type_exprs),
405
+ used_names,
406
+ define_named,
407
+ definitions,
408
+ )
409
+
410
+ if schema_type == "object" or "properties" in schema:
411
+ return _object_type_source(type_name, schema, root_schema, used_names, define_named=define_named)
412
+ if schema_type == "array":
413
+ items = schema.get("items")
414
+ if isinstance(items, dict):
415
+ item_type = _schema_to_type(
416
+ f"{type_name}Item",
417
+ items,
418
+ root_schema,
419
+ used_names,
420
+ )
421
+ return _maybe_alias(
422
+ type_name,
423
+ f"list[{item_type.type_expr}]",
424
+ used_names,
425
+ define_named,
426
+ [item_type.source],
427
+ )
428
+ return _maybe_alias(type_name, "list[_t.Any]", used_names, define_named)
429
+ if schema_type == "string":
430
+ return _maybe_alias(type_name, "str", used_names, define_named)
431
+ if schema_type == "integer":
432
+ return _maybe_alias(type_name, "int", used_names, define_named)
433
+ if schema_type == "number":
434
+ return _maybe_alias(type_name, "float", used_names, define_named)
435
+ if schema_type == "boolean":
436
+ return _maybe_alias(type_name, "bool", used_names, define_named)
437
+ if schema_type == "null":
438
+ return _maybe_alias(type_name, "None", used_names, define_named)
439
+ return _maybe_alias(type_name, "_t.Any", used_names, define_named)
440
+
441
+
442
+ def _object_type_source(
443
+ type_name: str,
444
+ schema: dict[str, Any],
445
+ root_schema: dict[str, Any],
446
+ used_names: set[str],
447
+ *,
448
+ define_named: bool,
449
+ ) -> TypeSource:
450
+ properties = schema.get("properties")
451
+ if isinstance(properties, dict) and properties:
452
+ reserved_name = _reserve_type_name(type_name, used_names)
453
+ required = {field for field in schema.get("required", []) if isinstance(field, str)}
454
+ definitions: list[str] = []
455
+ fields: list[dict[str, str]] = []
456
+ used_fields: set[str] = set()
457
+ for raw_prop_name, raw_prop_schema in sorted(properties.items()):
458
+ prop_name = str(raw_prop_name)
459
+ prop_schema = cast("dict[str, Any]", raw_prop_schema if isinstance(raw_prop_schema, dict) else {})
460
+ prop_type = _schema_to_type(
461
+ f"{reserved_name}{_class_name(str(prop_name))}",
462
+ prop_schema,
463
+ root_schema,
464
+ used_names,
465
+ )
466
+ definitions.append(prop_type.source)
467
+ default = _field_default(prop_name, prop_schema, required)
468
+ nullable = _is_nullable(prop_schema)
469
+ type_expr = prop_type.type_expr
470
+ if prop_name not in required or nullable:
471
+ type_expr = _optional_type(type_expr)
472
+ field_name = _safe_field_name(prop_name, used_fields)
473
+ field_args = _field_args(prop_name, prop_schema, default, field_name)
474
+ fields.append(
475
+ {
476
+ "field_args": field_args,
477
+ "name": field_name,
478
+ "type_expr": type_expr,
479
+ }
480
+ )
481
+ definitions.append(_render_source("codegen/model.py.j2", class_name=reserved_name, fields=fields))
482
+ return TypeSource(_join_definitions(definitions), reserved_name, is_model=True)
483
+
484
+ additional = schema.get("additionalProperties")
485
+ if isinstance(additional, dict):
486
+ value_type = _schema_to_type(f"{type_name}Value", additional, root_schema, used_names)
487
+ return _maybe_alias(
488
+ type_name,
489
+ f"dict[str, {value_type.type_expr}]",
490
+ used_names,
491
+ define_named,
492
+ [value_type.source],
493
+ )
494
+
495
+ return _maybe_alias(type_name, "dict[str, _t.Any]", used_names, define_named)
496
+
497
+
498
+ def _resolve_schema_ref(schema: dict[str, Any], root_schema: dict[str, Any]) -> dict[str, Any]:
499
+ ref = schema.get("$ref")
500
+ if not isinstance(ref, str) or not ref.startswith("#/"):
501
+ return schema
502
+ target: Any = root_schema
503
+ for part in ref[2:].split("/"):
504
+ part = part.replace("~1", "/").replace("~0", "~")
505
+ if not isinstance(target, dict) or part not in target:
506
+ return schema
507
+ target = target[part]
508
+ if not isinstance(target, dict):
509
+ return schema
510
+ merged = dict(target)
511
+ merged.update({key: value for key, value in schema.items() if key != "$ref"})
512
+ return merged
513
+
514
+
515
+ def _maybe_alias(
516
+ type_name: str,
517
+ type_expr: str,
518
+ used_names: set[str],
519
+ define_named: bool,
520
+ definitions: list[str] | None = None,
521
+ ) -> TypeSource:
522
+ definitions = definitions or []
523
+ if not define_named:
524
+ return TypeSource(_join_definitions(definitions), type_expr)
525
+ reserved_name = _reserve_type_name(type_name, used_names)
526
+ if _is_root_model_expr(type_expr):
527
+ return TypeSource(
528
+ _join_definitions(
529
+ [
530
+ *definitions,
531
+ _render_source("codegen/root_model.py.j2", class_name=reserved_name, type_expr=type_expr),
532
+ ]
533
+ ),
534
+ reserved_name,
535
+ is_model=True,
536
+ )
537
+ return TypeSource(_join_definitions([*definitions, _render_type_alias(reserved_name, type_expr)]), reserved_name)
538
+
539
+
540
+ def _render_type_alias(type_name: str, type_expr: str) -> str:
541
+ return _render_source("codegen/type_alias.py.j2", type_name=type_name, type_expr=type_expr)
542
+
543
+
544
+ def _is_root_model_expr(type_expr: str) -> bool:
545
+ return type_expr not in {"_t.Any", "None"} and not type_expr.startswith("dict[")
546
+
547
+
548
+ def _field_default(prop_name: str, schema: dict[str, Any], required: set[str]) -> str:
549
+ if "default" in schema:
550
+ return repr(schema["default"])
551
+ return "..." if prop_name in required else "None"
552
+
553
+
554
+ def _field_args(prop_name: str, schema: dict[str, Any], default: str, field_name: str) -> str:
555
+ kwargs = [f"default={default}"]
556
+ if field_name != prop_name:
557
+ kwargs.append(f"alias={prop_name!r}")
558
+ description = schema.get("description")
559
+ if isinstance(description, str) and description:
560
+ kwargs.append(f"description={description!r}")
561
+ title = schema.get("title")
562
+ if isinstance(title, str) and title:
563
+ kwargs.append(f"title={title!r}")
564
+ for schema_key, field_key in (
565
+ ("minimum", "ge"),
566
+ ("maximum", "le"),
567
+ ("exclusiveMinimum", "gt"),
568
+ ("exclusiveMaximum", "lt"),
569
+ ("minLength", "min_length"),
570
+ ("maxLength", "max_length"),
571
+ ("pattern", "pattern"),
572
+ ):
573
+ if schema_key in schema:
574
+ kwargs.append(f"{field_key}={schema[schema_key]!r}")
575
+ return f"Field({', '.join(kwargs)})"
576
+
577
+
578
+ def _safe_field_name(name: str, used_fields: set[str]) -> str:
579
+ candidate = re.sub(r"\W", "_", name)
580
+ if not candidate or candidate[0].isdigit():
581
+ candidate = f"field_{candidate}"
582
+ if keyword.iskeyword(candidate):
583
+ candidate += "_"
584
+ base = candidate
585
+ index = 2
586
+ while candidate in used_fields:
587
+ candidate = f"{base}_{index}"
588
+ index += 1
589
+ used_fields.add(candidate)
590
+ return candidate
591
+
592
+
593
+ def _optional_type(type_expr: str) -> str:
594
+ if "None" in type_expr.split(" | "):
595
+ return type_expr
596
+ return f"{type_expr} | None"
597
+
598
+
599
+ def _is_nullable(schema: dict[str, Any]) -> bool:
600
+ schema_type = schema.get("type")
601
+ return schema_type == "null" or (isinstance(schema_type, list) and "null" in schema_type)
602
+
603
+
604
+ def _return_expr(output_type: TypeSource) -> str:
605
+ if output_type.type_expr == "_t.Any":
606
+ return "result"
607
+ if output_type.is_model:
608
+ return f"{output_type.type_expr}.model_validate(result)"
609
+ return f"_t.cast({output_type.type_expr}, result)"
610
+
611
+
612
+ def _literal_type(values: list[Any]) -> str:
613
+ return "_t.Literal[{}]".format(", ".join(repr(value) for value in values))
614
+
615
+
616
+ def _union_type(type_exprs: list[str]) -> str:
617
+ unique = []
618
+ for expr in type_exprs:
619
+ if expr and expr not in unique:
620
+ unique.append(expr)
621
+ if not unique:
622
+ return "_t.Any"
623
+ if len(unique) == 1:
624
+ return unique[0]
625
+ return " | ".join(unique)
626
+
627
+
628
+ def _join_definitions(definitions: list[str]) -> str:
629
+ return "\n\n".join(definition for definition in definitions if definition)
630
+
631
+
632
+ def _reserve_type_name(type_name: str, used_names: set[str]) -> str:
633
+ base = _class_name(type_name)
634
+ candidate = base
635
+ index = 2
636
+ while candidate in used_names:
637
+ candidate = f"{base}{index}"
638
+ index += 1
639
+ used_names.add(candidate)
640
+ return candidate
641
+
642
+
643
+ def _docstring(description: str, input_schema: dict[str, Any], output_schema: Any) -> str:
644
+ del input_schema, output_schema
645
+ return (description.strip() or "Call the MCP tool.").replace('"""', '\"\"\"')
646
+
647
+
648
+ def _unique_sanitized_names(names: Any) -> dict[str, str]:
649
+ originals = list(names)
650
+ base_names = [_sanitize_identifier(name) for name in originals]
651
+ counts: Counter[str] = Counter()
652
+ result: dict[str, str] = {}
653
+ for original, base in zip(originals, base_names, strict=True):
654
+ counts[base] += 1
655
+ result[original] = base if counts[base] == 1 else f"{base}_{counts[base]}"
656
+ return result
657
+
658
+
659
+ def _sanitize_identifier(name: str) -> str:
660
+ words = [part for part in re.split(r"[^0-9A-Za-z]+", name.strip()) if part]
661
+ if not words:
662
+ result = "tool"
663
+ else:
664
+ result = words[0].lower() + "".join(part[:1].upper() + part[1:] for part in words[1:])
665
+ result = re.sub(r"\W", "_", result)
666
+ if result[0].isdigit():
667
+ result = f"_{result}"
668
+ if keyword.iskeyword(result):
669
+ result += "_"
670
+ return result
671
+
672
+
673
+ def _class_name(func_name: str) -> str:
674
+ parts = [part for part in re.split(r"[^0-9A-Za-z]+", str(func_name)) if part]
675
+ result = "".join(part[:1].upper() + part[1:] for part in parts) or "Tool"
676
+ if result[0].isdigit():
677
+ result = f"_{result}"
678
+ if keyword.iskeyword(result):
679
+ result += "Type"
680
+ return result