Skip to content

Commit 786243d

Browse files
authored
Merge pull request chigwell#136 from iqdoctor/feat/exposed-tools-read-only
Add read-only exposed tools mode
2 parents b78cae0 + 9d993fc commit 786243d

5 files changed

Lines changed: 120 additions & 1 deletion

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ TELEGRAM_SESSION_NAME=telegram_session
1414
# TELEGRAM_SESSION_STRING_WORK=<session string for work account>
1515
# TELEGRAM_SESSION_STRING_PERSONAL=<session string for personal account>
1616

17+
# --- MCP tool exposure (optional) ---
18+
# Default is all. Set to read-only to expose only tools annotated with
19+
# readOnlyHint=True through MCP. This does not reduce Telegram session authority
20+
# inside the server process.
21+
# TELEGRAM_EXPOSED_TOOLS=read-only
22+
1723
# --- Proxy (optional) ---
1824
# Route Telegram traffic through a proxy. Set TELEGRAM_PROXY_TYPE to enable.
1925
# Supported types: socks5, socks4, http, mtproxy.

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,21 @@ TELEGRAM_API_HASH=your_api_hash_here
112112
TELEGRAM_SESSION_STRING=your_session_string_here
113113
```
114114

115+
By default, all Telegram MCP tools are exposed. If you want to prevent MCP
116+
clients from sending messages or performing chat/account mutations, set
117+
`TELEGRAM_EXPOSED_TOOLS=read-only` to expose only tools annotated with
118+
`readOnlyHint=True`:
119+
120+
```env
121+
TELEGRAM_EXPOSED_TOOLS=read-only
122+
```
123+
124+
This is an MCP tool-surface restriction, not a Telegram session sandbox or
125+
reduced Telegram account permission. The Telegram session string still has its
126+
normal authority inside the server process; read-only mode only prevents
127+
non-read-only tools from being registered and exposed through MCP. Accepted
128+
values are `all` (the default) and `read-only`.
129+
115130
Run the server locally:
116131

117132
```bash
@@ -144,6 +159,13 @@ this project:
144159
}
145160
```
146161

162+
To expose only read-only tools in Claude Desktop or Cursor, add this to the
163+
server `env` block:
164+
165+
```json
166+
"TELEGRAM_EXPOSED_TOOLS": "read-only"
167+
```
168+
147169
Alternatively, install this repository directly from GitHub into a virtual
148170
environment using a specific release tag or commit:
149171

telegram_mcp/runner.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
except UnsafeInstallationError as exc:
88
raise SystemExit(str(exc)) from None
99

10+
from telegram_mcp import runtime as _runtime
1011
from telegram_mcp.runtime import *
1112
import telegram_mcp.tools # noqa: F401 - registers MCP tools via decorators
1213

@@ -60,6 +61,7 @@ async def _main() -> None:
6061

6162
def main() -> None:
6263
_configure_allowed_roots_from_cli(sys.argv[1:])
64+
_runtime._apply_exposed_tools_mode()
6365
nest_asyncio.apply()
6466
asyncio.run(_main())
6567

telegram_mcp/runtime.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,40 @@ async def annotated_handler(req):
137137
_install_annotation_hook()
138138

139139

140+
_EXPOSED_TOOLS_MODES = {"all", "read-only"}
141+
142+
143+
def _get_exposed_tools_mode(value: Optional[str] = None) -> str:
144+
"""Return the configured MCP tool exposure mode.
145+
146+
``TELEGRAM_EXPOSED_TOOLS=read-only`` keeps only tools annotated with
147+
``readOnlyHint=True``. The default is ``all`` for backward compatibility.
148+
"""
149+
raw_value = os.getenv("TELEGRAM_EXPOSED_TOOLS", "all") if value is None else value
150+
mode = raw_value.strip().lower()
151+
if mode not in _EXPOSED_TOOLS_MODES:
152+
accepted = ", ".join(sorted(_EXPOSED_TOOLS_MODES))
153+
raise SystemExit(
154+
f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. Expected one of: {accepted}."
155+
)
156+
return mode
157+
158+
159+
def _apply_exposed_tools_mode(server: FastMCP = mcp, mode: Optional[str] = None) -> list[str]:
160+
"""Prune registered MCP tools according to the configured exposure mode."""
161+
selected_mode = _get_exposed_tools_mode() if mode is None else _get_exposed_tools_mode(mode)
162+
if selected_mode == "all":
163+
return []
164+
165+
removed: list[str] = []
166+
for tool in list(server._tool_manager.list_tools()):
167+
annotations = getattr(tool, "annotations", None)
168+
if not getattr(annotations, "readOnlyHint", False):
169+
server._tool_manager.remove_tool(tool.name)
170+
removed.append(tool.name)
171+
return removed
172+
173+
140174
# ---------------------------------------------------------------------------
141175
# Multi-account configuration
142176
# ---------------------------------------------------------------------------

tests/test_runtime.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
from types import SimpleNamespace
55

66
import pytest
7+
from mcp.server.fastmcp import FastMCP
78
from mcp.shared.exceptions import McpError
8-
from mcp.types import ErrorData
9+
from mcp.types import ErrorData, ToolAnnotations
910
from telethon.tl.types import Channel, Chat, PeerUser, User
1011

1112
import main
@@ -24,6 +25,60 @@ def __init__(self, *args, **kwargs):
2425
self.kwargs = kwargs
2526

2627

28+
def _tool_names(server):
29+
return {tool.name for tool in server._tool_manager.list_tools()}
30+
31+
32+
def _synthetic_mcp():
33+
server = FastMCP("test")
34+
35+
@server.tool(annotations=ToolAnnotations(title="Read", readOnlyHint=True))
36+
def read_tool():
37+
return "read"
38+
39+
@server.tool(annotations=ToolAnnotations(title="Write", destructiveHint=True))
40+
def write_tool():
41+
return "write"
42+
43+
return server
44+
45+
46+
def test_get_exposed_tools_mode_defaults_to_all(monkeypatch):
47+
monkeypatch.delenv("TELEGRAM_EXPOSED_TOOLS", raising=False)
48+
49+
assert runtime._get_exposed_tools_mode() == "all"
50+
51+
52+
def test_apply_exposed_tools_all_keeps_tools():
53+
server = _synthetic_mcp()
54+
55+
removed = runtime._apply_exposed_tools_mode(server, "all")
56+
57+
assert removed == []
58+
assert _tool_names(server) == {"read_tool", "write_tool"}
59+
60+
61+
def test_apply_exposed_tools_read_only_removes_non_read_only_tools():
62+
server = _synthetic_mcp()
63+
64+
removed = runtime._apply_exposed_tools_mode(server, "read-only")
65+
66+
assert removed == ["write_tool"]
67+
assert _tool_names(server) == {"read_tool"}
68+
69+
70+
def test_get_exposed_tools_mode_rejects_invalid_value(monkeypatch):
71+
monkeypatch.setenv("TELEGRAM_EXPOSED_TOOLS", "send-everything")
72+
73+
with pytest.raises(SystemExit) as excinfo:
74+
runtime._get_exposed_tools_mode()
75+
76+
message = str(excinfo.value)
77+
assert "TELEGRAM_EXPOSED_TOOLS" in message
78+
assert "all" in message
79+
assert "read-only" in message
80+
81+
2782
def test_discover_accounts_supports_suffixed_and_default_sessions(monkeypatch):
2883
_clear_session_env(monkeypatch)
2984
monkeypatch.setenv("TELEGRAM_SESSION_STRING_WORK", "work-session")

0 commit comments

Comments
 (0)