Skip to content

Commit 7f7b84b

Browse files
authored
Merge pull request chigwell#281 from vsem-azamat/feat/exposed-tools-exclusions
feat(runtime): exclude tools from TELEGRAM_EXPOSED_TOOLS with a - list
2 parents 80a62d5 + 493bd2a commit 7f7b84b

6 files changed

Lines changed: 477 additions & 56 deletions

File tree

‎.env.example‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,11 @@ TELEGRAM_SESSION_NAME=telegram_session
6464
# Append '+name,name' to read-only to also expose specific write tools. Every
6565
# other write tool stays unregistered. Unknown names abort startup.
6666
# TELEGRAM_EXPOSED_TOOLS=read-only+send_message,reply_to_message
67+
#
68+
# Append '-name,name' to either base (after any '+' list) to hide specific
69+
# tools. Unknown names, or names the base already hides, abort startup.
70+
# TELEGRAM_EXPOSED_TOOLS=all-delete_chat_history,delete_messages_bulk
71+
# TELEGRAM_EXPOSED_TOOLS=read-only+send_message-get_participants
6772

6873
# --- Per-tool file extension allowlists (optional) ---
6974
# A hardcoded allowlist already restricts send_voice/send_sticker/

‎README.md‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -267,17 +267,33 @@ Every other write tool stays unregistered:
267267
TELEGRAM_EXPOSED_TOOLS=read-only+send_message,reply_to_message,send_file
268268
```
269269

270-
An unknown name in the allowlist aborts startup, so a typo cannot silently
271-
degrade into a narrower surface that looks like it worked.
270+
To hide specific tools instead, append `-` and a comma-separated list to either
271+
base. It works after a `+` list too:
272+
273+
```env
274+
TELEGRAM_EXPOSED_TOOLS=all-delete_chat_history,delete_messages_bulk
275+
TELEGRAM_EXPOSED_TOOLS=read-only-get_participants
276+
TELEGRAM_EXPOSED_TOOLS=read-only+send_message,reply_to_message-get_participants
277+
```
278+
279+
An unknown name in either list aborts startup, so a typo cannot silently
280+
produce a different surface that looks like it worked. Startup also aborts on
281+
an empty list, a name in both lists, or a `-` name the base already hides (such
282+
as `read-only-send_message`), which would be a no-op. To turn off voice
283+
transcription, set `TELEGRAM_TRANSCRIBE=off` rather than `-transcribe_voice`:
284+
hiding the tool leaves listings' pending-transcript hints and `auto` prefetch in
285+
place.
272286

273287
At startup the server prints the names of the tools the mode hides to stderr;
274-
those are the names the `+` list accepts.
288+
those are the names the `+` list accepts. Tools hidden by the `-` list are
289+
printed on a separate line.
275290

276291
This is an MCP tool-surface restriction, not a Telegram session sandbox or
277292
reduced Telegram account permission. The Telegram session string still has its
278293
normal authority inside the server process; read-only mode only prevents
279294
non-read-only tools from being registered and exposed through MCP. Accepted
280-
values are `all` (the default), `read-only`, and `read-only+<tool>,<tool>`.
295+
values are `all` (the default), `read-only`, and `read-only+<tool>,<tool>`,
296+
each optionally followed by `-<tool>,<tool>`.
281297

282298
A separate, hardcoded allowlist restricts `send_voice`, `send_sticker`,
283299
`set_profile_photo`, and `edit_chat_photo` to their expected file extensions;

‎telegram_mcp/runner.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,7 @@ def main() -> None:
314314
# send_file is not exposed is a valid configuration, so the name check
315315
# has to see the full tool set.
316316
_runtime._apply_file_extension_overrides()
317-
hidden = _runtime._apply_exposed_tools_mode()
317+
hidden, excluded = _runtime._apply_exposed_tools_mode()
318318
if hidden:
319319
# These names are the menu for the "+" allowlist; without this line the
320320
# only way to find them is reading the tool annotations in the source.
@@ -323,6 +323,12 @@ def main() -> None:
323323
f"after '+' to expose it: {', '.join(sorted(hidden))}",
324324
file=sys.stderr,
325325
)
326+
if excluded:
327+
print(
328+
f"TELEGRAM_EXPOSED_TOOLS excludes {len(excluded)} tool(s) listed "
329+
f"after '-': {', '.join(sorted(excluded))}",
330+
file=sys.stderr,
331+
)
326332
_transcription.validate_transcription_config()
327333
_session_lock_shared() # fail loudly at startup on a bad toggle
328334
asyncio.run(_main())

‎telegram_mcp/runtime.py‎

Lines changed: 165 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from difflib import SequenceMatcher
2121
from datetime import datetime, timedelta, timezone
2222
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
2424
from pathlib import Path
2525
from urllib.parse import unquote, urlparse
2626

@@ -241,74 +241,194 @@ async def annotated_handler(req):
241241

242242
_EXPOSED_TOOLS_MODES = {"all", "read-only"}
243243
_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+
)
244249

245250

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

254+
base: str
255+
allowlist: list[str]
256+
exclusions: list[str]
253257

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

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``.
261323
"""
262324
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:
266328
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":
267331
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 "
275333
f"'{_EXPOSED_TOOLS_ALLOW_SEPARATOR}tool,tool' allowlist is only valid "
276334
"with read-only."
277335
)
278-
if not allowlist:
336+
if allowlist is not None and not allowlist:
279337
raise SystemExit(
280-
f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. The "
338+
f"{invalid} The "
281339
f"'{_EXPOSED_TOOLS_ALLOW_SEPARATOR}' allowlist must name at least one tool."
282340
)
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 [])
284344

285345

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+
292370

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)
295375
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
297377
# that looks like it worked.
298378
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}"
300381
)
301382

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:
306429
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
312432

313433

314434
_FILE_EXTENSION_TOKEN_PATTERN = re.compile(r"^\.[A-Za-z0-9_-]+$")

‎tests/test_runner.py‎

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ def test_file_extension_overrides_are_validated_before_tools_are_pruned(monkeypa
472472
monkeypatch.setattr(
473473
runner._runtime,
474474
"_apply_exposed_tools_mode",
475-
lambda *a, **k: calls.append("exposed") or [],
475+
lambda *a, **k: calls.append("exposed") or ([], []),
476476
)
477477
monkeypatch.setattr(
478478
runner._runtime,
@@ -505,7 +505,7 @@ def test_cli_transport_flag_sets_env_var(monkeypatch):
505505
monkeypatch.setattr(runner._runtime, "_CLI_HOST", None)
506506
monkeypatch.setattr(runner._runtime, "_CLI_PORT", None)
507507
monkeypatch.setattr(runner._runtime, "_apply_file_extension_overrides", lambda: None)
508-
monkeypatch.setattr(runner._runtime, "_apply_exposed_tools_mode", lambda: None)
508+
monkeypatch.setattr(runner._runtime, "_apply_exposed_tools_mode", lambda: ([], []))
509509
monkeypatch.setattr(runner._transcription, "validate_transcription_config", lambda: None)
510510
monkeypatch.setattr(runner, "_session_lock_shared", lambda: None)
511511
monkeypatch.setattr(runner.asyncio, "run", lambda coro: coro.close())
@@ -566,7 +566,7 @@ def test_main_prints_the_tools_exposure_hides(monkeypatch, capsys):
566566
monkeypatch.setattr(
567567
runner._runtime,
568568
"_apply_exposed_tools_mode",
569-
lambda: ["send_message", "export_chat_invite"],
569+
lambda: (["send_message", "export_chat_invite"], []),
570570
)
571571
monkeypatch.setattr(runner._transcription, "validate_transcription_config", lambda: None)
572572
monkeypatch.setattr(runner, "_session_lock_shared", lambda: None)
@@ -577,3 +577,35 @@ def test_main_prints_the_tools_exposure_hides(monkeypatch, capsys):
577577
err = capsys.readouterr().err
578578
assert "hides 2 tool(s)" in err
579579
assert "export_chat_invite, send_message" in err
580+
581+
582+
def _run_main_with_exposure_result(monkeypatch, result):
583+
monkeypatch.setattr(runner, "_configure_allowed_roots_from_cli", lambda *a, **k: None)
584+
monkeypatch.setattr(runner._runtime, "_apply_file_extension_overrides", lambda: None)
585+
monkeypatch.setattr(runner._runtime, "_apply_exposed_tools_mode", lambda: result)
586+
monkeypatch.setattr(runner._transcription, "validate_transcription_config", lambda: None)
587+
monkeypatch.setattr(runner, "_session_lock_shared", lambda: None)
588+
monkeypatch.setattr(runner.asyncio, "run", lambda coro: coro.close())
589+
590+
runner.main()
591+
592+
593+
def test_main_reports_excluded_tools_separately(monkeypatch, capsys):
594+
"""Excluded names are not '+' candidates, so they get their own line."""
595+
_run_main_with_exposure_result(
596+
monkeypatch, (["delete_message", "ban_user"], ["list_chats", "get_participants"])
597+
)
598+
599+
err = capsys.readouterr().err
600+
assert (
601+
"hides 2 tool(s); list any of them after '+' to expose it: ban_user, delete_message" in err
602+
)
603+
assert "excludes 2 tool(s) listed after '-': get_participants, list_chats" in err
604+
605+
606+
def test_main_reports_only_exclusions_for_all_base(monkeypatch, capsys):
607+
_run_main_with_exposure_result(monkeypatch, ([], ["delete_message", "delete_messages_bulk"]))
608+
609+
err = capsys.readouterr().err
610+
assert "after '+'" not in err
611+
assert "excludes 2 tool(s) listed after '-': delete_message, delete_messages_bulk" in err

0 commit comments

Comments
 (0)