Coverage for .venv/lib/python3.13/site-packages/litellm/proxy/_experimental/mcp_server/openapi_to_mcp_generator.py: 17%
296 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 12:01 +0000
1"""
2This module is used to generate MCP tools from OpenAPI specs.
3"""
5import asyncio
6import contextvars
7import json
8import os
9import re
10from collections.abc import Mapping, Sequence
11from pathlib import PurePosixPath
12from typing import Any, Final, TypedDict
13from urllib.parse import quote
15import httpx
16from typing_extensions import ReadOnly, Required
18from litellm.llms.custom_httpx.http_handler import MaskedHTTPStatusError
19from litellm.proxy._experimental.mcp_server.exceptions import (
20 MCPOpenApiUpstreamError,
21 MCPUpstreamAuthError,
22)
24# Tool names emitted from OpenAPI specs must work across all major LLM providers.
25# OpenAI/Anthropic/Bedrock all enforce a character class roughly equivalent to
26# ^[a-zA-Z0-9_-]+$ on tool names. Many specs (notably GitHub's REST API) use
27# tag-namespaced operationIds like "actions/download-job-logs-for-workflow-run"
28# which include '/'. Sanitize here so the same regex passes everywhere downstream.
29_OPENAPI_TOOL_NAME_INVALID_CHARS: Final = re.compile(r"[^a-zA-Z0-9_-]")
30_OPENAPI_TOOL_NAME_MAX_LEN: Final = 128
33def sanitize_openapi_tool_name(raw_name: str) -> str:
34 """Map an OpenAPI operationId / fallback to a provider-safe tool name.
36 Replaces any character outside ``[a-zA-Z0-9_-]`` with ``_`` and caps the
37 result at 128 chars (the most restrictive of the major providers).
38 Lowercased to match the existing convention in
39 ``register_tools_from_openapi``.
40 """
41 if not raw_name:
42 return raw_name
43 sanitized: Final = _OPENAPI_TOOL_NAME_INVALID_CHARS.sub("_", raw_name).lower()
44 return sanitized[:_OPENAPI_TOOL_NAME_MAX_LEN]
47from litellm._logging import verbose_logger
48from litellm.litellm_core_utils.url_utils import async_safe_get
49from litellm.llms.custom_httpx.http_handler import (
50 AsyncHTTPHandler,
51 get_async_httpx_client,
52 header_value,
53 httpxSpecialProvider,
54)
55from litellm.proxy._experimental.mcp_server.tool_registry import (
56 global_mcp_tool_registry,
57)
58from litellm.types.mcp import MCPAuthType, credential_redirect_hook, custom_credential_slot
61class _OpenAPIJSONSchema(TypedDict, total=False):
62 properties: Mapping[str, object]
63 type: ReadOnly[str]
66class _OpenAPIParameter(TypedDict, total=False):
67 name: Required[ReadOnly[str]]
68 description: ReadOnly[str]
69 required: ReadOnly[bool]
70 schema: ReadOnly[_OpenAPIJSONSchema]
73class _OpenAPIMediaType(TypedDict, total=False):
74 schema: _OpenAPIJSONSchema
77class _OpenAPIRequestBody(TypedDict, total=False):
78 description: str
79 required: bool
80 content: Mapping[str, _OpenAPIMediaType]
83class _OpenAPIOperation(TypedDict, total=False):
84 operationId: str
85 summary: str
86 description: str
87 parameters: Sequence[_OpenAPIParameter]
88 requestBody: _OpenAPIRequestBody
91class _OpenAPIPathItem(TypedDict, total=False):
92 summary: str
93 description: str
94 parameters: Sequence[_OpenAPIParameter]
97class _OpenAPIComponents(TypedDict, total=False):
98 parameters: Mapping[str, _OpenAPIParameter]
101# Store the base URL and headers globally
102BASE_URL: Final = ""
103HEADERS: Final[dict[str, str]] = {}
105# Per-request auth header override for BYOK servers.
106# Set this ContextVar before calling a local tool handler to inject the user's
107# stored credential into the HTTP request made by the tool function closure.
108_request_auth_header: contextvars.ContextVar[str | None] = contextvars.ContextVar("_request_auth_header", default=None)
110# Per-request extra headers forwarded from the client request.
111# Populated from MCPServer.extra_headers names matched against raw request
112# headers in server.py before dispatching to a local/OpenAPI tool handler.
113_request_extra_headers: Final[contextvars.ContextVar[dict[str, str] | None]] = contextvars.ContextVar(
114 "_request_extra_headers", default=None
115)
117# Per-request headers carrying the gateway-resolved upstream credential
118# (stored per-user OAuth token, minted M2M token, exchanged OBO token).
119# Set from MCPServerManager.resolve_openapi_upstream_auth; authoritative
120# over every other Authorization source in _merge_openapi_tool_request_headers.
121_request_resolved_auth_headers: Final[contextvars.ContextVar[dict[str, str] | None]] = contextvars.ContextVar(
122 "_request_resolved_auth_headers", default=None
123)
125_request_upstream_url: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar(
126 "_request_upstream_url", default=None
127)
130def _sanitize_path_parameter_value(param_value: object, param_name: str) -> str:
131 """Ensure path params cannot introduce directory traversal."""
132 if param_value is None:
133 return ""
135 value_str: Final = str(param_value)
136 if value_str == "":
137 return ""
139 normalized_value: Final = value_str.replace("\\", "/")
140 if "/" in normalized_value:
141 raise ValueError(f"Path parameter '{param_name}' must not contain path separators")
143 if any(part in {".", ".."} for part in PurePosixPath(normalized_value).parts):
144 raise ValueError(f"Path parameter '{param_name}' cannot include '.' or '..' segments")
146 return quote(value_str, safe="")
149def load_openapi_spec(filepath: str) -> dict[str, Any]:
150 """
151 Sync wrapper. For URL specs, use the shared/custom MCP httpx client.
152 """
153 try:
154 # If we're already inside an event loop, prefer the async function.
155 asyncio.get_running_loop()
156 raise RuntimeError(
157 "load_openapi_spec() was called from within a running event loop. "
158 "Use 'await load_openapi_spec_async(...)' instead."
159 )
160 except RuntimeError as e:
161 # "no running event loop" is fine; other RuntimeErrors we re-raise
162 if "no running event loop" not in str(e).lower():
163 raise
164 return asyncio.run(load_openapi_spec_async(filepath))
167async def load_openapi_spec_async(filepath: str, *, max_bytes: int | None = None) -> dict[str, Any]:
168 if filepath.startswith("http://") or filepath.startswith("https://"): 168 ↛ 169line 168 didn't jump to line 169 because the condition on line 168 was never true
169 client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
170 r: Final[httpx.Response] = (
171 await async_safe_get(client, filepath)
172 if max_bytes is None
173 else await async_safe_get(client, filepath, max_response_bytes=max_bytes)
174 )
175 r.raise_for_status()
176 return r.json()
178 # fallback: local file
179 # Local filesystem path
180 if not os.path.exists(filepath): 180 ↛ 182line 180 didn't jump to line 182 because the condition on line 180 was always true
181 raise FileNotFoundError(f"OpenAPI spec not found at {filepath}")
182 with open(filepath, "r", encoding="utf-8") as f:
183 return json.load(f)
186def get_base_url(spec: Mapping[str, Any], spec_path: str | None = None) -> str:
187 """Extract base URL from OpenAPI spec."""
188 # OpenAPI 3.x
189 if "servers" in spec and spec["servers"]:
190 server_url: Final[str] = spec["servers"][0]["url"]
192 # If the server URL is relative (starts with /), derive base from spec_path
193 if server_url.startswith("/") and spec_path:
194 if spec_path.startswith("http://") or spec_path.startswith("https://"):
195 # Extract base URL from spec_path (e.g., https://petstore3.swagger.io/api/v3/openapi.json)
196 # Combine domain with the relative server URL
197 from urllib.parse import urlparse
199 parsed: Final = urlparse(spec_path)
200 base_domain: Final = f"{parsed.scheme}://{parsed.netloc}"
201 full_base_url: Final = base_domain + server_url
202 verbose_logger.info(
203 "OpenAPI spec has relative server URL '%s'. Deriving base from spec_path: %s",
204 server_url,
205 full_base_url,
206 )
207 return full_base_url
209 return server_url
210 # OpenAPI 2.x (Swagger)
211 elif "host" in spec:
212 scheme: Final[str] = spec.get("schemes", ["https"])[0]
213 base_path: Final[str] = spec.get("basePath", "")
214 return f"{scheme}://{spec['host']}{base_path}"
216 # Fallback: derive base URL from spec_path if it's a URL
217 if spec_path and (spec_path.startswith("http://") or spec_path.startswith("https://")):
218 for suffix in [
219 "/openapi.json",
220 "/openapi.yaml",
221 "/swagger.json",
222 "/swagger.yaml",
223 ]:
224 if spec_path.endswith(suffix):
225 base_url = spec_path[: -len(suffix)]
226 verbose_logger.info("No server info in OpenAPI spec. Using derived base URL: %s", base_url)
227 return base_url
229 if spec_path.split("/")[-1].endswith((".json", ".yaml", ".yml")):
230 base_url = "/".join(spec_path.split("/")[:-1])
231 verbose_logger.info("No server info in OpenAPI spec. Using derived base URL: %s", base_url)
232 return base_url
234 return ""
237def _resolve_ref(
238 param: _OpenAPIParameter, component_params: Mapping[str, _OpenAPIParameter]
239) -> _OpenAPIParameter | None:
240 """Resolve a single parameter, following a $ref if present.
242 Returns the resolved param dict, or None if the $ref target is absent from
243 components (so callers can skip/filter it rather than propagating a stub
244 with name=None that would corrupt deduplication).
245 """
246 ref: Final[str] = param.get("$ref", "")
247 if not ref.startswith("#/components/parameters/"):
248 return param
249 return component_params.get(ref.split("/")[-1])
252def _resolve_param_list(
253 raw: Sequence[_OpenAPIParameter], component_params: Mapping[str, _OpenAPIParameter]
254) -> list[_OpenAPIParameter]:
255 """Resolve $refs in a parameter list, dropping any unresolvable entries."""
256 result: Final = []
257 for p in raw:
258 resolved = _resolve_ref(p, component_params)
259 if resolved is not None and resolved.get("name"):
260 result.append(resolved)
261 return result
264def resolve_operation_params(
265 operation: _OpenAPIOperation,
266 path_item: _OpenAPIPathItem,
267 components: _OpenAPIComponents,
268) -> _OpenAPIOperation:
269 """Return a copy of *operation* with fully-resolved, merged parameters.
271 Handles two common patterns in real-world OpenAPI specs:
273 1. **$ref parameters** — ``{"$ref": "#/components/parameters/per-page"}``
274 instead of inline objects. Each ref is resolved against
275 ``components["parameters"]``; unresolvable refs are silently dropped so
276 they cannot corrupt the deduplication set with ``(None, None)`` keys.
278 2. **Path-level parameters** — params defined on the path item that apply
279 to every HTTP method on that path (e.g. ``owner``, ``repo``). They are
280 merged with the operation-level params; operation-level wins when the
281 same ``name`` + ``in`` combination appears in both.
282 """
283 component_params: Final[Mapping[str, _OpenAPIParameter]] = components.get("parameters", {})
284 path_level: Final = _resolve_param_list(path_item.get("parameters", []), component_params)
285 op_level: Final = _resolve_param_list(operation.get("parameters", []), component_params)
286 op_keys: Final = {(p["name"], p.get("in")) for p in op_level}
287 merged: Final = [p for p in path_level if (p["name"], p.get("in")) not in op_keys] + op_level
288 result: Final[_OpenAPIOperation] = {**operation, "parameters": merged}
289 return result
292def extract_parameters(operation: _OpenAPIOperation) -> tuple[Sequence[str], Sequence[str], Sequence[str]]:
293 """Extract parameter names from OpenAPI operation."""
294 path_params: Final = []
295 query_params: Final = []
296 body_params: Final = []
298 # OpenAPI 3.x and 2.x parameters
299 if "parameters" in operation:
300 for param in operation["parameters"]:
301 if "name" not in param:
302 continue
303 param_name = param["name"]
304 if param.get("in") == "path":
305 path_params.append(param_name)
306 elif param.get("in") == "query":
307 query_params.append(param_name)
308 elif param.get("in") == "body":
309 body_params.append(param_name)
311 # OpenAPI 3.x requestBody
312 if "requestBody" in operation:
313 body_params.append("body")
315 return path_params, query_params, body_params
318def build_input_schema(operation: _OpenAPIOperation) -> dict[str, object]:
319 """Build MCP input schema from OpenAPI operation."""
320 properties: Final = {}
321 required: Final = []
323 # Process parameters
324 if "parameters" in operation:
325 for param in operation["parameters"]:
326 if "name" not in param:
327 continue
328 param_name = param["name"]
329 param_schema = param.get("schema", {})
330 param_type = param_schema.get("type", "string")
332 properties[param_name] = {
333 "type": param_type,
334 "description": param.get("description", ""),
335 }
337 if param.get("required", False):
338 required.append(param_name)
340 # Process requestBody (OpenAPI 3.x)
341 if "requestBody" in operation:
342 request_body: Final[_OpenAPIRequestBody] = operation["requestBody"]
343 content: Final[Mapping[str, _OpenAPIMediaType]] = request_body.get("content", {})
345 # Try to get JSON schema
346 if "application/json" in content:
347 schema: Final[_OpenAPIJSONSchema] = content["application/json"].get("schema", {})
348 properties["body"] = {
349 "type": "object",
350 "description": request_body.get("description", "Request body"),
351 "properties": schema.get("properties", {}),
352 }
353 if request_body.get("required", False):
354 required.append("body")
356 return {
357 "type": "object",
358 "properties": properties,
359 "required": required if required else [],
360 }
363async def _drop_credential_across_origin(request: httpx.Request) -> None:
364 """Apply this request's cross-origin credential guard, if it needs one.
366 Reads the per-request context rather than closing over it so the hook is one stable object, which
367 keeps the guarded client cacheable. A closure would key a new entry per call, and the handler it
368 built would never be closed.
369 """
370 guard: Final = credential_redirect_hook(
371 _request_upstream_url.get() or "", custom_credential_slot(_request_resolved_auth_headers.get())
372 )
373 if guard is not None:
374 await guard(request)
377def _upstream_client() -> AsyncHTTPHandler:
378 """The HTTP client for one upstream call, guarded when a credential rides a custom slot.
380 A resolved credential outside ``Authorization`` is not stripped across origins by the client
381 itself, so this arm installs the same hook the MCP client uses. Both variants come from the
382 shared cache, so a guarded call reuses its connection pool like any other.
383 """
384 if custom_credential_slot(_request_resolved_auth_headers.get()) is None:
385 return get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
386 return get_async_httpx_client(
387 llm_provider=httpxSpecialProvider.MCP,
388 params={"event_hooks": {"request": [_drop_credential_across_origin]}},
389 )
392def _merge_openapi_tool_request_headers(
393 static_headers: dict[str, str],
394) -> dict[str, str]:
395 """Merge static closure headers with per-request ContextVar overrides.
397 Precedence (highest to lowest):
398 1. ``_request_resolved_auth_headers`` — the gateway-resolved upstream
399 credential (stored per-user OAuth token, minted M2M token,
400 exchanged OBO token). The resolver is authoritative: a BYOK or
401 forwarded ``Authorization`` must not shadow it, mirroring
402 ``_resolve_v2_auth`` on the MCPClient path
403 2. ``_request_auth_header`` — BYOK override of ``Authorization``
404 3. ``static_headers`` — operator-configured headers baked into the
405 tool closure at registration time
406 4. ``_request_extra_headers`` — per-request headers forwarded from
407 the MCP caller (allowlisted by ``MCPServer.extra_headers``)
409 This matches the existing MCP invariant in
410 :func:`litellm.proxy._experimental.mcp_server.utils.merge_mcp_headers`
411 and the managed MCP path, where ``static_headers`` always wins over
412 caller-forwarded headers. Keeping the same precedence here prevents an
413 authenticated caller from overriding an operator-configured value
414 (e.g. a tenant id or upstream API key) by sending the same header name.
416 Header names are compared case-insensitively so different casing cannot
417 bypass the precedence rules.
418 """
419 request_extra: Final = _request_extra_headers.get() or {}
420 static: Final = static_headers or {}
422 static_lower_names: Final = {k.lower() for k in static}
423 effective_headers: dict[str, str] = {k: v for k, v in request_extra.items() if k.lower() not in static_lower_names}
424 effective_headers.update(static)
426 override_auth: Final = _request_auth_header.get()
427 if override_auth:
428 for existing in [k for k in effective_headers if k.lower() == "authorization"]:
429 del effective_headers[existing]
430 effective_headers["Authorization"] = override_auth
432 resolved_auth_headers: Final = _request_resolved_auth_headers.get() or {}
433 for name, value in resolved_auth_headers.items():
434 for existing in [k for k in effective_headers if k.lower() == name.lower()]:
435 del effective_headers[existing]
436 effective_headers[name] = value
438 return effective_headers
441def _raise_for_upstream_failure(
442 response: httpx.Response,
443 upstream: str,
444 relays_upstream_auth: bool,
445) -> None:
446 """Turn a non-2xx upstream response into the right typed failure, or return for a 2xx.
448 Both call sites feed this: ``get`` hands back the response for a 4xx, while post/put/patch/delete
449 raise ``MaskedHTTPStatusError`` from inside the HTTP handler, so without one classifier the
450 non-GET tools would keep serving an error body as tool output.
452 Only the client-forwarded modes carry the caller's own upstream token, so only they can act on a
453 401 by re-authenticating; ``_call_regular_mcp_tool`` gates its re-auth signal the same way. Every
454 other status carries the code alone, never the upstream's body, which crosses a trust boundary.
455 """
456 if response.status_code < 400:
457 return
458 if response.status_code == 401 and relays_upstream_auth:
459 raise MCPUpstreamAuthError(
460 status_code=response.status_code,
461 www_authenticate=header_value(response.headers, "www-authenticate"),
462 server_name=upstream,
463 )
464 raise MCPOpenApiUpstreamError(response.status_code, upstream)
467def create_tool_function(
468 path: str,
469 method: str,
470 operation: _OpenAPIOperation,
471 base_url: str,
472 headers: dict[str, str] | None = None,
473 server_label: str | None = None,
474 relays_upstream_auth: bool = False,
475 auth_type: MCPAuthType = None,
476 upstream_token_header: str | None = None,
477):
478 """Create a tool function for an OpenAPI operation.
480 This function creates an async tool function that can be called with
481 keyword arguments. Parameter names from the OpenAPI spec are accessed
482 directly via **kwargs, avoiding syntax errors from invalid Python identifiers.
484 Args:
485 path: API endpoint path
486 method: HTTP method (get, post, put, delete, patch)
487 operation: OpenAPI operation object
488 base_url: Base URL for the API
489 headers: Optional headers to include in requests (e.g., authentication)
491 Returns:
492 An async function that accepts **kwargs and makes the HTTP request
493 """
494 if headers is None:
495 headers = {}
497 path_params, query_params, body_params = extract_parameters(operation)
498 original_method: Final = method.lower()
500 async def tool_function(**kwargs: object) -> str:
501 """
502 Dynamically generated tool function.
504 Accepts keyword arguments where keys are the original OpenAPI parameter names.
505 The function safely handles parameter names that aren't valid Python identifiers
506 by using **kwargs instead of named parameters.
507 """
508 effective_headers: Final = _merge_openapi_tool_request_headers(headers)
509 if auth_type is not None:
510 from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
511 raise_public,
512 validate_static_credential,
513 )
514 from litellm.proxy._experimental.mcp_server.outbound_credentials.result import Error, Ok
516 match validate_static_credential(auth_type, effective_headers, upstream_token_header, headers or ()):
517 case Error(error):
518 raise_public(error)
519 case Ok():
520 pass
522 # Build URL from base_url and path
523 url = base_url + path
525 # Replace path parameters using original names from OpenAPI spec
526 # Apply path traversal validation and URL encoding
527 for param_name in path_params:
528 param_value = kwargs.get(param_name, "")
529 if param_value:
530 try:
531 # Sanitize and encode path parameter to prevent traversal attacks
532 safe_value = _sanitize_path_parameter_value(param_value, param_name)
533 except ValueError as exc:
534 return "Invalid path parameter: " + str(exc)
535 # Replace {param_name} or {{param_name}} in URL
536 url = url.replace("{" + param_name + "}", safe_value)
537 url = url.replace("{{" + param_name + "}}", safe_value)
539 # Build query params using original parameter names
540 params: Final[dict[str, object]] = {}
541 for param_name in query_params:
542 param_value = kwargs.get(param_name, "")
543 if param_value:
544 # Use original parameter name in query string (as expected by API)
545 params[param_name] = param_value
547 # Build request body
548 json_body: dict[str, object] | None = None
549 if body_params:
550 # Try "body" first (most common), then check all body param names
551 body_value = kwargs.get("body", {})
552 if not body_value:
553 for param_name in body_params:
554 body_value = kwargs.get(param_name, {})
555 if body_value:
556 break
558 if isinstance(body_value, dict):
559 json_body = body_value
560 elif body_value:
561 # If it's a string, try to parse as JSON
562 try:
563 json_body = json.loads(body_value) if isinstance(body_value, str) else {"data": body_value}
564 except (json.JSONDecodeError, TypeError):
565 json_body = {"data": body_value}
567 client: Final = _upstream_client()
568 upstream: Final = server_label or f"{original_method.upper()} {path}"
569 url_token: Final = _request_upstream_url.set(url)
571 try:
572 if original_method == "get":
573 response = await client.get(url, params=params, headers=effective_headers)
574 elif original_method == "post":
575 response = await client.post(url, params=params, json=json_body, headers=effective_headers)
576 elif original_method == "put":
577 response = await client.put(url, params=params, json=json_body, headers=effective_headers)
578 elif original_method == "delete":
579 response = await client.delete(url, params=params, headers=effective_headers)
580 elif original_method == "patch":
581 response = await client.patch(url, params=params, json=json_body, headers=effective_headers)
582 else:
583 return f"Unsupported HTTP method: {original_method}"
584 except MaskedHTTPStatusError as e:
585 _raise_for_upstream_failure(e.response, upstream, relays_upstream_auth)
586 raise
587 finally:
588 _request_upstream_url.reset(url_token)
590 _raise_for_upstream_failure(response, upstream, relays_upstream_auth)
591 return response.text
593 return tool_function
596def register_tools_from_openapi(spec: Mapping[str, Any], base_url: str) -> None:
597 """Register MCP tools from OpenAPI specification."""
598 paths: Final[Mapping[str, Mapping[str, _OpenAPIOperation]]] = spec.get("paths", {})
599 used_names: Final = set()
601 for path, path_item in paths.items():
602 for method in ["get", "post", "put", "delete", "patch"]:
603 if method in path_item:
604 operation = path_item[method]
606 # Generate tool name. Sanitize to ^[a-zA-Z0-9_-]+$ (lowercase)
607 # so the resulting name is valid across OpenAI/Anthropic/Bedrock.
608 # Many specs (e.g. GitHub REST) use tag-namespaced operationIds
609 # like "actions/download-job-logs-for-workflow-run" which
610 # contain '/' and would 400 at the LLM provider boundary.
611 operation_id = operation.get("operationId", f"{method}_{path}")
612 tool_name = sanitize_openapi_tool_name(operation_id)
614 # Disambiguate collisions: two operationIds that differ only
615 # by sanitized characters (e.g. "foo/list" and "foo.list")
616 # would both become "foo_list". Append _2, _3, … to keep
617 # every tool reachable, mirroring the Anthropic-side logic
618 # in _build_anthropic_tool_name_maps.
619 unique = tool_name
620 n = 1
621 while unique in used_names:
622 n += 1
623 suffix = f"_{n}"
624 unique = tool_name[: _OPENAPI_TOOL_NAME_MAX_LEN - len(suffix)] + suffix
625 tool_name = unique
626 used_names.add(tool_name)
628 # Get description
629 description = operation.get("summary", operation.get("description", f"{method.upper()} {path}"))
631 # Build input schema
632 input_schema = build_input_schema(operation)
634 # Create tool function
635 tool_func = create_tool_function(path, method, operation, base_url)
636 tool_func.__name__ = tool_name
637 tool_func.__doc__ = description
639 # Register tool with local registry
640 global_mcp_tool_registry.register_tool(
641 name=tool_name,
642 description=description,
643 input_schema=input_schema,
644 handler=tool_func,
645 )
646 verbose_logger.debug("Registered tool: %s", tool_name)