Skip to content

Commit 92d5887

Browse files
authored
Merge pull request chigwell#168 from cyb3ralbert/feat/exposed-tools-allowlist
feat(runtime): allow read-only mode to expose named write tools
2 parents 7bb78e9 + 26e66d4 commit 92d5887

4 files changed

Lines changed: 113 additions & 5 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ TELEGRAM_SESSION_NAME=telegram_session
3939
# readOnlyHint=True through MCP. This does not reduce Telegram session authority
4040
# inside the server process.
4141
# TELEGRAM_EXPOSED_TOOLS=read-only
42+
#
43+
# Append '+name,name' to read-only to also expose specific write tools. Every
44+
# other write tool stays unregistered. Unknown names abort startup.
45+
# TELEGRAM_EXPOSED_TOOLS=read-only+send_message,reply_to_message
4246

4347
# --- File-path tools / allowed roots (optional) ---
4448
# When the MCP client implements Roots but advertises an empty list — or when

README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,22 @@ clients from sending messages or performing chat/account mutations, set
122122
TELEGRAM_EXPOSED_TOOLS=read-only
123123
```
124124

125+
If read-only is too strict but `all` is too broad, append `+` and a
126+
comma-separated list of tool names to also expose those specific write tools.
127+
Every other write tool stays unregistered:
128+
129+
```env
130+
TELEGRAM_EXPOSED_TOOLS=read-only+send_message,reply_to_message,send_file
131+
```
132+
133+
An unknown name in the allowlist aborts startup, so a typo cannot silently
134+
degrade into a narrower surface that looks like it worked.
135+
125136
This is an MCP tool-surface restriction, not a Telegram session sandbox or
126137
reduced Telegram account permission. The Telegram session string still has its
127138
normal authority inside the server process; read-only mode only prevents
128139
non-read-only tools from being registered and exposed through MCP. Accepted
129-
values are `all` (the default) and `read-only`.
140+
values are `all` (the default), `read-only`, and `read-only+<tool>,<tool>`.
130141

131142
Run the server locally:
132143

@@ -167,6 +178,12 @@ server `env` block:
167178
"TELEGRAM_EXPOSED_TOOLS": "read-only"
168179
```
169180

181+
Or keep read-only as the baseline and allow a few write tools on top:
182+
183+
```json
184+
"TELEGRAM_EXPOSED_TOOLS": "read-only+send_message,reply_to_message"
185+
```
186+
170187
Alternatively, install this repository directly from GitHub into a virtual
171188
environment using a specific release tag or commit:
172189

telegram_mcp/runtime.py

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,32 +149,70 @@ async def annotated_handler(req):
149149

150150

151151
_EXPOSED_TOOLS_MODES = {"all", "read-only"}
152+
_EXPOSED_TOOLS_ALLOW_SEPARATOR = "+"
153+
154+
155+
def _split_exposed_tools_mode(mode: str) -> tuple[str, list[str]]:
156+
"""Split a normalised exposure mode into its base mode and write allowlist."""
157+
base, separator, raw_allowlist = mode.partition(_EXPOSED_TOOLS_ALLOW_SEPARATOR)
158+
if not separator:
159+
return base, []
160+
return base, [name.strip() for name in raw_allowlist.split(",") if name.strip()]
152161

153162

154163
def _get_exposed_tools_mode(value: Optional[str] = None) -> str:
155164
"""Return the configured MCP tool exposure mode.
156165
157166
``TELEGRAM_EXPOSED_TOOLS=read-only`` keeps only tools annotated with
158-
``readOnlyHint=True``. The default is ``all`` for backward compatibility.
167+
``readOnlyHint=True``. ``read-only+send_message,reply_to_message`` keeps
168+
those plus the named write tools. The default is ``all`` for backward
169+
compatibility.
159170
"""
160171
raw_value = os.getenv("TELEGRAM_EXPOSED_TOOLS", "all") if value is None else value
161172
mode = raw_value.strip().lower()
162-
if mode not in _EXPOSED_TOOLS_MODES:
173+
base_mode, allowlist = _split_exposed_tools_mode(mode)
174+
if base_mode not in _EXPOSED_TOOLS_MODES:
163175
accepted = ", ".join(sorted(_EXPOSED_TOOLS_MODES))
164176
raise SystemExit(
165177
f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. Expected one of: {accepted}."
166178
)
167-
return mode
179+
if _EXPOSED_TOOLS_ALLOW_SEPARATOR not in mode:
180+
return base_mode
181+
if base_mode != "read-only":
182+
raise SystemExit(
183+
f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. The "
184+
f"'{_EXPOSED_TOOLS_ALLOW_SEPARATOR}tool,tool' allowlist is only valid "
185+
"with read-only."
186+
)
187+
if not allowlist:
188+
raise SystemExit(
189+
f"Invalid TELEGRAM_EXPOSED_TOOLS '{raw_value}'. The "
190+
f"'{_EXPOSED_TOOLS_ALLOW_SEPARATOR}' allowlist must name at least one tool."
191+
)
192+
return f"{base_mode}{_EXPOSED_TOOLS_ALLOW_SEPARATOR}{','.join(allowlist)}"
168193

169194

170195
def _apply_exposed_tools_mode(server: FastMCP = mcp, mode: Optional[str] = None) -> list[str]:
171196
"""Prune registered MCP tools according to the configured exposure mode."""
172197
selected_mode = _get_exposed_tools_mode() if mode is None else _get_exposed_tools_mode(mode)
173-
if selected_mode == "all":
198+
base_mode, allowlist = _split_exposed_tools_mode(selected_mode)
199+
if base_mode == "all":
174200
return []
175201

202+
registered = {tool.name for tool in server._tool_manager.list_tools()}
203+
unknown = sorted(set(allowlist) - registered)
204+
if unknown:
205+
# Fail loudly: a typo must not silently degrade into a narrower allowlist
206+
# that looks like it worked.
207+
raise SystemExit(
208+
f"Invalid TELEGRAM_EXPOSED_TOOLS allowlist: unknown tool(s) {', '.join(unknown)}."
209+
)
210+
211+
allowed = set(allowlist)
176212
removed: list[str] = []
177213
for tool in list(server._tool_manager.list_tools()):
214+
if tool.name in allowed:
215+
continue
178216
annotations = getattr(tool, "annotations", None)
179217
if not getattr(annotations, "readOnlyHint", False):
180218
server._tool_manager.remove_tool(tool.name)

tests/test_runtime.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,55 @@ def test_get_exposed_tools_mode_rejects_invalid_value(monkeypatch):
8484
assert "read-only" in message
8585

8686

87+
def _synthetic_mcp_with_two_writes():
88+
server = _synthetic_mcp()
89+
90+
@server.tool(annotations=ToolAnnotations(title="Send", destructiveHint=True))
91+
def send_tool():
92+
return "send"
93+
94+
return server
95+
96+
97+
def test_get_exposed_tools_mode_normalises_allowlist(monkeypatch):
98+
monkeypatch.setenv("TELEGRAM_EXPOSED_TOOLS", " Read-Only+ send_tool , write_tool ")
99+
100+
assert runtime._get_exposed_tools_mode() == "read-only+send_tool,write_tool"
101+
102+
103+
def test_apply_exposed_tools_allowlist_keeps_named_write_tools():
104+
server = _synthetic_mcp_with_two_writes()
105+
106+
removed = runtime._apply_exposed_tools_mode(server, "read-only+send_tool")
107+
108+
assert removed == ["write_tool"]
109+
assert _tool_names(server) == {"read_tool", "send_tool"}
110+
111+
112+
def test_apply_exposed_tools_allowlist_rejects_unknown_tool():
113+
server = _synthetic_mcp_with_two_writes()
114+
115+
with pytest.raises(SystemExit) as excinfo:
116+
runtime._apply_exposed_tools_mode(server, "read-only+send_mesage")
117+
118+
assert "send_mesage" in str(excinfo.value)
119+
assert _tool_names(server) == {"read_tool", "write_tool", "send_tool"}
120+
121+
122+
def test_get_exposed_tools_mode_rejects_allowlist_with_all():
123+
with pytest.raises(SystemExit) as excinfo:
124+
runtime._get_exposed_tools_mode("all+send_tool")
125+
126+
assert "read-only" in str(excinfo.value)
127+
128+
129+
def test_get_exposed_tools_mode_rejects_empty_allowlist():
130+
with pytest.raises(SystemExit) as excinfo:
131+
runtime._get_exposed_tools_mode("read-only+")
132+
133+
assert "at least one tool" in str(excinfo.value)
134+
135+
87136
def test_discover_accounts_supports_suffixed_and_default_sessions(monkeypatch):
88137
_clear_session_env(monkeypatch)
89138
monkeypatch.setenv("TELEGRAM_SESSION_STRING_WORK", "work-session")

0 commit comments

Comments
 (0)