Skip to content

Commit 5960872

Browse files
authored
Merge pull request chigwell#251 from YorickLane/readonly-hint-side-effects
Mark export_unread_messages and the invite-link tools as not read-only; print hidden tools at startup
2 parents fa96092 + 6aceae9 commit 5960872

6 files changed

Lines changed: 52 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,9 @@ TELEGRAM_EXPOSED_TOOLS=read-only+send_message,reply_to_message,send_file
261261
An unknown name in the allowlist aborts startup, so a typo cannot silently
262262
degrade into a narrower surface that looks like it worked.
263263

264+
At startup the server prints the names of the tools the mode hides to stderr;
265+
those are the names the `+` list accepts.
266+
264267
This is an MCP tool-surface restriction, not a Telegram session sandbox or
265268
reduced Telegram account permission. The Telegram session string still has its
266269
normal authority inside the server process; read-only mode only prevents

‎telegram_mcp/runner.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,15 @@ def main() -> None:
275275
# send_file is not exposed is a valid configuration, so the name check
276276
# has to see the full tool set.
277277
_runtime._apply_file_extension_overrides()
278-
_runtime._apply_exposed_tools_mode()
278+
hidden = _runtime._apply_exposed_tools_mode()
279+
if hidden:
280+
# These names are the menu for the "+" allowlist; without this line the
281+
# only way to find them is reading the tool annotations in the source.
282+
print(
283+
f"TELEGRAM_EXPOSED_TOOLS hides {len(hidden)} tool(s); list any of them "
284+
f"after '+' to expose it: {', '.join(sorted(hidden))}",
285+
file=sys.stderr,
286+
)
279287
_transcription.validate_transcription_config()
280288
_session_lock_shared() # fail loudly at startup on a bad toggle
281289
asyncio.run(_main())

‎telegram_mcp/tools/groups.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,7 +1125,7 @@ async def get_banned_users(chat_id: Union[int, str], account: str = None) -> str
11251125

11261126

11271127
@mcp.tool(
1128-
annotations=ToolAnnotations(title="Get Invite Link", openWorldHint=True, readOnlyHint=True)
1128+
annotations=ToolAnnotations(title="Get Invite Link", openWorldHint=True, readOnlyHint=False)
11291129
)
11301130
@with_account(readonly=True)
11311131
@validate_id("chat_id")
@@ -1222,7 +1222,7 @@ async def join_chat_by_link(link: str, account: str = None) -> str:
12221222

12231223

12241224
@mcp.tool(
1225-
annotations=ToolAnnotations(title="Export Chat Invite", openWorldHint=True, readOnlyHint=True)
1225+
annotations=ToolAnnotations(title="Export Chat Invite", openWorldHint=True, readOnlyHint=False)
12261226
)
12271227
@with_account(readonly=True)
12281228
@validate_id("chat_id")

‎telegram_mcp/tools/messages.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2654,7 +2654,7 @@ async def clear_draft(chat_id: Union[int, str], account: str = None) -> str:
26542654
annotations=ToolAnnotations(
26552655
title="Export Unread Messages",
26562656
openWorldHint=True,
2657-
readOnlyHint=True,
2657+
readOnlyHint=False,
26582658
destructiveHint=False,
26592659
)
26602660
)

‎tests/test_runner.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,3 +434,23 @@ def test_cli_host_and_port_flags(monkeypatch):
434434
runtime._configure_allowed_roots_from_cli(["--host", "0.0.0.0", "--port", "9000"])
435435
assert runtime._CLI_HOST == "0.0.0.0"
436436
assert runtime._CLI_PORT == 9000
437+
438+
439+
def test_main_prints_the_tools_exposure_hides(monkeypatch, capsys):
440+
"""The hidden names are what a user copies into read-only+<tool>."""
441+
monkeypatch.setattr(runner, "_configure_allowed_roots_from_cli", lambda *a, **k: None)
442+
monkeypatch.setattr(runner._runtime, "_apply_file_extension_overrides", lambda: None)
443+
monkeypatch.setattr(
444+
runner._runtime,
445+
"_apply_exposed_tools_mode",
446+
lambda: ["send_message", "export_chat_invite"],
447+
)
448+
monkeypatch.setattr(runner._transcription, "validate_transcription_config", lambda: None)
449+
monkeypatch.setattr(runner, "_session_lock_shared", lambda: None)
450+
monkeypatch.setattr(runner.asyncio, "run", lambda coro: coro.close())
451+
452+
runner.main()
453+
454+
err = capsys.readouterr().err
455+
assert "hides 2 tool(s)" in err
456+
assert "export_chat_invite, send_message" in err
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import pytest
2+
3+
from telegram_mcp import runtime
4+
from telegram_mcp.tools import groups, messages # noqa: F401 (registers the tools)
5+
6+
# These tools change state, so TELEGRAM_EXPOSED_TOOLS=read-only must drop them:
7+
# export_unread_messages writes a JSON file to a caller-chosen path, and
8+
# export_chat_invite / get_invite_link call messages.exportChatInvite, which
9+
# creates a new invite link. They can still be exposed by name
10+
# (read-only+export_chat_invite).
11+
SIDE_EFFECT_TOOLS = ["export_unread_messages", "export_chat_invite", "get_invite_link"]
12+
13+
14+
@pytest.mark.parametrize("tool_name", SIDE_EFFECT_TOOLS)
15+
def test_side_effect_tools_are_not_read_only(tool_name):
16+
registered = {tool.name: tool for tool in runtime.mcp._tool_manager.list_tools()}
17+
assert registered[tool_name].annotations.readOnlyHint is False

0 commit comments

Comments
 (0)