@overpod/mcp-telegram 1.26.1 → 1.27.0

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.
@@ -0,0 +1,12 @@
1
+ /** Thin IPC proxy: forwards tool calls to the master process over Unix socket */
2
+ export declare class IpcClient {
3
+ private socket;
4
+ private pending;
5
+ private buf;
6
+ private connected;
7
+ connect(): Promise<boolean>;
8
+ isConnected(): boolean;
9
+ call(tool: string, args: Record<string, unknown>): Promise<unknown>;
10
+ destroy(): void;
11
+ }
12
+ export declare function runClient(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/client.js ADDED
@@ -0,0 +1,136 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { connect } from "node:net";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { encodeMessage, parseMessages } from "./ipc-protocol.js";
6
+ import { socketPath } from "./lock.js";
7
+ import { TelegramService } from "./telegram-client.js";
8
+ import { registerTools } from "./tools/index.js";
9
+ const CONNECT_TIMEOUT_MS = 5_000;
10
+ const IPC_CALL_TIMEOUT_MS = 30_000;
11
+ const MAX_RECONNECT_ATTEMPTS = 5;
12
+ /** Thin IPC proxy: forwards tool calls to the master process over Unix socket */
13
+ export class IpcClient {
14
+ socket = null;
15
+ pending = new Map();
16
+ buf = "";
17
+ connected = false;
18
+ async connect() {
19
+ return new Promise((resolve) => {
20
+ const sock = socketPath();
21
+ const s = connect(sock);
22
+ // One-shot connect timeout — cleared immediately on connect (HIGH-3)
23
+ const connectTimer = setTimeout(() => {
24
+ s.destroy();
25
+ resolve(false);
26
+ }, CONNECT_TIMEOUT_MS);
27
+ const onConnect = () => {
28
+ clearTimeout(connectTimer);
29
+ this.socket = s;
30
+ this.connected = true;
31
+ s.removeListener("error", onError);
32
+ s.on("data", (chunk) => {
33
+ this.buf += chunk.toString("utf-8");
34
+ const { messages, remaining } = parseMessages(this.buf);
35
+ this.buf = remaining;
36
+ for (const msg of messages) {
37
+ const res = msg;
38
+ const pending = this.pending.get(res.id);
39
+ if (!pending)
40
+ continue;
41
+ clearTimeout(pending.timer);
42
+ this.pending.delete(res.id);
43
+ if (res.error) {
44
+ pending.reject(new Error(res.error));
45
+ }
46
+ else {
47
+ pending.resolve(res.result);
48
+ }
49
+ }
50
+ });
51
+ s.on("close", () => {
52
+ this.connected = false;
53
+ for (const [, p] of this.pending) {
54
+ clearTimeout(p.timer);
55
+ p.reject(new Error("IPC connection closed"));
56
+ }
57
+ this.pending.clear();
58
+ });
59
+ s.on("error", () => { });
60
+ resolve(true);
61
+ };
62
+ const onError = () => {
63
+ clearTimeout(connectTimer);
64
+ resolve(false);
65
+ };
66
+ s.once("connect", onConnect);
67
+ s.once("error", onError);
68
+ });
69
+ }
70
+ isConnected() {
71
+ return this.connected;
72
+ }
73
+ async call(tool, args) {
74
+ if (!this.socket || !this.connected) {
75
+ throw new Error("IPC client not connected");
76
+ }
77
+ const id = randomUUID();
78
+ const socket = this.socket;
79
+ return new Promise((resolve, reject) => {
80
+ const timer = setTimeout(() => {
81
+ this.pending.delete(id);
82
+ reject(new Error(`IPC call timeout: ${tool}`));
83
+ }, IPC_CALL_TIMEOUT_MS);
84
+ this.pending.set(id, { resolve, reject, timer });
85
+ socket.write(encodeMessage({ id, tool, args }));
86
+ });
87
+ }
88
+ destroy() {
89
+ for (const [, p] of this.pending) {
90
+ clearTimeout(p.timer);
91
+ p.reject(new Error("IPC client destroyed"));
92
+ }
93
+ this.pending.clear();
94
+ this.socket?.destroy();
95
+ this.socket = null;
96
+ this.connected = false;
97
+ }
98
+ }
99
+ function wireIpcProxies(server, ipc) {
100
+ const s = server;
101
+ for (const [name, tool] of Object.entries(s._registeredTools)) {
102
+ Object.assign(tool, {
103
+ handler: (args) => ipc.call(name, args),
104
+ });
105
+ }
106
+ }
107
+ export async function runClient(apiId, apiHash, version) {
108
+ // Try to connect to master with retries — master may still be initializing its socket
109
+ let ipc = null;
110
+ for (let attempt = 0; attempt < MAX_RECONNECT_ATTEMPTS; attempt++) {
111
+ const candidate = new IpcClient();
112
+ if (await candidate.connect()) {
113
+ ipc = candidate;
114
+ break;
115
+ }
116
+ if (attempt < MAX_RECONNECT_ATTEMPTS - 1) {
117
+ await new Promise((r) => setTimeout(r, 150 * 2 ** attempt)); // 150ms, 300ms, 600ms, 1200ms
118
+ }
119
+ }
120
+ if (!ipc) {
121
+ // Master acquired lock but socket not ready — this process should not become master
122
+ // (it lost the lock race). Exit with clear message instead of creating two masters (CRITICAL-2)
123
+ console.error("[mcp-telegram] Cannot connect to master process. Try again in a moment.");
124
+ process.exit(1);
125
+ }
126
+ console.error(`[mcp-telegram] Client mode — proxying to master via ${socketPath()}`);
127
+ // Register all tools for MCP schema; dummy telegram instance is never used for actual calls
128
+ const telegram = new TelegramService(apiId, apiHash);
129
+ const server = new McpServer({ name: "mcp-telegram", version });
130
+ registerTools(server, telegram);
131
+ // Replace all handlers with IPC-forwarding versions
132
+ wireIpcProxies(server, ipc);
133
+ const transport = new StdioServerTransport();
134
+ await server.connect(transport);
135
+ console.error("[mcp-telegram] MCP server running on stdio (client)");
136
+ }
package/dist/index.js CHANGED
@@ -1,16 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  // Redirect console.log to stderr BEFORE any imports.
3
3
  // GramJS Logger uses console.log (stdout) which corrupts MCP JSON-RPC stream.
4
- const _origLog = console.log;
5
4
  console.log = (...args) => {
6
5
  console.error(...args);
7
6
  };
8
7
  import "dotenv/config";
9
8
  import { createRequire } from "node:module";
10
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
12
- import { TelegramService } from "./telegram-client.js";
13
- import { registerTools } from "./tools/index.js";
9
+ import { tryAcquireLock } from "./lock.js";
14
10
  const require = createRequire(import.meta.url);
15
11
  const { version } = require("../package.json");
16
12
  // Telegram API credentials from env
@@ -22,26 +18,18 @@ if (!API_ID || !API_HASH) {
22
18
  console.error("Set them in .env or export as environment variables");
23
19
  process.exit(1);
24
20
  }
25
- const telegram = new TelegramService(API_ID, API_HASH);
26
- const server = new McpServer({
27
- name: "mcp-telegram",
28
- version,
29
- });
30
- registerTools(server, telegram);
31
21
  async function main() {
32
- const transport = new StdioServerTransport();
33
- await server.connect(transport);
34
- console.error("[mcp-telegram] MCP server running on stdio");
35
- // Auto-connect with saved session after MCP is ready (non-blocking)
36
- telegram.loadSession().then(async () => {
37
- if (await telegram.connect()) {
38
- const me = await telegram.getMe();
39
- console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
40
- }
41
- else if (telegram.lastError) {
42
- console.error(`[mcp-telegram] ${telegram.lastError}`);
43
- }
44
- });
22
+ const isMaster = tryAcquireLock();
23
+ if (isMaster) {
24
+ console.error("[mcp-telegram] Starting as master process");
25
+ const { runMaster } = await import("./master.js");
26
+ await runMaster(API_ID, API_HASH, version);
27
+ }
28
+ else {
29
+ console.error("[mcp-telegram] Starting as client process (master already running)");
30
+ const { runClient } = await import("./client.js");
31
+ await runClient(API_ID, API_HASH, version);
32
+ }
45
33
  }
46
34
  main().catch((err) => {
47
35
  console.error("[mcp-telegram] Fatal:", err);
@@ -0,0 +1,19 @@
1
+ /** Request from Client → Master */
2
+ export interface IpcRequest {
3
+ id: string;
4
+ tool: string;
5
+ args: Record<string, unknown>;
6
+ }
7
+ /** Response from Master → Client */
8
+ export interface IpcResponse {
9
+ id: string;
10
+ result?: unknown;
11
+ error?: string;
12
+ }
13
+ /** Encode a message as newline-delimited JSON */
14
+ export declare function encodeMessage(msg: IpcRequest | IpcResponse): string;
15
+ /** Parse newline-delimited JSON messages from a buffer, returns parsed messages + leftover */
16
+ export declare function parseMessages(buf: string): {
17
+ messages: (IpcRequest | IpcResponse)[];
18
+ remaining: string;
19
+ };
@@ -0,0 +1,22 @@
1
+ /** Encode a message as newline-delimited JSON */
2
+ export function encodeMessage(msg) {
3
+ return `${JSON.stringify(msg)}\n`;
4
+ }
5
+ /** Parse newline-delimited JSON messages from a buffer, returns parsed messages + leftover */
6
+ export function parseMessages(buf) {
7
+ const lines = buf.split("\n");
8
+ const remaining = lines.pop() ?? "";
9
+ const messages = [];
10
+ for (const line of lines) {
11
+ const trimmed = line.trim();
12
+ if (!trimmed)
13
+ continue;
14
+ try {
15
+ messages.push(JSON.parse(trimmed));
16
+ }
17
+ catch {
18
+ // Skip malformed lines
19
+ }
20
+ }
21
+ return { messages, remaining };
22
+ }
package/dist/lock.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ export declare function lockPath(): string;
2
+ export declare function socketPath(): string;
3
+ /**
4
+ * Try to acquire the master lock.
5
+ * Returns true if this process is now the master.
6
+ * Returns false if another live master process holds the lock.
7
+ *
8
+ * Uses PID file + kill -0 to detect stale locks after crashes.
9
+ */
10
+ export declare function tryAcquireLock(): boolean;
11
+ export declare function releaseLock(): void;
12
+ export declare function releaseSocket(): void;
package/dist/lock.js ADDED
@@ -0,0 +1,83 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ const DEFAULT_SESSION_DIR = join(homedir(), ".mcp-telegram");
5
+ function resolveSessionDir() {
6
+ const sessionPath = process.env.TELEGRAM_SESSION_PATH;
7
+ if (sessionPath)
8
+ return dirname(sessionPath);
9
+ return DEFAULT_SESSION_DIR;
10
+ }
11
+ export function lockPath() {
12
+ return join(resolveSessionDir(), "daemon.lock");
13
+ }
14
+ export function socketPath() {
15
+ return join(resolveSessionDir(), "daemon.sock");
16
+ }
17
+ /**
18
+ * Try to acquire the master lock.
19
+ * Returns true if this process is now the master.
20
+ * Returns false if another live master process holds the lock.
21
+ *
22
+ * Uses PID file + kill -0 to detect stale locks after crashes.
23
+ */
24
+ export function tryAcquireLock() {
25
+ const lock = lockPath();
26
+ const dir = dirname(lock);
27
+ if (!existsSync(dir)) {
28
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
29
+ }
30
+ if (existsSync(lock)) {
31
+ try {
32
+ const pid = Number.parseInt(readFileSync(lock, "utf-8").trim(), 10);
33
+ if (!Number.isNaN(pid) && pid > 0) {
34
+ try {
35
+ // kill -0: check if process is alive without sending a signal
36
+ process.kill(pid, 0);
37
+ // Process is alive — another master owns the lock
38
+ return false;
39
+ }
40
+ catch {
41
+ // ESRCH: process not found — stale lock, take over
42
+ unlinkSync(lock);
43
+ }
44
+ }
45
+ }
46
+ catch {
47
+ // Unreadable lock — remove and take over
48
+ try {
49
+ unlinkSync(lock);
50
+ }
51
+ catch { }
52
+ }
53
+ }
54
+ try {
55
+ // O_EXCL flag: atomic exclusive create — prevents TOCTOU race between two simultaneous starts
56
+ writeFileSync(lock, String(process.pid), { flag: "wx", mode: 0o600 });
57
+ return true;
58
+ }
59
+ catch {
60
+ // EEXIST: another process just created the lock — we lost the race, become client
61
+ return false;
62
+ }
63
+ }
64
+ export function releaseLock() {
65
+ try {
66
+ const lock = lockPath();
67
+ if (existsSync(lock)) {
68
+ const pid = Number.parseInt(readFileSync(lock, "utf-8").trim(), 10);
69
+ // Only remove our own lock
70
+ if (pid === process.pid)
71
+ unlinkSync(lock);
72
+ }
73
+ }
74
+ catch { }
75
+ }
76
+ export function releaseSocket() {
77
+ try {
78
+ const sock = socketPath();
79
+ if (existsSync(sock))
80
+ unlinkSync(sock);
81
+ }
82
+ catch { }
83
+ }
@@ -0,0 +1 @@
1
+ export declare function runMaster(apiId: number, apiHash: string, version: string): Promise<void>;
package/dist/master.js ADDED
@@ -0,0 +1,105 @@
1
+ import { createServer } from "node:net";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { encodeMessage, parseMessages } from "./ipc-protocol.js";
5
+ import { releaseLock, releaseSocket, socketPath } from "./lock.js";
6
+ import { TelegramService } from "./telegram-client.js";
7
+ import { registerTools } from "./tools/index.js";
8
+ let socketServer = null;
9
+ let cleanedUp = false;
10
+ function cleanup() {
11
+ if (cleanedUp)
12
+ return;
13
+ cleanedUp = true;
14
+ releaseLock();
15
+ releaseSocket();
16
+ socketServer?.close();
17
+ }
18
+ process.on("exit", cleanup);
19
+ process.on("SIGINT", () => process.exit(0));
20
+ process.on("SIGTERM", () => process.exit(0));
21
+ function handleClient(socket, mcpServer) {
22
+ let buf = "";
23
+ // Processing queue — ensures sequential handling even when handler awaits
24
+ let processing = false;
25
+ const queue = [];
26
+ async function drainQueue() {
27
+ if (processing)
28
+ return;
29
+ processing = true;
30
+ while (queue.length > 0) {
31
+ const req = queue.shift();
32
+ if (!req)
33
+ break;
34
+ const tool = mcpServer._registeredTools[req.tool];
35
+ const response = { id: req.id };
36
+ if (!tool) {
37
+ response.error = `Unknown tool: ${req.tool}`;
38
+ }
39
+ else {
40
+ try {
41
+ response.result = await tool.handler(req.args ?? {}, {});
42
+ }
43
+ catch (err) {
44
+ response.error = err instanceof Error ? err.message : String(err);
45
+ }
46
+ }
47
+ if (!socket.destroyed) {
48
+ socket.write(encodeMessage(response));
49
+ }
50
+ }
51
+ processing = false;
52
+ }
53
+ socket.on("data", (chunk) => {
54
+ buf += chunk.toString("utf-8");
55
+ const { messages, remaining } = parseMessages(buf);
56
+ buf = remaining;
57
+ for (const msg of messages) {
58
+ const req = msg;
59
+ if (!req.id || !req.tool)
60
+ continue;
61
+ queue.push(req);
62
+ }
63
+ drainQueue();
64
+ });
65
+ socket.on("error", () => { });
66
+ }
67
+ export async function runMaster(apiId, apiHash, version) {
68
+ const telegram = new TelegramService(apiId, apiHash);
69
+ const server = new McpServer({ name: "mcp-telegram", version });
70
+ registerTools(server, telegram);
71
+ const mcpServer = server;
72
+ // Remove stale socket file from previous crash before attempting to listen (HIGH-2)
73
+ releaseSocket();
74
+ const sock = socketPath();
75
+ const srv = createServer((socket) => handleClient(socket, mcpServer));
76
+ socketServer = srv;
77
+ await new Promise((resolve, reject) => {
78
+ srv.listen(sock, resolve);
79
+ srv.once("error", reject);
80
+ });
81
+ const { chmod } = await import("node:fs/promises");
82
+ try {
83
+ await chmod(sock, 0o600);
84
+ }
85
+ catch { }
86
+ console.error(`[mcp-telegram] Master mode — IPC socket ready: ${sock}`);
87
+ const transport = new StdioServerTransport();
88
+ await server.connect(transport);
89
+ console.error("[mcp-telegram] MCP server running on stdio (master)");
90
+ // Auto-connect with saved session — catch to avoid unhandled rejection (MEDIUM-2)
91
+ telegram
92
+ .loadSession()
93
+ .then(async () => {
94
+ if (await telegram.connect()) {
95
+ const me = await telegram.getMe();
96
+ console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
97
+ }
98
+ else if (telegram.lastError) {
99
+ console.error(`[mcp-telegram] ${telegram.lastError}`);
100
+ }
101
+ })
102
+ .catch((err) => {
103
+ console.error("[mcp-telegram] Auto-connect failed:", err);
104
+ });
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.26.1",
3
+ "version": "1.27.0",
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",