@overpod/mcp-telegram 1.37.1 → 1.38.1

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.38.1](https://github.com/mcp-telegram/mcp-telegram/compare/v1.38.0...v1.38.1) (2026-06-10)
9
+
10
+
11
+ ### Fixed
12
+
13
+ * **deps:** bump transitive hono 4.12.18 → 4.12.25 (security) ([f90b32c](https://github.com/mcp-telegram/mcp-telegram/commit/f90b32c61588c92e4f83d938d6b68c83d4deb82f))
14
+
15
+
16
+ ### Documentation
17
+
18
+ * **daemon:** fix dead link to packaging/ — point at GitHub, not a relative path ([0574c6a](https://github.com/mcp-telegram/mcp-telegram/commit/0574c6a369b9c0d30311abf374c59f80462b289e))
19
+
20
+ ## [1.38.0](https://github.com/mcp-telegram/mcp-telegram/compare/v1.37.1...v1.38.0) (2026-06-10)
21
+
22
+
23
+ ### Added
24
+
25
+ * shared daemon (serve mode) for concurrent MCP clients ([#49](https://github.com/mcp-telegram/mcp-telegram/issues/49)) ([7568927](https://github.com/mcp-telegram/mcp-telegram/commit/75689270ffb21ca4f14e9e7673a36af25f36cde5))
26
+
8
27
  ## [1.37.1](https://github.com/mcp-telegram/mcp-telegram/compare/v1.37.0...v1.37.1) (2026-06-06)
9
28
 
10
29
 
package/README.md CHANGED
@@ -102,6 +102,22 @@ claude mcp add telegram-personal -s user \
102
102
 
103
103
  Each account gets its own session file — no conflicts.
104
104
 
105
+ ### Multiple agents / concurrent clients (shared daemon)
106
+
107
+ The opposite of multiple accounts: **one** account driven by **many** clients at once — several Claude Code windows, parallel sub-agents, or multiple IDEs. Normally each process opens the same session and they evict one another with `AUTH_KEY_DUPLICATED`. Serve mode fixes this.
108
+
109
+ Run a single persistent **daemon** that owns the one Telegram connection. Every other process auto-detects the daemon (via a PID lock) and becomes a thin client that proxies tool calls to it over a local Unix socket:
110
+
111
+ ```bash
112
+ # On the host, once: start the daemon (owns the connection, no stdio)
113
+ TELEGRAM_API_ID=YOUR_ID TELEGRAM_API_HASH=YOUR_HASH mcp-telegram serve
114
+ # (or set MCP_TELEGRAM_DAEMON=1 instead of the `serve` argument)
115
+ ```
116
+
117
+ Then point each MCP client at the same install with the same `TELEGRAM_SESSION_PATH` — no `serve` argument. They connect to the daemon automatically; closing any client never drops the shared connection. Credentials are only required by the daemon (the owner), so client commands can omit `TELEGRAM_API_ID`/`TELEGRAM_API_HASH` and keep them where the daemon runs.
118
+
119
+ See the **[shared daemon guide](docs/guides/shared-daemon.md)** for a systemd unit and SSH usage.
120
+
105
121
  ### Proxy Support
106
122
 
107
123
  If Telegram is blocked or you're running in a containerized environment (Docker, K3s), use a SOCKS5 or MTProxy:
package/dist/index.js CHANGED
@@ -12,23 +12,40 @@ const { version } = require("../package.json");
12
12
  // Telegram API credentials from env
13
13
  const API_ID = Number(process.env.TELEGRAM_API_ID);
14
14
  const API_HASH = process.env.TELEGRAM_API_HASH;
15
- if (!API_ID || !API_HASH) {
16
- console.error("[mcp-telegram] Missing TELEGRAM_API_ID and TELEGRAM_API_HASH");
17
- console.error("Get your credentials at https://my.telegram.org/apps (API development tools)");
18
- console.error("Set them in .env or export as environment variables");
19
- process.exit(1);
15
+ // Credentials are required only on the OWNER paths (serve/master) that open the real
16
+ // Telegram connection. A client proxies every call over IPC and never connects, so it
17
+ // runs without them — letting credentials stay only where the daemon runs.
18
+ function requireCreds() {
19
+ if (!API_ID || !API_HASH) {
20
+ console.error("[mcp-telegram] Missing TELEGRAM_API_ID and TELEGRAM_API_HASH (required to own the Telegram connection)");
21
+ console.error("Get your credentials at https://my.telegram.org/apps (API development tools)");
22
+ console.error("Set them in .env or export as environment variables");
23
+ process.exit(1);
24
+ }
20
25
  }
21
26
  async function main() {
27
+ // Persistent daemon mode: `mcp-telegram serve` (or MCP_TELEGRAM_DAEMON=1). The daemon owns
28
+ // the connection with no stdio attached; every other process becomes a client.
29
+ if (process.argv[2] === "serve" || process.env.MCP_TELEGRAM_DAEMON === "1") {
30
+ requireCreds();
31
+ console.error("[mcp-telegram] Starting in serve (daemon) mode");
32
+ const { runServe } = await import("./serve.js");
33
+ await runServe(API_ID, API_HASH, version);
34
+ return;
35
+ }
22
36
  const isMaster = tryAcquireLock();
23
37
  if (isMaster) {
38
+ requireCreds();
24
39
  console.error("[mcp-telegram] Starting as master process");
25
40
  const { runMaster } = await import("./master.js");
26
41
  await runMaster(API_ID, API_HASH, version);
27
42
  }
28
43
  else {
29
- console.error("[mcp-telegram] Starting as client process (master already running)");
44
+ // Client proxies all tool calls to the owner over IPC; the dummy TelegramService is
45
+ // never used for real calls, so API credentials are optional here.
46
+ console.error("[mcp-telegram] Starting as client process (owner already running)");
30
47
  const { runClient } = await import("./client.js");
31
- await runClient(API_ID, API_HASH, version);
48
+ await runClient(API_ID || 0, API_HASH ?? "", version);
32
49
  }
33
50
  }
34
51
  main().catch((err) => {
package/dist/lock.js CHANGED
@@ -37,8 +37,12 @@ export function tryAcquireLock() {
37
37
  // Process is alive — another master owns the lock
38
38
  return false;
39
39
  }
40
- catch {
41
- // ESRCH: process not found — stale lock, take over
40
+ catch (err) {
41
+ // ESRCH: process not found — stale lock, take over.
42
+ // EPERM: process is alive but owned by another uid we can't signal —
43
+ // treat as a live owner and DON'T steal the lock.
44
+ if (err.code === "EPERM")
45
+ return false;
42
46
  unlinkSync(lock);
43
47
  }
44
48
  }
@@ -76,8 +80,30 @@ export function releaseLock() {
76
80
  export function releaseSocket() {
77
81
  try {
78
82
  const sock = socketPath();
79
- if (existsSync(sock))
80
- unlinkSync(sock);
83
+ if (!existsSync(sock))
84
+ return;
85
+ // Ownership guard (mirrors releaseLock): never unlink a socket owned by a different,
86
+ // still-alive process. Otherwise any process that imports this module and exits (e.g. a
87
+ // one-shot run or a test on the same host) would delete a running daemon's socket file,
88
+ // leaving the daemon listening in memory but unreachable for new clients.
89
+ const lock = lockPath();
90
+ if (existsSync(lock)) {
91
+ const pid = Number.parseInt(readFileSync(lock, "utf-8").trim(), 10);
92
+ // Ignore non-positive PIDs (e.g. 0) so kill() can't probe our own process group.
93
+ if (!Number.isNaN(pid) && pid > 0 && pid !== process.pid) {
94
+ try {
95
+ process.kill(pid, 0); // foreign owner still alive?
96
+ return; // yes — leave its socket alone
97
+ }
98
+ catch (err) {
99
+ // ESRCH: stale owner — safe to remove. EPERM: owner is alive under a
100
+ // different uid (e.g. a systemd daemon) — leave its socket alone too.
101
+ if (err.code === "EPERM")
102
+ return;
103
+ }
104
+ }
105
+ }
106
+ unlinkSync(sock);
81
107
  }
82
108
  catch { }
83
109
  }
package/dist/master.d.ts CHANGED
@@ -1,5 +1,20 @@
1
- import { type Socket } from "node:net";
1
+ import { type Server, type Socket } from "node:net";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
3
  import { type McpServerInternal } from "./ipc-protocol.js";
3
4
  import { TelegramService } from "./telegram-client.js";
4
5
  export declare function handleClient(socket: Socket, mcpServer: McpServerInternal, telegram: TelegramService): void;
6
+ export interface OwnerHandle {
7
+ server: McpServer;
8
+ srv: Server;
9
+ gracefulExit: () => Promise<void>;
10
+ }
11
+ /**
12
+ * Bootstrap the connection owner shared by master (stdio) and serve (daemon) modes:
13
+ * build the tool registry, listen on the IPC socket, install a graceful shutdown that
14
+ * disconnects Telegram, and auto-connect the single client. No stdio is attached here —
15
+ * the caller decides whether to also serve a stdio MCP session (master) or not (serve).
16
+ */
17
+ export declare function startOwner(telegram: TelegramService, version: string, opts?: {
18
+ label?: string;
19
+ }): Promise<OwnerHandle>;
5
20
  export declare function runMaster(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/master.js CHANGED
@@ -17,8 +17,6 @@ function cleanup() {
17
17
  releaseSocket();
18
18
  }
19
19
  process.on("exit", cleanup);
20
- process.on("SIGINT", () => process.exit(0));
21
- process.on("SIGTERM", () => process.exit(0));
22
20
  // Serializes tool calls with QR login — login holds the lock for up to minutes,
23
21
  // tool calls queue behind it. Prevents tool calls from running against a stale
24
22
  // Telegram client mid-relogin.
@@ -126,15 +124,25 @@ async function handleLoginStart(socket, req, telegram) {
126
124
  unlock();
127
125
  }
128
126
  }
129
- export async function runMaster(apiId, apiHash, version) {
130
- const telegram = new TelegramService(apiId, apiHash);
127
+ /**
128
+ * Bootstrap the connection owner shared by master (stdio) and serve (daemon) modes:
129
+ * build the tool registry, listen on the IPC socket, install a graceful shutdown that
130
+ * disconnects Telegram, and auto-connect the single client. No stdio is attached here —
131
+ * the caller decides whether to also serve a stdio MCP session (master) or not (serve).
132
+ */
133
+ export async function startOwner(telegram, version, opts = {}) {
134
+ const label = opts.label ?? "mcp-telegram";
131
135
  const server = new McpServer({ name: "mcp-telegram", version });
132
136
  registerTools(server, telegram);
133
137
  const mcpServer = server;
134
- // Remove stale socket file from previous crash before attempting to listen (HIGH-2)
138
+ // Remove a stale socket file from a previous crash before attempting to listen.
135
139
  releaseSocket();
136
140
  const sock = socketPath();
137
- const srv = createServer((socket) => handleClient(socket, mcpServer, telegram));
141
+ const srv = createServer((socket) => {
142
+ console.error(`[${label}] client connected`);
143
+ socket.on("close", () => console.error(`[${label}] client disconnected`));
144
+ handleClient(socket, mcpServer, telegram);
145
+ });
138
146
  await new Promise((resolve, reject) => {
139
147
  srv.listen(sock, resolve);
140
148
  srv.once("error", reject);
@@ -144,27 +152,49 @@ export async function runMaster(apiId, apiHash, version) {
144
152
  await chmod(sock, 0o600);
145
153
  }
146
154
  catch { }
147
- console.error(`[mcp-telegram] Master mode — IPC socket ready: ${sock}`);
148
- // Parent (Claude Code / MCP client) can close stdio without sending a signal.
149
- // Without this, the process keeps running as an orphan with a live Telegram connection,
150
- // blocking auth_key from being reused — causes AUTH_KEY_DUPLICATED on next start.
151
- process.stdin.on("end", () => process.exit(0));
152
- const transport = new StdioServerTransport();
153
- await server.connect(transport);
154
- console.error("[mcp-telegram] MCP server running on stdio (master)");
155
- // Auto-connect with saved session — catch to avoid unhandled rejection (MEDIUM-2)
155
+ console.error(`[${label}] IPC socket ready: ${sock}`);
156
+ let shuttingDown = false;
157
+ const gracefulExit = async () => {
158
+ if (shuttingDown)
159
+ return;
160
+ shuttingDown = true;
161
+ console.error(`[${label}] Shutting down, disconnecting from Telegram...`);
162
+ try {
163
+ await telegram.disconnect();
164
+ }
165
+ catch (err) {
166
+ console.error(`[${label}] Disconnect error:`, err);
167
+ }
168
+ process.exit(0);
169
+ };
170
+ process.on("SIGINT", gracefulExit);
171
+ process.on("SIGTERM", gracefulExit);
172
+ // Auto-connect with saved session — catch to avoid unhandled rejection.
156
173
  telegram
157
174
  .loadSession()
158
175
  .then(async () => {
159
176
  if (await telegram.connect()) {
160
177
  const me = await telegram.getMe();
161
- console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
178
+ console.error(`[${label}] connected as @${me.username}`);
162
179
  }
163
180
  else if (telegram.lastError) {
164
- console.error(`[mcp-telegram] ${telegram.lastError}`);
181
+ console.error(`[${label}] ${telegram.lastError}`);
165
182
  }
166
183
  })
167
184
  .catch((err) => {
168
- console.error("[mcp-telegram] Auto-connect failed:", err);
185
+ console.error(`[${label}] Auto-connect failed:`, err);
169
186
  });
187
+ return { server, srv, gracefulExit };
188
+ }
189
+ export async function runMaster(apiId, apiHash, version) {
190
+ const telegram = new TelegramService(apiId, apiHash);
191
+ const { server, gracefulExit } = await startOwner(telegram, version, { label: "mcp-telegram (master)" });
192
+ // Parent (Claude Code / MCP client) can close stdio without sending a signal.
193
+ // Without this, the process keeps running as an orphan with a live Telegram connection,
194
+ // blocking auth_key from being reused — causes AUTH_KEY_DUPLICATED on next start.
195
+ process.stdin.on("end", gracefulExit);
196
+ // Master also serves the launching window directly over stdio.
197
+ const transport = new StdioServerTransport();
198
+ await server.connect(transport);
199
+ console.error("[mcp-telegram] MCP server running on stdio (master)");
170
200
  }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Persistent daemon mode: own the single Telegram connection and serve many concurrent
3
+ * IPC clients, with no stdio and no stdin-exit, so closing any client never tears the
4
+ * connection down. Intended to run under a supervisor (systemd, Docker) with Restart=always.
5
+ */
6
+ export declare function runServe(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/serve.js ADDED
@@ -0,0 +1,22 @@
1
+ import { tryAcquireLock } from "./lock.js";
2
+ import { startOwner } from "./master.js";
3
+ import { TelegramService } from "./telegram-client.js";
4
+ /**
5
+ * Persistent daemon mode: own the single Telegram connection and serve many concurrent
6
+ * IPC clients, with no stdio and no stdin-exit, so closing any client never tears the
7
+ * connection down. Intended to run under a supervisor (systemd, Docker) with Restart=always.
8
+ */
9
+ export async function runServe(apiId, apiHash, version) {
10
+ // The daemon must be the sole connection owner. If another owner already holds the lock,
11
+ // refuse rather than open a second client on the same session (AUTH_KEY_DUPLICATED).
12
+ if (!tryAcquireLock()) {
13
+ console.error("[serve] Another owner already holds the lock; refusing to start a second daemon.");
14
+ process.exit(1);
15
+ }
16
+ const telegram = new TelegramService(apiId, apiHash);
17
+ // Owner core: socket server + IPC dispatch + auto-connect + SIGINT/SIGTERM graceful shutdown.
18
+ // No StdioServerTransport and no process.stdin handler — the daemon's lifetime is independent
19
+ // of any client. The listening socket keeps the event loop alive until a termination signal.
20
+ await startOwner(telegram, version, { label: "serve" });
21
+ console.error("[serve] daemon ready — owning the Telegram connection, no stdio attached");
22
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.37.1",
3
+ "version": "1.38.1",
4
4
  "description": "MCP server for Telegram userbot — messages, media, reactions, polls & more. Built on GramJS/MTProto.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",