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

1""" 

2This module is used to generate MCP tools from OpenAPI specs. 

3""" 

4 

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 

14 

15import httpx 

16from typing_extensions import ReadOnly, Required 

17 

18from litellm.llms.custom_httpx.http_handler import MaskedHTTPStatusError 

19from litellm.proxy._experimental.mcp_server.exceptions import ( 

20 MCPOpenApiUpstreamError, 

21 MCPUpstreamAuthError, 

22) 

23 

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 

31 

32 

33def sanitize_openapi_tool_name(raw_name: str) -> str: 

34 """Map an OpenAPI operationId / fallback to a provider-safe tool name. 

35 

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] 

45 

46 

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 

59 

60 

61class _OpenAPIJSONSchema(TypedDict, total=False): 

62 properties: Mapping[str, object] 

63 type: ReadOnly[str] 

64 

65 

66class _OpenAPIParameter(TypedDict, total=False): 

67 name: Required[ReadOnly[str]] 

68 description: ReadOnly[str] 

69 required: ReadOnly[bool] 

70 schema: ReadOnly[_OpenAPIJSONSchema] 

71 

72 

73class _OpenAPIMediaType(TypedDict, total=False): 

74 schema: _OpenAPIJSONSchema 

75 

76 

77class _OpenAPIRequestBody(TypedDict, total=False): 

78 description: str 

79 required: bool 

80 content: Mapping[str, _OpenAPIMediaType] 

81 

82 

83class _OpenAPIOperation(TypedDict, total=False): 

84 operationId: str 

85 summary: str 

86 description: str 

87 parameters: Sequence[_OpenAPIParameter] 

88 requestBody: _OpenAPIRequestBody 

89 

90 

91class _OpenAPIPathItem(TypedDict, total=False): 

92 summary: str 

93 description: str 

94 parameters: Sequence[_OpenAPIParameter] 

95 

96 

97class _OpenAPIComponents(TypedDict, total=False): 

98 parameters: Mapping[str, _OpenAPIParameter] 

99 

100 

101# Store the base URL and headers globally 

102BASE_URL: Final = "" 

103HEADERS: Final[dict[str, str]] = {} 

104 

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) 

109 

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) 

116 

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) 

124 

125_request_upstream_url: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar( 

126 "_request_upstream_url", default=None 

127) 

128 

129 

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

134 

135 value_str: Final = str(param_value) 

136 if value_str == "": 

137 return "" 

138 

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") 

142 

143 if any(part in {".", ".."} for part in PurePosixPath(normalized_value).parts): 

144 raise ValueError(f"Path parameter '{param_name}' cannot include '.' or '..' segments") 

145 

146 return quote(value_str, safe="") 

147 

148 

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)) 

165 

166 

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() 

177 

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) 

184 

185 

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"] 

191 

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 

198 

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 

208 

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}" 

215 

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 

228 

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 

233 

234 return "" 

235 

236 

237def _resolve_ref( 

238 param: _OpenAPIParameter, component_params: Mapping[str, _OpenAPIParameter] 

239) -> _OpenAPIParameter | None: 

240 """Resolve a single parameter, following a $ref if present. 

241 

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]) 

250 

251 

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 

262 

263 

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. 

270 

271 Handles two common patterns in real-world OpenAPI specs: 

272 

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. 

277 

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 

290 

291 

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 = [] 

297 

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) 

310 

311 # OpenAPI 3.x requestBody 

312 if "requestBody" in operation: 

313 body_params.append("body") 

314 

315 return path_params, query_params, body_params 

316 

317 

318def build_input_schema(operation: _OpenAPIOperation) -> dict[str, object]: 

319 """Build MCP input schema from OpenAPI operation.""" 

320 properties: Final = {} 

321 required: Final = [] 

322 

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") 

331 

332 properties[param_name] = { 

333 "type": param_type, 

334 "description": param.get("description", ""), 

335 } 

336 

337 if param.get("required", False): 

338 required.append(param_name) 

339 

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", {}) 

344 

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") 

355 

356 return { 

357 "type": "object", 

358 "properties": properties, 

359 "required": required if required else [], 

360 } 

361 

362 

363async def _drop_credential_across_origin(request: httpx.Request) -> None: 

364 """Apply this request's cross-origin credential guard, if it needs one. 

365 

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) 

375 

376 

377def _upstream_client() -> AsyncHTTPHandler: 

378 """The HTTP client for one upstream call, guarded when a credential rides a custom slot. 

379 

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 ) 

390 

391 

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. 

396 

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``) 

408 

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. 

415 

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 {} 

421 

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) 

425 

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 

431 

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 

437 

438 return effective_headers 

439 

440 

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. 

447 

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. 

451 

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) 

465 

466 

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. 

479 

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. 

483 

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) 

490 

491 Returns: 

492 An async function that accepts **kwargs and makes the HTTP request 

493 """ 

494 if headers is None: 

495 headers = {} 

496 

497 path_params, query_params, body_params = extract_parameters(operation) 

498 original_method: Final = method.lower() 

499 

500 async def tool_function(**kwargs: object) -> str: 

501 """ 

502 Dynamically generated tool function. 

503 

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 

515 

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 

521 

522 # Build URL from base_url and path 

523 url = base_url + path 

524 

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) 

538 

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 

546 

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 

557 

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} 

566 

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) 

570 

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) 

589 

590 _raise_for_upstream_failure(response, upstream, relays_upstream_auth) 

591 return response.text 

592 

593 return tool_function 

594 

595 

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() 

600 

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] 

605 

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) 

613 

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) 

627 

628 # Get description 

629 description = operation.get("summary", operation.get("description", f"{method.upper()} {path}")) 

630 

631 # Build input schema 

632 input_schema = build_input_schema(operation) 

633 

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 

638 

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)