@overpod/mcp-telegram 1.17.0 → 1.19.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.
package/README.md CHANGED
@@ -138,6 +138,34 @@ cd mcp-telegram
138
138
  npm install && npm run build
139
139
  ```
140
140
 
141
+ ### Docker
142
+
143
+ ```bash
144
+ docker build -t mcp-telegram https://github.com/overpod/mcp-telegram.git
145
+ ```
146
+
147
+ Login (interactive terminal required):
148
+
149
+ ```bash
150
+ docker run -it --rm \
151
+ -e TELEGRAM_API_ID=YOUR_ID \
152
+ -e TELEGRAM_API_HASH=YOUR_HASH \
153
+ -v ~/.mcp-telegram:/root/.mcp-telegram \
154
+ --entrypoint node mcp-telegram dist/qr-login-cli.js
155
+ ```
156
+
157
+ Run the MCP server:
158
+
159
+ ```bash
160
+ docker run -i --rm \
161
+ -e TELEGRAM_API_ID=YOUR_ID \
162
+ -e TELEGRAM_API_HASH=YOUR_HASH \
163
+ -v ~/.mcp-telegram:/root/.mcp-telegram \
164
+ mcp-telegram
165
+ ```
166
+
167
+ > **Note**: Login must be done once via terminal. After that, the session is persisted in `~/.mcp-telegram` and reused automatically.
168
+
141
169
  ## Usage with MCP Clients
142
170
 
143
171
  ### Claude Code (CLI)
@@ -174,12 +202,37 @@ claude mcp add telegram -s user \
174
202
 
175
203
  3. Restart Claude Desktop.
176
204
 
177
- 4. Ask Claude: **"Run telegram-login"** -- a QR code will appear. If the image is not visible, Claude will provide a browser link to view the QR code. Scan it in Telegram (**Settings > Devices > Link Desktop Device**).
205
+ 4. Ask Claude: **"Run telegram-login"** -- a QR code will appear. If the image is not visible, it's also saved to `~/.mcp-telegram/qr-login.png`. Scan it in Telegram (**Settings > Devices > Link Desktop Device**).
178
206
 
179
207
  5. Ask Claude: **"Run telegram-status"** to verify the connection.
180
208
 
181
209
  > **Note**: No terminal required! Login works entirely through Claude Desktop.
182
210
 
211
+ ### Claude Desktop (Docker)
212
+
213
+ 1. Login via terminal first (see [Docker](#docker) section above).
214
+
215
+ 2. Add to your config file:
216
+
217
+ ```json
218
+ {
219
+ "mcpServers": {
220
+ "telegram": {
221
+ "command": "docker",
222
+ "args": [
223
+ "run", "-i", "--rm",
224
+ "-e", "TELEGRAM_API_ID=YOUR_ID",
225
+ "-e", "TELEGRAM_API_HASH=YOUR_HASH",
226
+ "-v", "~/.mcp-telegram:/root/.mcp-telegram",
227
+ "mcp-telegram"
228
+ ]
229
+ }
230
+ }
231
+ }
232
+ ```
233
+
234
+ 3. Restart Claude Desktop. Ask Claude: **"Run telegram-status"** to verify.
235
+
183
236
  ### Cursor / VS Code
184
237
 
185
238
  Add the same JSON config above to your MCP settings (Cursor Settings > MCP, or VS Code MCP config).
@@ -276,6 +329,8 @@ Then set `TELEGRAM_SESSION_PATH` in each environment's MCP config accordingly.
276
329
  - Session is stored in `~/.mcp-telegram/session` with `0600` permissions (owner-only access)
277
330
  - Session directory is created with `0700` permissions
278
331
  - Phone number is **not required** -- QR-only authentication
332
+ - No data is sent to third-party services -- all communication goes directly to Telegram servers via MTProto
333
+ - QR login codes are generated locally and never leave your machine
279
334
  - **One session per process** -- using the same session in multiple processes simultaneously causes `AUTH_KEY_DUPLICATED` errors (see [Troubleshooting](#troubleshooting))
280
335
  - This is a **userbot** (personal account), not a bot -- respect the [Telegram Terms of Service](https://core.telegram.org/api/terms)
281
336
 
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert";
2
+ import { describe, it } from "node:test";
3
+ import { DESTRUCTIVE, fail, formatReactions, ok, READ_ONLY, sanitize, WRITE } from "../../tools/shared.js";
4
+ describe("shared utilities", () => {
5
+ describe("ok()", () => {
6
+ it("should return success response with text content", () => {
7
+ const result = ok("Operation successful");
8
+ assert.deepStrictEqual(result, {
9
+ content: [{ type: "text", text: "Operation successful" }],
10
+ });
11
+ });
12
+ it("should handle empty string", () => {
13
+ const result = ok("");
14
+ assert.deepStrictEqual(result, {
15
+ content: [{ type: "text", text: "" }],
16
+ });
17
+ });
18
+ });
19
+ describe("fail()", () => {
20
+ it("should return error response with isError flag", () => {
21
+ const error = new Error("Something went wrong");
22
+ const result = fail(error);
23
+ assert.deepStrictEqual(result, {
24
+ content: [{ type: "text", text: "Error: Something went wrong" }],
25
+ isError: true,
26
+ });
27
+ });
28
+ it("should handle non-Error objects", () => {
29
+ const result = fail({ message: "Custom error" });
30
+ assert.ok(result.content[0].text.includes("Error:"));
31
+ assert.strictEqual(result.isError, true);
32
+ });
33
+ });
34
+ describe("sanitize()", () => {
35
+ it("should remove unpaired high surrogates", () => {
36
+ const input = "Hello\uD800World";
37
+ const result = sanitize(input);
38
+ assert.strictEqual(result, "Hello\uFFFDWorld");
39
+ });
40
+ it("should remove unpaired low surrogates", () => {
41
+ const input = "Hello\uDC00World";
42
+ const result = sanitize(input);
43
+ assert.strictEqual(result, "Hello\uFFFDWorld");
44
+ });
45
+ it("should preserve valid surrogate pairs", () => {
46
+ const input = "Hello\uD83D\uDE00World"; // 😀 emoji
47
+ const result = sanitize(input);
48
+ assert.strictEqual(result, "Hello\uD83D\uDE00World");
49
+ });
50
+ it("should handle normal text without surrogates", () => {
51
+ const input = "Hello World";
52
+ const result = sanitize(input);
53
+ assert.strictEqual(result, "Hello World");
54
+ });
55
+ it("should handle empty string", () => {
56
+ const result = sanitize("");
57
+ assert.strictEqual(result, "");
58
+ });
59
+ });
60
+ describe("formatReactions()", () => {
61
+ it("should format reactions with counts", () => {
62
+ const reactions = [
63
+ { emoji: "👍", count: 5, me: false },
64
+ { emoji: "❤️", count: 3, me: true },
65
+ { emoji: "🔥", count: 1, me: false },
66
+ ];
67
+ const result = formatReactions(reactions);
68
+ assert.strictEqual(result, " [👍×5 ❤️×3(me) 🔥×1]");
69
+ });
70
+ it("should mark reactions from current user", () => {
71
+ const reactions = [{ emoji: "👍", count: 2, me: true }];
72
+ const result = formatReactions(reactions);
73
+ assert.strictEqual(result, " [👍×2(me)]");
74
+ });
75
+ it("should return empty string for undefined reactions", () => {
76
+ const result = formatReactions(undefined);
77
+ assert.strictEqual(result, "");
78
+ });
79
+ it("should return empty string for empty reactions array", () => {
80
+ const result = formatReactions([]);
81
+ assert.strictEqual(result, "");
82
+ });
83
+ it("should handle single reaction", () => {
84
+ const reactions = [{ emoji: "🎉", count: 1, me: false }];
85
+ const result = formatReactions(reactions);
86
+ assert.strictEqual(result, " [🎉×1]");
87
+ });
88
+ });
89
+ describe("MCP tool annotations", () => {
90
+ it("should define READ_ONLY preset", () => {
91
+ assert.deepStrictEqual(READ_ONLY, {
92
+ readOnlyHint: true,
93
+ openWorldHint: true,
94
+ });
95
+ });
96
+ it("should define WRITE preset", () => {
97
+ assert.deepStrictEqual(WRITE, {
98
+ readOnlyHint: false,
99
+ openWorldHint: true,
100
+ });
101
+ });
102
+ it("should define DESTRUCTIVE preset", () => {
103
+ assert.deepStrictEqual(DESTRUCTIVE, {
104
+ readOnlyHint: false,
105
+ destructiveHint: true,
106
+ openWorldHint: true,
107
+ });
108
+ });
109
+ });
110
+ });
package/dist/index.js CHANGED
@@ -24,18 +24,19 @@ const server = new McpServer({
24
24
  });
25
25
  registerTools(server, telegram);
26
26
  async function main() {
27
- // Try to auto-connect with saved session
28
- await telegram.loadSession();
29
- if (await telegram.connect()) {
30
- const me = await telegram.getMe();
31
- console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
32
- }
33
- else if (telegram.lastError) {
34
- console.error(`[mcp-telegram] ${telegram.lastError}`);
35
- }
36
27
  const transport = new StdioServerTransport();
37
28
  await server.connect(transport);
38
29
  console.error("[mcp-telegram] MCP server running on stdio");
30
+ // Auto-connect with saved session after MCP is ready (non-blocking)
31
+ telegram.loadSession().then(async () => {
32
+ if (await telegram.connect()) {
33
+ const me = await telegram.getMe();
34
+ console.error(`[mcp-telegram] Auto-connected as @${me.username}`);
35
+ }
36
+ else if (telegram.lastError) {
37
+ console.error(`[mcp-telegram] ${telegram.lastError}`);
38
+ }
39
+ });
39
40
  }
40
41
  main().catch((err) => {
41
42
  console.error("[mcp-telegram] Fatal:", err);
@@ -6,6 +6,7 @@ export declare class TelegramService {
6
6
  private connected;
7
7
  private sessionPath;
8
8
  lastError: string;
9
+ get sessionDir(): string;
9
10
  constructor(apiId: number, apiHash: string, options?: {
10
11
  sessionPath?: string;
11
12
  });
@@ -182,7 +183,15 @@ export declare class TelegramService {
182
183
  id: string;
183
184
  name: string;
184
185
  username?: string;
186
+ role: string;
185
187
  }>>;
188
+ getMyRole(chatId: string): Promise<{
189
+ role: string;
190
+ chatId: string;
191
+ chatName: string;
192
+ }>;
193
+ private getParticipantUserId;
194
+ private getParticipantRole;
186
195
  getProfile(userId: string): Promise<{
187
196
  id: string;
188
197
  name: string;
@@ -50,6 +50,9 @@ export class TelegramService {
50
50
  connected = false;
51
51
  sessionPath;
52
52
  lastError = "";
53
+ get sessionDir() {
54
+ return dirname(this.sessionPath);
55
+ }
53
56
  constructor(apiId, apiHash, options) {
54
57
  this.apiId = apiId;
55
58
  this.apiHash = apiHash;
@@ -879,19 +882,99 @@ export class TelegramService {
879
882
  async getChatMembers(chatId, limit = 50) {
880
883
  if (!this.client || !this.connected)
881
884
  throw new Error("Not connected");
882
- const participants = await this.client.getParticipants(chatId, { limit });
883
- const members = [];
884
- for (const p of participants) {
885
- if (p instanceof Api.User) {
886
- const parts = [p.firstName, p.lastName].filter(Boolean);
887
- members.push({
888
- id: p.id.toString(),
885
+ const entity = await this.resolveChat(chatId);
886
+ if (entity instanceof Api.Channel) {
887
+ const result = await this.client.invoke(new Api.channels.GetParticipants({
888
+ channel: entity,
889
+ filter: new Api.ChannelParticipantsRecent(),
890
+ offset: 0,
891
+ limit,
892
+ hash: bigInt.zero,
893
+ }));
894
+ if (!(result instanceof Api.channels.ChannelParticipants))
895
+ return [];
896
+ const userMap = new Map();
897
+ for (const u of result.users) {
898
+ if (u instanceof Api.User)
899
+ userMap.set(u.id.toString(), u);
900
+ }
901
+ return result.participants.map((p) => {
902
+ const userId = this.getParticipantUserId(p);
903
+ const user = userMap.get(userId);
904
+ const parts = user ? [user.firstName, user.lastName].filter(Boolean) : [];
905
+ return {
906
+ id: userId,
889
907
  name: parts.join(" ") || "Unknown",
890
- username: p.username ?? undefined,
891
- });
908
+ username: user?.username ?? undefined,
909
+ role: this.getParticipantRole(p),
910
+ };
911
+ });
912
+ }
913
+ // Basic group — use getParticipants (no role info available)
914
+ const participants = await this.client.getParticipants(entity, { limit });
915
+ return participants
916
+ .filter((p) => p instanceof Api.User)
917
+ .map((p) => {
918
+ const parts = [p.firstName, p.lastName].filter(Boolean);
919
+ return {
920
+ id: p.id.toString(),
921
+ name: parts.join(" ") || "Unknown",
922
+ username: p.username ?? undefined,
923
+ role: "member",
924
+ };
925
+ });
926
+ }
927
+ async getMyRole(chatId) {
928
+ if (!this.client || !this.connected)
929
+ throw new Error("Not connected");
930
+ const entity = await this.resolveChat(chatId);
931
+ const me = await this.getMe();
932
+ if (entity instanceof Api.Channel) {
933
+ const result = await this.client.invoke(new Api.channels.GetParticipant({ channel: entity, participant: new Api.InputUserSelf() }));
934
+ return {
935
+ role: this.getParticipantRole(result.participant),
936
+ chatId: entity.id.toString(),
937
+ chatName: entity.title ?? "Unknown",
938
+ };
939
+ }
940
+ if (entity instanceof Api.Chat) {
941
+ // Basic group — check if creator
942
+ if (entity.creator) {
943
+ return { role: "creator", chatId: entity.id.toString(), chatName: entity.title ?? "Unknown" };
944
+ }
945
+ if (entity.adminRights) {
946
+ return { role: "admin", chatId: entity.id.toString(), chatName: entity.title ?? "Unknown" };
892
947
  }
948
+ return { role: "member", chatId: entity.id.toString(), chatName: entity.title ?? "Unknown" };
893
949
  }
894
- return members;
950
+ if (entity instanceof Api.User) {
951
+ return { role: "user", chatId: entity.id.toString(), chatName: me.username ?? "self" };
952
+ }
953
+ return { role: "unknown", chatId: chatId, chatName: "Unknown" };
954
+ }
955
+ getParticipantUserId(p) {
956
+ if (p instanceof Api.ChannelParticipantCreator)
957
+ return p.userId.toString();
958
+ if (p instanceof Api.ChannelParticipantAdmin)
959
+ return p.userId.toString();
960
+ if (p instanceof Api.ChannelParticipantSelf)
961
+ return p.userId.toString();
962
+ if (p instanceof Api.ChannelParticipantBanned)
963
+ return p.peer?.userId?.toString() ?? "0";
964
+ if (p instanceof Api.ChannelParticipant)
965
+ return p.userId.toString();
966
+ return "0";
967
+ }
968
+ getParticipantRole(p) {
969
+ if (p instanceof Api.ChannelParticipantCreator)
970
+ return "creator";
971
+ if (p instanceof Api.ChannelParticipantAdmin)
972
+ return "admin";
973
+ if (p instanceof Api.ChannelParticipantBanned)
974
+ return "banned";
975
+ if (p instanceof Api.ChannelParticipantLeft)
976
+ return "left";
977
+ return "member";
895
978
  }
896
979
  async getProfile(userId) {
897
980
  if (!this.client || !this.connected)
@@ -1,3 +1,5 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
1
3
  import { fail, ok, READ_ONLY, WRITE } from "./shared.js";
2
4
  export function registerAuthTools(server, telegram) {
3
5
  server.registerTool("telegram-status", { description: "Check Telegram connection status", annotations: READ_ONLY }, async () => {
@@ -18,11 +20,8 @@ export function registerAuthTools(server, telegram) {
18
20
  annotations: WRITE,
19
21
  }, async () => {
20
22
  let qrDataUrl = "";
21
- let qrRawUrl = "";
22
23
  const loginPromise = telegram.startQrLogin((dataUrl) => {
23
24
  qrDataUrl = dataUrl;
24
- }, (url) => {
25
- qrRawUrl = url;
26
25
  });
27
26
  // Wait for first QR to be generated
28
27
  const startTime = Date.now();
@@ -41,20 +40,17 @@ export function registerAuthTools(server, telegram) {
41
40
  console.error(`[mcp-telegram] Login failed: ${result.message}`);
42
41
  }
43
42
  });
44
- // Return as MCP image content + text with fallback options
43
+ // Save QR to file as fallback (no data sent to third-party services)
45
44
  const base64 = qrDataUrl.replace(/^data:image\/png;base64,/, "");
46
- const qrApiUrl = qrRawUrl
47
- ? `https://api.qrserver.com/v1/create-qr-code/?size=256x256&data=${encodeURIComponent(qrRawUrl)}`
48
- : "";
45
+ const qrFilePath = join(telegram.sessionDir, "qr-login.png");
46
+ await writeFile(qrFilePath, Buffer.from(base64, "base64")).catch(() => { });
49
47
  const instructions = [
50
48
  "Scan this QR code in Telegram: **Settings → Devices → Link Desktop Device**.",
51
49
  "",
52
- qrApiUrl ? `If the QR image is not visible, open this link in your browser:\n${qrApiUrl}` : "",
50
+ `If the QR image is not visible, it's also saved to: ${qrFilePath}`,
53
51
  "",
54
52
  "After scanning, run **telegram-status** to verify the connection.",
55
- ]
56
- .filter(Boolean)
57
- .join("\n");
53
+ ].join("\n");
58
54
  return {
59
55
  content: [
60
56
  {
@@ -93,13 +93,36 @@ export function registerChatTools(server, telegram) {
93
93
  return fail(new Error(err));
94
94
  try {
95
95
  const members = await telegram.getChatMembers(chatId, limit);
96
- const text = members.map((m) => `${m.name}${m.username ? ` (@${m.username})` : ""} (${m.id})`).join("\n");
96
+ const text = members
97
+ .map((m) => {
98
+ const role = m.role !== "member" ? ` [${m.role}]` : "";
99
+ return `${m.name}${m.username ? ` (@${m.username})` : ""} (${m.id})${role}`;
100
+ })
101
+ .join("\n");
97
102
  return ok(sanitize(text) || "No members found");
98
103
  }
99
104
  catch (e) {
100
105
  return fail(e);
101
106
  }
102
107
  });
108
+ server.registerTool("telegram-get-my-role", {
109
+ description: "Get the current user's role in a chat (creator, admin, or member)",
110
+ inputSchema: {
111
+ chatId: z.string().describe("Chat ID or username"),
112
+ },
113
+ annotations: READ_ONLY,
114
+ }, async ({ chatId }) => {
115
+ const err = await requireConnection(telegram);
116
+ if (err)
117
+ return fail(new Error(err));
118
+ try {
119
+ const result = await telegram.getMyRole(chatId);
120
+ return ok(`Role: ${result.role}\nChat: ${result.chatName} (${result.chatId})`);
121
+ }
122
+ catch (e) {
123
+ return fail(e);
124
+ }
125
+ });
103
126
  server.registerTool("telegram-create-group", {
104
127
  description: "Create a new Telegram group or supergroup",
105
128
  inputSchema: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@overpod/mcp-telegram",
3
- "version": "1.17.0",
3
+ "version": "1.19.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",
@@ -25,7 +25,9 @@
25
25
  "prepublishOnly": "npm run build",
26
26
  "lint": "biome check src/",
27
27
  "lint:fix": "biome check --fix src/",
28
- "format": "biome format --write src/"
28
+ "format": "biome format --write src/",
29
+ "test": "tsx --test src/**/*.test.ts",
30
+ "test:watch": "tsx --test --watch src/**/*.test.ts"
29
31
  },
30
32
  "keywords": [
31
33
  "mcp",