telegram-context-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. telegram_context_mcp-0.2.0.dist-info/METADATA +187 -0
  2. telegram_context_mcp-0.2.0.dist-info/RECORD +49 -0
  3. telegram_context_mcp-0.2.0.dist-info/WHEEL +4 -0
  4. telegram_context_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  5. telegram_context_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
  6. telegram_mcp/__init__.py +1 -0
  7. telegram_mcp/adapters/__init__.py +1 -0
  8. telegram_mcp/adapters/bridge.py +77 -0
  9. telegram_mcp/adapters/cli.py +215 -0
  10. telegram_mcp/adapters/clock.py +12 -0
  11. telegram_mcp/adapters/config.py +169 -0
  12. telegram_mcp/adapters/cursors.py +58 -0
  13. telegram_mcp/adapters/http_token.py +76 -0
  14. telegram_mcp/adapters/mcp.py +363 -0
  15. telegram_mcp/adapters/session.py +57 -0
  16. telegram_mcp/adapters/telegram/__init__.py +6 -0
  17. telegram_mcp/adapters/telegram/auth.py +48 -0
  18. telegram_mcp/adapters/telegram/backend.py +101 -0
  19. telegram_mcp/adapters/telegram/chats.py +178 -0
  20. telegram_mcp/adapters/telegram/discovery.py +50 -0
  21. telegram_mcp/adapters/telegram/forums.py +124 -0
  22. telegram_mcp/adapters/telegram/mapping.py +287 -0
  23. telegram_mcp/adapters/telegram/messages.py +397 -0
  24. telegram_mcp/adapters/telegram/metadata.py +142 -0
  25. telegram_mcp/adapters/telegram/runtime.py +138 -0
  26. telegram_mcp/adapters/tool_gate.py +24 -0
  27. telegram_mcp/adapters/transport.py +106 -0
  28. telegram_mcp/adapters/version.py +7 -0
  29. telegram_mcp/application/__init__.py +1 -0
  30. telegram_mcp/application/access.py +59 -0
  31. telegram_mcp/application/catalog.py +76 -0
  32. telegram_mcp/application/common.py +19 -0
  33. telegram_mcp/application/discovery.py +50 -0
  34. telegram_mcp/application/message_pager.py +134 -0
  35. telegram_mcp/application/messages.py +195 -0
  36. telegram_mcp/application/ports.py +123 -0
  37. telegram_mcp/application/queries.py +118 -0
  38. telegram_mcp/application/query_preparation.py +154 -0
  39. telegram_mcp/application/requests.py +47 -0
  40. telegram_mcp/application/retry.py +31 -0
  41. telegram_mcp/application/service.py +182 -0
  42. telegram_mcp/application/topics.py +146 -0
  43. telegram_mcp/bootstrap.py +45 -0
  44. telegram_mcp/cli.py +6 -0
  45. telegram_mcp/domain/__init__.py +1 -0
  46. telegram_mcp/domain/folders.py +93 -0
  47. telegram_mcp/domain/models.py +319 -0
  48. telegram_mcp/domain/policy.py +12 -0
  49. telegram_mcp/domain/text.py +16 -0
@@ -0,0 +1,187 @@
1
+ Metadata-Version: 2.5
2
+ Name: telegram-context-mcp
3
+ Version: 0.2.0
4
+ Summary: Local, read-only Telegram MCP server with folder-aware retrieval
5
+ Project-URL: Documentation, https://github.com/egor-fedorov/telegram-mcp/blob/main/README.md
6
+ Project-URL: Repository, https://github.com/egor-fedorov/telegram-mcp
7
+ Project-URL: Issues, https://github.com/egor-fedorov/telegram-mcp/issues
8
+ Project-URL: Roadmap, https://github.com/egor-fedorov/telegram-mcp/blob/main/ROADMAP.md
9
+ Project-URL: Changelog, https://github.com/egor-fedorov/telegram-mcp/blob/main/CHANGELOG.md
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Operating System :: MacOS
15
+ Classifier: Operating System :: Microsoft :: Windows
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: 3.15
22
+ Requires-Python: >=3.12
23
+ Requires-Dist: anyio<5,>=4.15.1
24
+ Requires-Dist: httpx2<3,>=2.5
25
+ Requires-Dist: mcp<3,>=2.2
26
+ Requires-Dist: platformdirs<5,>=4
27
+ Requires-Dist: portalocker<5,>=4.4.1
28
+ Requires-Dist: pydantic<3,>=2.14.1
29
+ Requires-Dist: pywin32>=311; sys_platform == 'win32'
30
+ Requires-Dist: starlette<2,>=1.6
31
+ Requires-Dist: telethon<2,>=1.45
32
+ Requires-Dist: uvicorn<1,>=0.52.4
33
+ Description-Content-Type: text/markdown
34
+
35
+ # Telegram Context MCP
36
+
37
+ [![CI](https://github.com/egor-fedorov/telegram-mcp/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/egor-fedorov/telegram-mcp/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/egor-fedorov/telegram-mcp/blob/main/LICENSE)
39
+
40
+ Read Telegram through your own account from an MCP client. Find permitted conversations,
41
+ search and read original posts, navigate topics and replies, and inspect albums, reactions,
42
+ and polls. Connect several local clients to one session-owning process, or run standalone STDIO.
43
+
44
+ Supports Windows, macOS, and Linux with Python 3.12–3.15. The server uses Telethon and
45
+ preserves original text, dates, links, entities, and available metadata. It does not send
46
+ messages, join channels, mark messages read, or download attachment bytes.
47
+
48
+ ## Install and log in
49
+
50
+ Install [uv](https://docs.astral.sh/uv/getting-started/installation/), then run:
51
+
52
+ ```sh
53
+ uv tool install --python 3.12 telegram-context-mcp==0.2.0
54
+ telegram-mcp --version
55
+ telegram-mcp login
56
+ telegram-mcp configure
57
+ telegram-mcp status
58
+ ```
59
+
60
+ These examples target release 0.2.0; check [PyPI](https://pypi.org/project/telegram-context-mcp/)
61
+ for published versions. Use the [source setup](#run-from-source) for unreleased revisions.
62
+
63
+ Obtain `api_id` and `api_hash` from [my.telegram.org](https://my.telegram.org), then
64
+ enter them in the local login prompt with your phone number, login code, and any 2FA
65
+ password. Do not send login codes or credentials to the model.
66
+
67
+ `configure` lets you choose folders/chats and separately enable all-chat access,
68
+ explicit public-channel reads, or public-channel discovery. Enter keeps the current
69
+ value. By default, no message sources are permitted. `status` should report
70
+ `authorized: true` after login. Use `catalog` to find folder and chat IDs locally.
71
+
72
+ The package name is **`telegram-context-mcp`**; the executable is **`telegram-mcp`**.
73
+ You can also run a pinned version without installing a persistent command:
74
+
75
+ ```sh
76
+ uvx --python 3.12 --from telegram-context-mcp==0.2.0 telegram-mcp --version
77
+ ```
78
+
79
+ ## Connect your clients
80
+
81
+ Start one server in a terminal and leave it running:
82
+
83
+ ```sh
84
+ telegram-mcp serve --transport http
85
+ ```
86
+
87
+ It listens only on `http://127.0.0.1:8765/mcp`. Configure each MCP client to launch
88
+ `telegram-mcp connect` over STDIO. This small proxy reads the local token and connects
89
+ to the existing server; it does not start a server or silently fall back to another
90
+ Telegram session. Closing a client leaves the shared server running.
91
+
92
+ [Client examples](https://github.com/egor-fedorov/telegram-mcp/blob/main/docs/clients.md)
93
+ cover Codex, Claude Desktop, Claude Code, Cursor, and VS Code, including a pinned `uvx`
94
+ command and executable paths for Windows.
95
+
96
+ For one client, standalone STDIO is still available: configure that client to launch
97
+ `telegram-mcp serve`. Use either mode for a given session. Stop the session-owning
98
+ process before running local `login`, `configure`, `catalog`, or `status`; use the
99
+ `get_status` MCP tool while the server is running.
100
+
101
+ All shared clients use the same Telegram account, read permissions, and cursor store.
102
+ The HTTP bearer token is created in the local user profile and read by `connect`;
103
+ do not paste it into client configuration. This is a local, single-user integration.
104
+
105
+ ## Tools
106
+
107
+ | Tool | Purpose |
108
+ | --- | --- |
109
+ | `get_status` | Authorization, connection, permitted sources |
110
+ | `list_folders` | Folders, IDs, order, inclusion/exclusion rules |
111
+ | `list_chats` | Chats filtered by name, type, or folders |
112
+ | `get_chat` | Description and linked discussion group |
113
+ | `search_public_channels` | Find public broadcast channels by query; metadata only |
114
+ | `get_similar_channels` | Find similar public channels for one specified public source; metadata only |
115
+ | `search_messages` | Keyword search by chats, folders, dates, sender, or content type |
116
+ | `get_history` | Chat or forum-topic history over a time range, in either order |
117
+ | `list_topics` | Forum topics with navigation references, optional title query, and pagination |
118
+ | `get_pinned_messages` | Chat-wide or forum-topic pinned messages, newest first |
119
+ | `get_messages` | Messages by links/IDs with authors, dates, attachments, counters, reactions, and polls |
120
+ | `get_album` | Members of a selected album within a bounded neighborhood of one message |
121
+ | `get_context` | Messages before/after an anchor and a chain of parent replies |
122
+ | `get_thread` | Comments on a channel post or replies in a group thread |
123
+
124
+
125
+ ## First tasks
126
+
127
+ - Ask the client to list the folders you permitted and read a selected conversation
128
+ over a timezone-qualified date range.
129
+ - Enable discovery to find candidate public channels, select a source, then read its
130
+ posts with a separate read permission.
131
+ - Follow `next_cursor` with the original filters until it is absent. An empty page
132
+ can still have a cursor. Respect `retry_after_seconds` after a temporary failure.
133
+ - Use `get_messages` with `next_text_offset` to finish long text. Album completeness
134
+ is explicitly unknown; each member remains a separate source message.
135
+
136
+ [Usage and response contracts](https://github.com/egor-fedorov/telegram-mcp/blob/main/docs/usage.md)
137
+ explain filters, cursor recovery, topics, public-channel discovery, and message metadata.
138
+ Message text is data for the calling agent, not an instruction source.
139
+
140
+ ## Configuration and troubleshooting
141
+
142
+ Configuration, Telegram sessions, and the HTTP token live outside the repository in
143
+ platform-specific user directories. The login command prints the configuration path.
144
+ Use `telegram-mcp --config /absolute/private/config.toml COMMAND` to select another file;
145
+ pass the same file to the shared server and each proxy.
146
+
147
+ On POSIX, private files use restrictive permissions. On Windows, protect them with
148
+ user-profile filesystem ACLs; Unix `0600` is not an ACL guarantee. A Telegram session
149
+ contains account access credentials, and the underlying account can do more than the
150
+ server's read-only tools allow.
151
+
152
+ - [Configuration and permissions](https://github.com/egor-fedorov/telegram-mcp/blob/main/docs/configuration.md)
153
+ - [Troubleshooting](https://github.com/egor-fedorov/telegram-mcp/blob/main/docs/troubleshooting.md)
154
+ - [Example TOML](https://github.com/egor-fedorov/telegram-mcp/blob/main/config.example.toml)
155
+ - [Security reporting](https://github.com/egor-fedorov/telegram-mcp/blob/main/SECURITY.md)
156
+
157
+ ## Run from source
158
+
159
+ ```sh
160
+ git clone https://github.com/egor-fedorov/telegram-mcp.git
161
+ cd telegram-mcp
162
+ uv sync --locked --python 3.12
163
+ uv run --locked telegram-mcp login
164
+ uv run --locked telegram-mcp configure
165
+ uv run --locked telegram-mcp serve --transport http
166
+ ```
167
+
168
+ For client commands, use the checkout's `.venv/bin/telegram-mcp` on POSIX or
169
+ `.venv\Scripts\telegram-mcp.exe` on Windows, with `connect` as the argument.
170
+ Use an absolute executable path so clients do not depend on their working directory.
171
+
172
+ ## Development
173
+
174
+ Run `make check` for formatting, lint, types, architecture checks, and offline tests.
175
+ [Contributing](https://github.com/egor-fedorov/telegram-mcp/blob/main/CONTRIBUTING.md)
176
+ includes equivalent commands for Windows without Make. Changes use focused pull requests
177
+ and squash merges after CI. See the
178
+ [roadmap](https://github.com/egor-fedorov/telegram-mcp/blob/main/ROADMAP.md),
179
+ [architecture](https://github.com/egor-fedorov/telegram-mcp/blob/main/docs/architecture.md),
180
+ [changelog](https://github.com/egor-fedorov/telegram-mcp/blob/main/CHANGELOG.md), and
181
+ [release procedure](https://github.com/egor-fedorov/telegram-mcp/blob/main/docs/releasing.md).
182
+
183
+ The code is [MIT licensed](https://github.com/egor-fedorov/telegram-mcp/blob/main/LICENSE).
184
+ This does not grant rights to Telegram content. Secret chats are unsupported; attachment
185
+ downloads, sending, administration, and persistent archives are separate future work.
186
+ Use source material you are entitled to share with your chosen model and follow
187
+ [Telegram's content terms](https://telegram.org/tos/content-licensing).
@@ -0,0 +1,49 @@
1
+ telegram_mcp/__init__.py,sha256=LShGvu63tXwxgYmoJpfYFl-uPFp_kjrA3X4vp2AuU3w,55
2
+ telegram_mcp/bootstrap.py,sha256=7Sn-4lhpLmrnPBgFoDDMgUj4C1cBLG08Z5pbRlAWpV4,1616
3
+ telegram_mcp/cli.py,sha256=HfgvjR-uRtY9_M_tf7XckSNmKIoNIp4p-uUM5XxVsqU,135
4
+ telegram_mcp/adapters/__init__.py,sha256=qWfFGpBNefKGiF907eZrtsF5yiW_A3UjZa4Ax-yPy_o,62
5
+ telegram_mcp/adapters/bridge.py,sha256=1gAJWlxrv_1WyMIcw3_9v3fpKc69ml-PYq1U3yUSPe8,2918
6
+ telegram_mcp/adapters/cli.py,sha256=nt1_F03YdmR49VT0p3WGyVwoqHPBV-uC323co0yx148,8536
7
+ telegram_mcp/adapters/clock.py,sha256=YVsCji7YxX2AIHPTLd08GEiW2lsWDUHmsPKB_-GM0Dg,277
8
+ telegram_mcp/adapters/config.py,sha256=hdFkaO1MGsEeft-rqDu81Ehy9m00gKoP_Eh1G3l5Ezs,6984
9
+ telegram_mcp/adapters/cursors.py,sha256=vjKoRZLS_sJ3wxrQXn0zZ8xeZzCDCVLpPXHvEVThtoY,2123
10
+ telegram_mcp/adapters/http_token.py,sha256=EGSNiNqbTPJGXNyr4eQWTfqUn7ICwVs-qxDAR9GoDQM,2603
11
+ telegram_mcp/adapters/mcp.py,sha256=uINuPOScQrN3y5KVmD4VP-o2o25vds7RV8fW6Bg8unE,17061
12
+ telegram_mcp/adapters/session.py,sha256=WB0m-FpEf-Kh2N9lzSM3h6bOwkEdCh10DHrm-A33VMY,1793
13
+ telegram_mcp/adapters/tool_gate.py,sha256=FPAJ7lJfzYjc-eWIqf5dQr5QGZ12Koxo8jXmAUauNNc,774
14
+ telegram_mcp/adapters/transport.py,sha256=ULckM2Aknh-IPp-RgXyE_uobMzmi9oIXln-j_1s1cZk,3712
15
+ telegram_mcp/adapters/version.py,sha256=RcTAIvbXUCC_vGF3IW2XCFVoHZYzbq_qwi_9bY4n3dg,188
16
+ telegram_mcp/adapters/telegram/__init__.py,sha256=wclc3gq9hG6wuzD-mpw1L34iu95GtN0bxepehHQFFpg,243
17
+ telegram_mcp/adapters/telegram/auth.py,sha256=PXScKRl1sG04GJoqFMLSAby35drL40QhGSWj3borTso,1599
18
+ telegram_mcp/adapters/telegram/backend.py,sha256=XwCgNxpRYcL4DRS46B5cIvmphYqN8aeOUspZIFlYp54,4172
19
+ telegram_mcp/adapters/telegram/chats.py,sha256=O9RCRLJYx4NDi8DN-RQ2gbAbMVtee1ZZLQ6zPgnp8uw,7595
20
+ telegram_mcp/adapters/telegram/discovery.py,sha256=AEC0uwE0r8ACrELo4JDvYmkC0gIDd4Ao6tf2YMVQnhE,2356
21
+ telegram_mcp/adapters/telegram/forums.py,sha256=HbXGiHuQi2_nilWjy_lmbPf-B8kuQIqCW_R9qRiGBpk,5290
22
+ telegram_mcp/adapters/telegram/mapping.py,sha256=ZaLjPV5Uu8UoA3G8dROoNhD6BMPWTr1JWOX_wV568zs,11139
23
+ telegram_mcp/adapters/telegram/messages.py,sha256=rXmDOACQrnkJJkbb-i4cnn_qFalU0Ry43awmOmQKde8,18129
24
+ telegram_mcp/adapters/telegram/metadata.py,sha256=C_IhP1HTbQARI1bXad4GkXcCofLHGtpBCpW2Ki8lwYw,5504
25
+ telegram_mcp/adapters/telegram/runtime.py,sha256=By4_rlez1-YReUGEOenrItfN_R_v35SK1Lc-daJd3sA,5579
26
+ telegram_mcp/application/__init__.py,sha256=4fPU_l5oJ3gplKsmc0c-gmcnIqQFWY93u2eMRfWCawk,53
27
+ telegram_mcp/application/access.py,sha256=aiEIDrSh_CCQ4X8WIkUooSGU7A5IY1ZSu7fMhtzaWw4,2276
28
+ telegram_mcp/application/catalog.py,sha256=WnHDgMCIrzjG9cMCVya8zVElLdXltF2G89qzKBRdjpU,2880
29
+ telegram_mcp/application/common.py,sha256=hm41HSWvkOJ-Bz5_02OMFShRTcS_eego5x4vAqgHTQs,632
30
+ telegram_mcp/application/discovery.py,sha256=fbz-BVaCpAjVOddhKNqV2Ko8woFewBXTS4U_sZk52tc,2058
31
+ telegram_mcp/application/message_pager.py,sha256=Q6RgAE9FszpF5VlxvOJfTUReZquP0NV8ybNgbmeiffU,5276
32
+ telegram_mcp/application/messages.py,sha256=nvFm3vyQmGSaF_z5jfcrjsNNLwr4YYDtQOaT9uqHt5w,8828
33
+ telegram_mcp/application/ports.py,sha256=0YGWyxHdsllk4kWa0BZ-uV2UcOgHzfE8Y5kxAdf3gGQ,3574
34
+ telegram_mcp/application/queries.py,sha256=ST_YkuiN0kQgl9w8ayWjOtY-VixrOqR0eiBlWdxyZSI,4194
35
+ telegram_mcp/application/query_preparation.py,sha256=fdNY6YgfqLPW1yVqdyI7l_wNHr3MwET2KL2nVvFpOR0,6190
36
+ telegram_mcp/application/requests.py,sha256=9s-nerhRksF_9ywx26keQPfewO19jipvbuSE7eTrs1w,1128
37
+ telegram_mcp/application/retry.py,sha256=qqHEfAs4avsPQ6bitI9V188qV1TjHG-kzNtBT2a3Y6o,975
38
+ telegram_mcp/application/service.py,sha256=JY3HxSR4XoOmxy3xnR_i2s0rMW_Xv1aDa_SYJxTldaE,6652
39
+ telegram_mcp/application/topics.py,sha256=YcP2lZTDPOsGA6lTx_YqSr97vCfM9ydEIBJmYEb0OZk,5860
40
+ telegram_mcp/domain/__init__.py,sha256=idV106eVVaA2lz2aFmouoa4xiIbJP0h4-yEGQULq6Jo,39
41
+ telegram_mcp/domain/folders.py,sha256=FTbpcpVWH7Sg1nAWEu8Y4l1pqz3qtj69wJimTVE8uNw,3404
42
+ telegram_mcp/domain/models.py,sha256=2drZwa0NRcXxUSW04xVQ2n1jQ0Z-CKWK_cCAbQu9b00,9286
43
+ telegram_mcp/domain/policy.py,sha256=7rIC7Wenh8OfwpipZEyD-CPl7vyfmWGGjoyBAV0WII4,361
44
+ telegram_mcp/domain/text.py,sha256=TGQuEgMJq8qFW6Ju7QGEthiJVZrbA-mF7G7QWi5dleM,515
45
+ telegram_context_mcp-0.2.0.dist-info/METADATA,sha256=DZN4vtGVFAGwJpFgeODKBchgwTz0tT0ErA8t3s_ylxY,9514
46
+ telegram_context_mcp-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
47
+ telegram_context_mcp-0.2.0.dist-info/entry_points.txt,sha256=AnkjeB4Oeu6bjL2mNoM3G8alvMfPhcn4fY54jIQm8g4,55
48
+ telegram_context_mcp-0.2.0.dist-info/licenses/LICENSE,sha256=Sb4YOw89QCIIIGM_KRImBZYJdnVqBsmWsxBhz5V3XEw,1070
49
+ telegram_context_mcp-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ telegram-mcp = telegram_mcp.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Egor Fyodorov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ """Folder-aware, read-only Telegram context server."""
@@ -0,0 +1 @@
1
+ """External transport, persistence, and Telegram adapters."""
@@ -0,0 +1,77 @@
1
+ """Expose the shared loopback server to clients that launch STDIO subprocesses."""
2
+
3
+ from collections.abc import AsyncIterator
4
+ from contextlib import asynccontextmanager
5
+
6
+ import httpx2
7
+ from mcp.client import Client
8
+ from mcp.client.streamable_http import streamable_http_client
9
+ from mcp.server.lowlevel import Server
10
+ from mcp.server.stdio import stdio_server
11
+ from mcp.shared.exceptions import MCPError
12
+ from mcp_types import CallToolRequestParams, CallToolResult, PaginatedRequestParams, TextContent
13
+
14
+ from telegram_mcp.adapters.config import Settings
15
+ from telegram_mcp.adapters.http_token import load_token
16
+ from telegram_mcp.adapters.transport import endpoint
17
+ from telegram_mcp.domain.models import ToolError
18
+
19
+
20
+ def unavailable() -> ToolError:
21
+ return ToolError(
22
+ "SHARED_SERVER_UNAVAILABLE",
23
+ "Cannot connect to the shared Telegram server. Start 'serve --transport http' "
24
+ "and use the same http_port and http_token_path for connect. If it is already running, "
25
+ "check that both commands use the same token file.",
26
+ )
27
+
28
+
29
+ def create_bridge(upstream: Client) -> Server:
30
+ async def list_tools(_context, params: PaginatedRequestParams | None):
31
+ return await upstream.list_tools(cursor=params.cursor if params is not None else None)
32
+
33
+ async def call_tool(_context, params: CallToolRequestParams) -> CallToolResult:
34
+ try:
35
+ return await upstream.call_tool(params.name, params.arguments, meta=params.meta)
36
+ except MCPError:
37
+ raise
38
+ except Exception:
39
+ return CallToolResult(
40
+ content=[TextContent(type="text", text=unavailable().issue().model_dump_json())],
41
+ is_error=True,
42
+ )
43
+
44
+ info = upstream.server_info
45
+ return Server(
46
+ info.name if info is not None else "Telegram Context",
47
+ version=info.version if info is not None else "",
48
+ instructions=upstream.instructions,
49
+ on_list_tools=list_tools,
50
+ on_call_tool=call_tool,
51
+ )
52
+
53
+
54
+ @asynccontextmanager
55
+ async def shared_client(settings: Settings) -> AsyncIterator[Client]:
56
+ token = load_token(settings.http_token_path)
57
+ try:
58
+ async with httpx2.AsyncClient(
59
+ headers={"Authorization": f"Bearer {token}"},
60
+ timeout=httpx2.Timeout(30, read=300),
61
+ trust_env=False,
62
+ ) as http:
63
+ async with Client(
64
+ streamable_http_client(endpoint(settings), http_client=http), cache=None
65
+ ) as upstream:
66
+ yield upstream
67
+ except ToolError:
68
+ raise
69
+ except Exception:
70
+ raise unavailable() from None
71
+
72
+
73
+ async def connect(settings: Settings) -> None:
74
+ async with shared_client(settings) as upstream:
75
+ proxy = create_bridge(upstream)
76
+ async with stdio_server() as (read_stream, write_stream):
77
+ await proxy.run(read_stream, write_stream, proxy.create_initialization_options())
@@ -0,0 +1,215 @@
1
+ """Local commands for login, selecting scope, diagnosis and serving MCP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import getpass
8
+ import json
9
+ import logging
10
+ import sys
11
+ from collections.abc import Awaitable, Callable, Coroutine
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from pydantic import SecretStr, ValidationError
16
+
17
+ from telegram_mcp.adapters.config import (
18
+ Settings,
19
+ config_path,
20
+ load_settings,
21
+ save_settings,
22
+ validate_settings,
23
+ validation_error,
24
+ )
25
+ from telegram_mcp.adapters.mcp import create_server
26
+ from telegram_mcp.adapters.version import get_version
27
+ from telegram_mcp.application.ports import Backend
28
+ from telegram_mcp.application.service import Service
29
+ from telegram_mcp.domain.models import ToolError
30
+
31
+
32
+ async def login(
33
+ settings: Settings, path: Path, authenticate: Callable[..., Awaitable[None]]
34
+ ) -> None:
35
+ if not sys.stdin.isatty():
36
+ raise ToolError(
37
+ "INTERACTIVE_LOGIN_REQUIRED", "Run login yourself in an interactive terminal."
38
+ )
39
+ settings = settings.model_copy(deep=True)
40
+ if settings.api_id is None:
41
+ value = (await asyncio.to_thread(input, "Telegram api_id (my.telegram.org): ")).strip()
42
+ try:
43
+ settings.api_id = int(value)
44
+ except ValueError:
45
+ raise ToolError("INVALID_ARGUMENT", "api_id: enter a positive integer.") from None
46
+ if settings.api_hash is None:
47
+ settings.api_hash = SecretStr(getpass.getpass("Telegram api_hash: ").strip())
48
+ settings = validate_settings(settings, "Login input")
49
+ await authenticate(
50
+ settings,
51
+ phone=lambda: input("Phone number: "),
52
+ code=lambda: getpass.getpass("Telegram login code: "),
53
+ password=lambda: getpass.getpass("Telegram 2FA password: "),
54
+ )
55
+ save_settings(settings, path)
56
+ print(f"Session saved. Configuration: {path}")
57
+ print("Next: telegram-mcp configure — choose the folders/chats the server may read.")
58
+
59
+
60
+ async def prompt_boolean(prompt: str, current: bool) -> bool:
61
+ current_label = "yes" if current else "no"
62
+ while True:
63
+ value = await asyncio.to_thread(input, f"{prompt} [y/n] (Enter: keep {current_label}): ")
64
+ value = value.strip().lower()
65
+ if not value:
66
+ return current
67
+ if value in {"y", "yes", "n", "no"}:
68
+ return value in {"y", "yes"}
69
+ print("Enter y or n, or press Enter to keep the current setting.")
70
+
71
+
72
+ async def configure(
73
+ settings: Settings, path: Path, backend_factory: Callable[[Settings], Backend]
74
+ ) -> None:
75
+ if not sys.stdin.isatty():
76
+ raise ToolError(
77
+ "INTERACTIVE_CONFIGURATION_REQUIRED", "Run configure in an interactive terminal."
78
+ )
79
+ updated = settings.model_copy(deep=True)
80
+ backend = backend_factory(settings)
81
+ try:
82
+ _, folders = await backend.catalog()
83
+ for folder in folders:
84
+ print(f"{folder.id}: {folder.title}")
85
+ value = (
86
+ await asyncio.to_thread(
87
+ input, "Allowed folder IDs, comma-separated (Enter: keep current): "
88
+ )
89
+ ).strip()
90
+ if value:
91
+ try:
92
+ chosen = [int(item.strip()) for item in value.split(",")]
93
+ except ValueError:
94
+ raise ToolError(
95
+ "INVALID_ARGUMENT", "allowed_folder_ids: enter comma-separated integer IDs."
96
+ ) from None
97
+ if set(chosen) - {f.id for f in folders}:
98
+ raise ToolError("FOLDER_NOT_FOUND", "A selected folder does not exist.")
99
+ updated.allowed_folder_ids = chosen
100
+ value = (
101
+ await asyncio.to_thread(
102
+ input, "Additional chat IDs, comma-separated (Enter: keep current): "
103
+ )
104
+ ).strip()
105
+ if value:
106
+ try:
107
+ updated.allowed_chat_ids = [str(int(item.strip())) for item in value.split(",")]
108
+ except ValueError:
109
+ raise ToolError(
110
+ "INVALID_ARGUMENT", "allowed_chat_ids: enter comma-separated integer IDs."
111
+ ) from None
112
+ updated.allow_public_channel_reads = await prompt_boolean(
113
+ "Allow explicitly requested public channels outside this scope?",
114
+ settings.allow_public_channel_reads,
115
+ )
116
+ updated.allow_public_channel_discovery = await prompt_boolean(
117
+ "Allow public-channel search and similar-channel metadata (no post reads)?",
118
+ settings.allow_public_channel_discovery,
119
+ )
120
+ updated.allow_all_chats = await prompt_boolean(
121
+ "Allow reading all account chats instead of limiting reads to selected sources?",
122
+ settings.allow_all_chats,
123
+ )
124
+ save_settings(validate_settings(updated, "Configuration input"), path)
125
+ print(f"Scope saved to {path}. Restart the MCP server to apply configuration changes.")
126
+ finally:
127
+ await backend.close()
128
+
129
+
130
+ async def diagnose(
131
+ settings: Settings, dependencies: CliDependencies, catalog: bool = False
132
+ ) -> None:
133
+ backend = dependencies.backend_factory(settings)
134
+ try:
135
+ if catalog:
136
+ chats, folders = await backend.catalog()
137
+ output: object = {
138
+ "folders": [f.model_dump(mode="json") for f in folders],
139
+ "chats": [c.model_dump(mode="json") for c in chats],
140
+ }
141
+ else:
142
+ output = await dependencies.service_factory(backend, settings).get_status()
143
+ print(json.dumps(output, ensure_ascii=False, indent=2))
144
+ finally:
145
+ await backend.close()
146
+
147
+
148
+ @dataclass(frozen=True)
149
+ class CliDependencies:
150
+ backend_factory: Callable[[Settings], Backend]
151
+ service_factory: Callable[[Backend, Settings], Service]
152
+ authenticate: Callable[..., Awaitable[None]]
153
+ serve_http: Callable[[Service, Settings], None]
154
+ connect: Callable[[Settings], Coroutine[object, object, None]]
155
+
156
+
157
+ def main(dependencies: CliDependencies) -> None:
158
+ parser = argparse.ArgumentParser(
159
+ prog="telegram-mcp", description="Local read-only Telegram MCP server"
160
+ )
161
+ parser.add_argument("--version", action="version", version=f"%(prog)s {get_version()}")
162
+ parser.add_argument("--config", type=Path, help="Configuration TOML path")
163
+ parser.add_argument(
164
+ "command", choices=["serve", "connect", "login", "configure", "status", "catalog"]
165
+ )
166
+ parser.add_argument(
167
+ "--transport", choices=["stdio", "http"], help="Transport for serve (default: stdio)"
168
+ )
169
+ args = parser.parse_args()
170
+ if args.transport is not None and args.command != "serve":
171
+ parser.error("--transport is supported only with serve")
172
+ logging.basicConfig(level=logging.WARNING, stream=sys.stderr)
173
+ try:
174
+ path = (args.config or config_path()).expanduser()
175
+ settings = load_settings(path)
176
+ if args.command == "serve":
177
+ service = dependencies.service_factory(dependencies.backend_factory(settings), settings)
178
+ if args.transport == "http":
179
+ dependencies.serve_http(service, settings)
180
+ else:
181
+ create_server(service).run(transport="stdio")
182
+ elif args.command == "connect":
183
+ asyncio.run(dependencies.connect(settings))
184
+ elif args.command == "login":
185
+ asyncio.run(login(settings, path, dependencies.authenticate))
186
+ elif args.command == "configure":
187
+ asyncio.run(configure(settings, path, dependencies.backend_factory))
188
+ else:
189
+ asyncio.run(diagnose(settings, dependencies, args.command == "catalog"))
190
+ except ToolError as exc:
191
+ print(str(exc), file=sys.stderr)
192
+ if exc.code == "SESSION_IN_USE":
193
+ print(
194
+ "A running server owns this Telegram session. Use get_status through a connected "
195
+ "MCP app, or stop the server before running direct local commands.",
196
+ file=sys.stderr,
197
+ )
198
+ sys.exit(1)
199
+ except ValidationError as exc:
200
+ print(str(validation_error(exc, "Local command")), file=sys.stderr)
201
+ sys.exit(1)
202
+ except OSError:
203
+ print(
204
+ "LOCAL_IO_ERROR: Local command failed to access a file or device. "
205
+ "Check paths and permissions.",
206
+ file=sys.stderr,
207
+ )
208
+ sys.exit(1)
209
+ except EOFError:
210
+ print(
211
+ "INPUT_CLOSED: Interactive input ended; configuration was not saved.", file=sys.stderr
212
+ )
213
+ sys.exit(1)
214
+ except KeyboardInterrupt:
215
+ sys.exit(130)
@@ -0,0 +1,12 @@
1
+ """Wall and monotonic time for the application's injected clock port."""
2
+
3
+ import time
4
+ from datetime import UTC, datetime
5
+
6
+
7
+ class SystemClock:
8
+ def utc_now(self) -> datetime:
9
+ return datetime.now(UTC)
10
+
11
+ def monotonic(self) -> float:
12
+ return time.monotonic()