|
20 | 20 | from difflib import SequenceMatcher |
21 | 21 | from datetime import datetime, timedelta, timezone |
22 | 22 | from enum import Enum |
23 | | -from typing import List, Dict, Optional, Union, Any, Iterable, get_args |
| 23 | +from typing import List, Dict, NamedTuple, Optional, Union, Any, Iterable, get_args |
24 | 24 | from pathlib import Path |
25 | 25 | from urllib.parse import unquote, urlparse |
26 | 26 |
|
@@ -241,74 +241,194 @@ async def annotated_handler(req): |
241 | 241 |
|
242 | 242 | _EXPOSED_TOOLS_MODES = {"all", "read-only"} |
243 | 243 | _EXPOSED_TOOLS_ALLOW_SEPARATOR = "+" |
| 244 | +_EXPOSED_TOOLS_EXCLUDE_SEPARATOR = "-" |
| 245 | +_EXPOSED_TOOLS_HYPHEN_HINT = ( |
| 246 | + f" Tool names use underscores; '{_EXPOSED_TOOLS_EXCLUDE_SEPARATOR}' starts the " |
| 247 | + "exclusion list." |
| 248 | +) |
244 | 249 |
|
245 | 250 |
|
246 | | -def _split_exposed_tools_mode(mode: str) -> tuple[str, list[str]]: |
247 | | - """Split a normalised exposure mode into its base mode and write allowlist.""" |
248 | | - base, separator, raw_allowlist = mode.partition(_EXPOSED_TOOLS_ALLOW_SEPARATOR) |
249 | | - if not separator: |
250 | | - return base, [] |
251 | | - return base, [name.strip() for name in raw_allowlist.split(",") if name.strip()] |
| 251 | +class _ExposedToolsMode(NamedTuple): |
| 252 | + """A validated ``TELEGRAM_EXPOSED_TOOLS`` value.""" |
252 | 253 |
|
| 254 | + base: str |
| 255 | + allowlist: list[str] |
| 256 | + exclusions: list[str] |
253 | 257 |
|
254 | | -def _get_exposed_tools_mode(value: Optional[str] = None) -> str: |
255 | | - """Return the configured MCP tool exposure mode. |
| 258 | + def __str__(self) -> str: |
| 259 | + normalised = self.base |
| 260 | + if self.allowlist: |
| 261 | + normalised += _EXPOSED_TOOLS_ALLOW_SEPARATOR + ",".join(self.allowlist) |
| 262 | + if self.exclusions: |
| 263 | + normalised += _EXPOSED_TOOLS_EXCLUDE_SEPARATOR + ",".join(self.exclusions) |
| 264 | + return normalised |
| 265 | + |
| 266 | + |
| 267 | +class _ExposedToolsResult(NamedTuple): |
| 268 | + """Tools removed by the base mode (``hidden``) and by the ``-`` list (``excluded``).""" |
| 269 | + |
| 270 | + hidden: list[str] |
| 271 | + excluded: list[str] |
| 272 | + |
| 273 | + |
| 274 | +def _split_tool_list(raw: Optional[str]) -> Optional[list[str]]: |
| 275 | + """Split a comma-separated tool list; ``None`` means the separator was absent.""" |
| 276 | + if raw is None: |
| 277 | + return None |
| 278 | + return [name.strip() for name in raw.split(",") if name.strip()] |
256 | 279 |
|
257 | | - ``TELEGRAM_EXPOSED_TOOLS=read-only`` keeps only tools annotated with |
258 | | - ``readOnlyHint=True``. ``read-only+send_message,reply_to_message`` keeps |
259 | | - those plus the named write tools. The default is ``all`` for backward |
260 | | - compatibility. |
| 280 | + |
| 281 | +def _split_exposed_tools_value( |
| 282 | + mode: str, |
| 283 | +) -> tuple[Optional[str], Optional[list[str]], Optional[list[str]]]: |
| 284 | + """Split a lowercased value into base mode, ``+`` allowlist and ``-`` exclusions. |
| 285 | +
|
| 286 | + The base is matched as a known-mode prefix rather than by splitting on |
| 287 | + ``-``, because ``read-only`` itself contains one. Tool names use |
| 288 | + underscores, so a ``-`` after the base always starts the exclusion list. |
| 289 | + A ``None`` base means no known mode matched; a ``None`` list means its |
| 290 | + separator was absent. |
| 291 | + """ |
| 292 | + for base in sorted(_EXPOSED_TOOLS_MODES, key=len, reverse=True): |
| 293 | + rest = mode[len(base) :] |
| 294 | + if mode.startswith(base) and rest[:1] in ( |
| 295 | + "", |
| 296 | + _EXPOSED_TOOLS_ALLOW_SEPARATOR, |
| 297 | + _EXPOSED_TOOLS_EXCLUDE_SEPARATOR, |
| 298 | + ): |
| 299 | + break |
| 300 | + else: |
| 301 | + return None, None, None |
| 302 | + |
| 303 | + raw_allowlist: Optional[str] = None |
| 304 | + raw_exclusions: Optional[str] = None |
| 305 | + if rest.startswith(_EXPOSED_TOOLS_ALLOW_SEPARATOR): |
| 306 | + raw_allowlist, separator, tail = rest[1:].partition(_EXPOSED_TOOLS_EXCLUDE_SEPARATOR) |
| 307 | + if separator: |
| 308 | + raw_exclusions = tail |
| 309 | + elif rest.startswith(_EXPOSED_TOOLS_EXCLUDE_SEPARATOR): |
| 310 | + raw_exclusions = rest[1:] |
| 311 | + return base, _split_tool_list(raw_allowlist), _split_tool_list(raw_exclusions) |
| 312 | + |
| 313 | + |
| 314 | +def _parse_exposed_tools_mode(value: Optional[str] = None) -> _ExposedToolsMode: |
| 315 | + """Parse and validate ``TELEGRAM_EXPOSED_TOOLS`` (or ``value``). |
| 316 | +
|
| 317 | + ``read-only`` keeps only tools annotated with ``readOnlyHint=True``. |
| 318 | + ``read-only+send_message,reply_to_message`` keeps those plus the named |
| 319 | + write tools. A trailing ``-tool,tool`` list hides named tools from either |
| 320 | + base, e.g. ``all-delete_chat_history``. The default is ``all`` for |
| 321 | + backward compatibility. Tool names are checked against the registry |
| 322 | + later, in ``_apply_exposed_tools_mode``. |
261 | 323 | """ |
262 | 324 | raw_value = os.getenv("TELEGRAM_EXPOSED_TOOLS", "all") if value is None else value |
263 | | - mode = raw_value.strip().lower() |
264 | | - base_mode, allowlist = _split_exposed_tools_mode(mode) |
265 | | - if base_mode not in _EXPOSED_TOOLS_MODES: |
| 325 | + base_mode, allowlist, exclusions = _split_exposed_tools_value(raw_value.strip().lower()) |
| 326 | + invalid = f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'." |
| 327 | + if base_mode is None: |
266 | 328 | accepted = ", ".join(sorted(_EXPOSED_TOOLS_MODES)) |
| 329 | + raise SystemExit(f"{invalid} Expected one of: {accepted}.") |
| 330 | + if allowlist is not None and base_mode != "read-only": |
267 | 331 | raise SystemExit( |
268 | | - f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. Expected one of: {accepted}." |
269 | | - ) |
270 | | - if _EXPOSED_TOOLS_ALLOW_SEPARATOR not in mode: |
271 | | - return base_mode |
272 | | - if base_mode != "read-only": |
273 | | - raise SystemExit( |
274 | | - f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. The " |
| 332 | + f"{invalid} The " |
275 | 333 | f"'{_EXPOSED_TOOLS_ALLOW_SEPARATOR}tool,tool' allowlist is only valid " |
276 | 334 | "with read-only." |
277 | 335 | ) |
278 | | - if not allowlist: |
| 336 | + if allowlist is not None and not allowlist: |
279 | 337 | raise SystemExit( |
280 | | - f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. The " |
| 338 | + f"{invalid} The " |
281 | 339 | f"'{_EXPOSED_TOOLS_ALLOW_SEPARATOR}' allowlist must name at least one tool." |
282 | 340 | ) |
283 | | - return f"{base_mode}{_EXPOSED_TOOLS_ALLOW_SEPARATOR}{','.join(allowlist)}" |
| 341 | + if exclusions is not None: |
| 342 | + _validate_exposed_tools_exclusions(invalid, allowlist or [], exclusions) |
| 343 | + return _ExposedToolsMode(base_mode, allowlist or [], exclusions or []) |
284 | 344 |
|
285 | 345 |
|
286 | | -def _apply_exposed_tools_mode(server: FastMCP = mcp, mode: Optional[str] = None) -> list[str]: |
287 | | - """Prune registered MCP tools according to the configured exposure mode.""" |
288 | | - selected_mode = _get_exposed_tools_mode() if mode is None else _get_exposed_tools_mode(mode) |
289 | | - base_mode, allowlist = _split_exposed_tools_mode(selected_mode) |
290 | | - if base_mode == "all": |
291 | | - return [] |
| 346 | +def _get_exposed_tools_mode(value: Optional[str] = None) -> str: |
| 347 | + """Return the configured MCP tool exposure mode, normalised.""" |
| 348 | + return str(_parse_exposed_tools_mode(value)) |
| 349 | + |
| 350 | + |
| 351 | +def _validate_exposed_tools_exclusions( |
| 352 | + invalid: str, allowlist: list[str], exclusions: list[str] |
| 353 | +) -> None: |
| 354 | + """Reject syntax mistakes in the ``-`` exclusion list.""" |
| 355 | + sep = _EXPOSED_TOOLS_EXCLUDE_SEPARATOR |
| 356 | + if not exclusions: |
| 357 | + raise SystemExit(f"{invalid} The '{sep}' exclusion list must name at least one tool.") |
| 358 | + if any(_EXPOSED_TOOLS_ALLOW_SEPARATOR in name or sep in name for name in exclusions): |
| 359 | + raise SystemExit( |
| 360 | + f"{invalid} Use one '{_EXPOSED_TOOLS_ALLOW_SEPARATOR}' list followed by one " |
| 361 | + f"'{sep}' list, each comma-separated." |
| 362 | + ) |
| 363 | + conflicting = sorted(set(allowlist) & set(exclusions)) |
| 364 | + if conflicting: |
| 365 | + raise SystemExit( |
| 366 | + f"{invalid} Tool(s) both exposed with '{_EXPOSED_TOOLS_ALLOW_SEPARATOR}' and " |
| 367 | + f"excluded with '{sep}': {', '.join(conflicting)}." |
| 368 | + ) |
| 369 | + |
292 | 370 |
|
293 | | - registered = {tool.name for tool in server._tool_manager.list_tools()} |
294 | | - unknown = sorted(set(allowlist) - registered) |
| 371 | +def _reject_unknown_exposed_tools( |
| 372 | + kind: str, names: list[str], registered: set[str], hint: str = "" |
| 373 | +) -> None: |
| 374 | + unknown = sorted(set(names) - registered) |
295 | 375 | if unknown: |
296 | | - # Fail loudly: a typo must not silently degrade into a narrower allowlist |
| 376 | + # Fail loudly: a typo must not silently produce a different surface |
297 | 377 | # that looks like it worked. |
298 | 378 | raise SystemExit( |
299 | | - f"Invalid TELEGRAM_EXPOSED_TOOLS allowlist: unknown tool(s) {', '.join(unknown)}." |
| 379 | + f"Invalid TELEGRAM_EXPOSED_TOOLS {kind}: unknown tool(s) " |
| 380 | + f"{', '.join(unknown)}.{hint}" |
300 | 381 | ) |
301 | 382 |
|
302 | | - allowed = set(allowlist) |
303 | | - removed: list[str] = [] |
304 | | - for tool in list(server._tool_manager.list_tools()): |
305 | | - if tool.name in allowed: |
| 383 | + |
| 384 | +def _apply_exposed_tools_mode( |
| 385 | + server: FastMCP = mcp, mode: Optional[str] = None |
| 386 | +) -> _ExposedToolsResult: |
| 387 | + """Prune registered MCP tools according to the configured exposure mode.""" |
| 388 | + selected = _parse_exposed_tools_mode(mode) |
| 389 | + if selected.base == "all" and not selected.exclusions: |
| 390 | + return _ExposedToolsResult([], []) |
| 391 | + |
| 392 | + tools = list(server._tool_manager.list_tools()) |
| 393 | + registered = {tool.name for tool in tools} |
| 394 | + # "read-only+send-message" splits into "+send" and "-message"; point at the |
| 395 | + # hyphen when a "-" list could be the result of one. |
| 396 | + hint = _EXPOSED_TOOLS_HYPHEN_HINT if selected.exclusions else "" |
| 397 | + _reject_unknown_exposed_tools("allowlist", selected.allowlist, registered, hint) |
| 398 | + _reject_unknown_exposed_tools("exclusion list", selected.exclusions, registered) |
| 399 | + |
| 400 | + allowed = set(selected.allowlist) |
| 401 | + excluded = set(selected.exclusions) |
| 402 | + |
| 403 | + def base_exposes(tool) -> bool: |
| 404 | + if selected.base == "all" or tool.name in allowed: |
| 405 | + return True |
| 406 | + return bool(getattr(getattr(tool, "annotations", None), "readOnlyHint", False)) |
| 407 | + |
| 408 | + already_hidden = sorted( |
| 409 | + tool.name for tool in tools if tool.name in excluded and not base_exposes(tool) |
| 410 | + ) |
| 411 | + if already_hidden: |
| 412 | + # A no-op exclusion usually means the operator expected a different base, |
| 413 | + # or typed "-" for "_" inside a "+" name. |
| 414 | + verb, pronoun = ("is", "it") if len(already_hidden) == 1 else ("are", "them") |
| 415 | + raise SystemExit( |
| 416 | + f"Invalid TELEGRAM_EXPOSED_TOOLS exclusion list: {', '.join(already_hidden)} " |
| 417 | + f"{verb} already hidden by {selected.base}; remove {pronoun} from the " |
| 418 | + f"'{_EXPOSED_TOOLS_EXCLUDE_SEPARATOR}' list." |
| 419 | + f"{_EXPOSED_TOOLS_HYPHEN_HINT if selected.allowlist else ''}" |
| 420 | + ) |
| 421 | + |
| 422 | + result = _ExposedToolsResult([], []) |
| 423 | + for tool in tools: |
| 424 | + if tool.name in excluded: |
| 425 | + result.excluded.append(tool.name) |
| 426 | + elif not base_exposes(tool): |
| 427 | + result.hidden.append(tool.name) |
| 428 | + else: |
306 | 429 | continue |
307 | | - annotations = getattr(tool, "annotations", None) |
308 | | - if not getattr(annotations, "readOnlyHint", False): |
309 | | - server._tool_manager.remove_tool(tool.name) |
310 | | - removed.append(tool.name) |
311 | | - return removed |
| 430 | + server._tool_manager.remove_tool(tool.name) |
| 431 | + return result |
312 | 432 |
|
313 | 433 |
|
314 | 434 | _FILE_EXTENSION_TOKEN_PATTERN = re.compile(r"^\.[A-Za-z0-9_-]+$") |
|
0 commit comments