diff --git a/src/App.tsx b/src/App.tsx index 8a2277a7f..b4fd03fd5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,6 +3,7 @@ import { Provider } from 'react-redux'; import { PersistGate } from 'redux-persist/integration/react'; import { store, persistor } from './store'; import SocketProvider from './providers/SocketProvider'; +import MCPProvider from './providers/MCPProvider'; import TelegramProvider from './providers/TelegramProvider'; import AppRoutes from './AppRoutes'; @@ -11,11 +12,13 @@ function App() { - - - - - + + + + + + + diff --git a/src/lib/mcp/telegram/args.ts b/src/lib/mcp/telegram/args.ts new file mode 100644 index 000000000..5864d8d7d --- /dev/null +++ b/src/lib/mcp/telegram/args.ts @@ -0,0 +1,13 @@ +/** + * Helpers for reading typed values from MCP tool args (Record) + */ + +export function optNumber(args: Record, key: string, fallback: number): number { + const v = args[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : fallback; +} + +export function optString(args: Record, key: string): string | undefined { + const v = args[key]; + return typeof v === 'string' ? v : undefined; +} diff --git a/src/lib/mcp/telegram/server.ts b/src/lib/mcp/telegram/server.ts index d0e9619e7..8b06af3e8 100644 --- a/src/lib/mcp/telegram/server.ts +++ b/src/lib/mcp/telegram/server.ts @@ -17,7 +17,11 @@ import { ErrorCategory, logAndFormatError } from "../errorHandler"; import { ValidationError } from "../validation"; import { SocketIOMCPTransportImpl } from "../transport"; import { mcpLog } from "../logger"; -import type { TelegramMCPToolHandler } from "./types"; +import { + type TelegramMCPToolHandler, + type TelegramMCPToolName, + TELEGRAM_MCP_TOOL_NAMES, +} from "./types"; import * as tools from "./tools"; export class TelegramMCPServer { @@ -27,6 +31,7 @@ export class TelegramMCPServer { constructor(socket: Socket | null | undefined) { this.transport = new SocketIOMCPTransportImpl(socket ?? undefined); this.config = { name: "telegram-mcp", version: "1.0.0" }; + mcpLog(`Telegram MCP ${this.config.name} v${this.config.version} ready`); this.setupHandlers(); } @@ -35,15 +40,14 @@ export class TelegramMCPServer { } private setupHandlers(): void { - this.transport.on( - "toolCall", - (data: { requestId: string; toolCall: MCPToolCall }) => { - void this.handleToolCallRequest(data); - }, - ); + this.transport.on("toolCall", (data: unknown) => { + void this.handleToolCallRequest( + data as { requestId: string; toolCall: MCPToolCall }, + ); + }); - this.transport.on("listTools", (data: { requestId: string }) => { - const { requestId } = data; + this.transport.on("listTools", (data: unknown) => { + const { requestId } = data as { requestId: string }; try { const toolsList = this.listTools(); this.transport.emit("listToolsResponse", { @@ -119,7 +123,12 @@ export class TelegramMCPServer { } private findToolHandler(name: string): TelegramMCPToolHandler | undefined { - const toolMap: Record = { + const isToolName = (n: string): n is TelegramMCPToolName => + (TELEGRAM_MCP_TOOL_NAMES as readonly string[]).includes(n); + + if (!isToolName(name)) return undefined; + + const toolMap: Record = { get_chats: tools.getChats, list_chats: tools.listChats, get_chat: tools.getChat, @@ -200,6 +209,7 @@ export class TelegramMCPServer { get_contact_chats: tools.getContactChats, get_last_interaction: tools.getLastInteraction, }; + return toolMap[name]; } } diff --git a/src/lib/mcp/telegram/telegramApi.ts b/src/lib/mcp/telegram/telegramApi.ts index c4074a92c..044859101 100644 --- a/src/lib/mcp/telegram/telegramApi.ts +++ b/src/lib/mcp/telegram/telegramApi.ts @@ -7,7 +7,6 @@ import { store } from '../../../store'; import { mtprotoService } from '../../../services/mtprotoService'; import { selectOrderedChats, - selectChatMessages, selectCurrentUser, } from '../../../store/telegramSelectors'; import type { TelegramChat, TelegramUser, TelegramMessage } from '../../../store/telegram/types'; diff --git a/src/lib/mcp/telegram/tools/getChat.ts b/src/lib/mcp/telegram/tools/getChat.ts index a68e91c76..02f217e00 100644 --- a/src/lib/mcp/telegram/tools/getChat.ts +++ b/src/lib/mcp/telegram/tools/getChat.ts @@ -25,8 +25,8 @@ export const tool: MCPTool = { }; export async function getChat( - args: { chat_id: string | number }, - context: TelegramMCPContext, + args: Record, + _context: TelegramMCPContext, ): Promise { try { const chatId = validateId(args.chat_id, 'chat_id'); diff --git a/src/lib/mcp/telegram/tools/getChats.ts b/src/lib/mcp/telegram/tools/getChats.ts index 93780cb21..450998353 100644 --- a/src/lib/mcp/telegram/tools/getChats.ts +++ b/src/lib/mcp/telegram/tools/getChats.ts @@ -6,6 +6,7 @@ import type { MCPTool, MCPToolResult } from '../../types'; import type { TelegramMCPContext } from '../types'; import { ErrorCategory, logAndFormatError } from '../../errorHandler'; +import { optNumber } from '../args'; import { formatEntity, getChats as getChatsApi } from '../telegramApi'; export const tool: MCPTool = { @@ -21,12 +22,12 @@ export const tool: MCPTool = { }; export async function getChats( - args: { page?: number; page_size?: number }, + args: Record, _context: TelegramMCPContext, ): Promise { try { - const page = args.page ?? 1; - const pageSize = args.page_size ?? 20; + const page = optNumber(args, 'page', 1); + const pageSize = optNumber(args, 'page_size', 20); const start = (page - 1) * pageSize; const chats = await getChatsApi(pageSize + start); diff --git a/src/lib/mcp/telegram/tools/getMe.ts b/src/lib/mcp/telegram/tools/getMe.ts index d8e449b91..4c44dad9d 100644 --- a/src/lib/mcp/telegram/tools/getMe.ts +++ b/src/lib/mcp/telegram/tools/getMe.ts @@ -18,7 +18,7 @@ export const tool: MCPTool = { }; export async function getMe( - _args: Record, + _args: Record, _context: TelegramMCPContext, ): Promise { try { diff --git a/src/lib/mcp/telegram/tools/getMessages.ts b/src/lib/mcp/telegram/tools/getMessages.ts index 9e01b0808..85c7daa39 100644 --- a/src/lib/mcp/telegram/tools/getMessages.ts +++ b/src/lib/mcp/telegram/tools/getMessages.ts @@ -2,66 +2,86 @@ * Get Messages tool - Get paginated messages from a specific chat */ -import type { MCPTool, MCPToolResult } from '../../types'; -import type { TelegramMCPContext } from '../types'; +import type { MCPTool, MCPToolResult } from "../../types"; +import type { TelegramMCPContext } from "../types"; -import { ErrorCategory, logAndFormatError } from '../../errorHandler'; -import { formatMessage, getChatById, getMessages as getMessagesApi } from '../telegramApi'; +import { ErrorCategory, logAndFormatError } from "../../errorHandler"; +import { + formatMessage, + getChatById, + getMessages as getMessagesApi, +} from "../telegramApi"; import { validateId } from '../../validation'; -import type { TelegramMessage } from '../../../store/telegram/types'; +import type { TelegramMessage } from '../../../../store/telegram/types'; +import { optNumber } from '../args'; export const tool: MCPTool = { - name: 'get_messages', - description: 'Get paginated messages from a specific chat', + name: "get_messages", + description: "Get paginated messages from a specific chat", inputSchema: { - type: 'object', + type: "object", properties: { - chat_id: { type: 'string', description: 'The ID or username of the chat' }, - page: { type: 'number', description: 'Page number (1-indexed)', default: 1 }, - page_size: { type: 'number', description: 'Number of messages per page', default: 20 }, + chat_id: { + type: "string", + description: "The ID or username of the chat", + }, + page: { + type: "number", + description: "Page number (1-indexed)", + default: 1, + }, + page_size: { + type: "number", + description: "Number of messages per page", + default: 20, + }, }, - required: ['chat_id'], + required: ["chat_id"], }, }; function senderDisplay(msg: TelegramMessage): string { - return msg.fromName ?? msg.fromId ?? 'Unknown'; + return msg.fromName ?? msg.fromId ?? "Unknown"; } export async function getMessages( - args: { chat_id: string | number; page?: number; page_size?: number }, + args: Record, _context: TelegramMCPContext, ): Promise { try { const chatId = validateId(args.chat_id, 'chat_id'); - const page = args.page ?? 1; - const pageSize = args.page_size ?? 20; + const page = optNumber(args, 'page', 1); + const pageSize = optNumber(args, 'page_size', 20); const offset = (page - 1) * pageSize; const chat = getChatById(chatId); if (!chat) { return { - content: [{ type: 'text', text: `Chat not found: ${chatId}` }], + content: [{ type: "text", text: `Chat not found: ${chatId}` }], isError: true, }; } const messages = await getMessagesApi(chatId, pageSize, offset); if (!messages || messages.length === 0) { - return { content: [{ type: 'text', text: 'No messages found for this page.' }] }; + return { + content: [{ type: "text", text: "No messages found for this page." }], + }; } const lines = messages.map((msg) => { const formatted = formatMessage(msg); - const replyStr = msg.replyToMessageId ? ` | reply to ${msg.replyToMessageId}` : ''; - const msgText = formatted.text || '[Media/No text]'; + const replyStr = msg.replyToMessageId + ? ` | reply to ${msg.replyToMessageId}` + : ""; + const msgText = formatted.text || "[Media/No text]"; return `ID: ${formatted.id} | ${senderDisplay(msg)} | Date: ${formatted.date}${replyStr} | Message: ${msgText}`; }); - return { content: [{ type: 'text', text: lines.join('\n') }] }; + return { content: [{ type: "text", text: lines.join("\n") }] }; } catch (error) { return logAndFormatError( - 'get_messages', + "get_messages", error instanceof Error ? error : new Error(String(error)), ErrorCategory.MSG, ); diff --git a/src/lib/mcp/telegram/tools/listChats.ts b/src/lib/mcp/telegram/tools/listChats.ts index f3b729383..d0ba9eee5 100644 --- a/src/lib/mcp/telegram/tools/listChats.ts +++ b/src/lib/mcp/telegram/tools/listChats.ts @@ -6,6 +6,7 @@ import type { MCPTool, MCPToolResult } from '../../types'; import type { TelegramMCPContext } from '../types'; import { ErrorCategory, logAndFormatError } from '../../errorHandler'; +import { optNumber, optString } from '../args'; import { formatEntity, getChats as getChatsApi } from '../telegramApi'; import { toHumanReadableAction } from '../toolActionParser'; @@ -27,12 +28,12 @@ export const tool: MCPTool = { }; export async function listChats( - args: { chat_type?: string; limit?: number }, + args: Record, _context: TelegramMCPContext, ): Promise { try { - const limit = args.limit ?? 20; - const chatType = args.chat_type?.toLowerCase(); + const limit = optNumber(args, 'limit', 20); + const chatType = optString(args, 'chat_type')?.toLowerCase(); const chats = await getChatsApi(limit); const contentItems: Array<{ type: 'text'; text: string }> = []; diff --git a/src/lib/mcp/telegram/tools/listMessages.ts b/src/lib/mcp/telegram/tools/listMessages.ts index bb0838500..b2ccc3a3c 100644 --- a/src/lib/mcp/telegram/tools/listMessages.ts +++ b/src/lib/mcp/telegram/tools/listMessages.ts @@ -6,6 +6,7 @@ import type { MCPTool, MCPToolResult } from '../../types'; import type { TelegramMCPContext } from '../types'; import { ErrorCategory, logAndFormatError } from '../../errorHandler'; +import { optNumber, optString } from '../args'; import { formatMessage, getChatById, getMessages as getMessagesApi } from '../telegramApi'; import { validateId } from '../../validation'; @@ -26,18 +27,15 @@ export const tool: MCPTool = { }; export async function listMessages( - args: { - chat_id: string | number; - limit?: number; - search_query?: string; - from_date?: string; - to_date?: string; - }, + args: Record, _context: TelegramMCPContext, ): Promise { try { const chatId = validateId(args.chat_id, 'chat_id'); - const limit = args.limit ?? 20; + const limit = optNumber(args, 'limit', 20); + const searchQuery = optString(args, 'search_query'); + const fromDate = optString(args, 'from_date'); + const toDate = optString(args, 'to_date'); const chat = getChatById(chatId); if (!chat) { @@ -52,17 +50,17 @@ export async function listMessages( return { content: [{ type: 'text', text: 'No messages found matching the criteria.' }] }; } - if (args.search_query) { - const q = args.search_query.toLowerCase(); + if (searchQuery) { + const q = searchQuery.toLowerCase(); messages = messages.filter((m) => (m.message ?? '').toLowerCase().includes(q)); } - if (args.from_date || args.to_date) { + if (fromDate || toDate) { messages = messages.filter((m) => { const d = new Date(m.date * 1000); - if (args.from_date && d < new Date(args.from_date)) return false; - if (args.to_date) { - const to = new Date(args.to_date); + if (fromDate && d < new Date(fromDate)) return false; + if (toDate) { + const to = new Date(toDate); to.setHours(23, 59, 59, 999); if (d > to) return false; } diff --git a/src/lib/mcp/telegram/tools/replyToMessage.ts b/src/lib/mcp/telegram/tools/replyToMessage.ts index 835089716..6dea35620 100644 --- a/src/lib/mcp/telegram/tools/replyToMessage.ts +++ b/src/lib/mcp/telegram/tools/replyToMessage.ts @@ -30,12 +30,28 @@ export const tool: MCPTool = { }; export async function replyToMessage( - args: { chat_id: string | number; message_id: number; text: string }, + args: Record, _context: TelegramMCPContext, ): Promise { try { - const chatId = validateId(args.chat_id, "chat_id"); - const { message_id, text } = args; + const chatId = validateId(args.chat_id, 'chat_id'); + const messageId = typeof args.message_id === 'number' && Number.isInteger(args.message_id) + ? args.message_id + : undefined; + const text = typeof args.text === 'string' ? args.text : ''; + + if (messageId === undefined) { + return { + content: [{ type: 'text', text: 'message_id must be an integer' }], + isError: true, + }; + } + if (!text) { + return { + content: [{ type: 'text', text: 'text is required' }], + isError: true, + }; + } const chat = getChatById(chatId); if (!chat) { @@ -45,13 +61,13 @@ export async function replyToMessage( }; } - const result = await sendMessage(chatId, text, message_id); + const result = await sendMessage(chatId, text, messageId); if (!result) { return { content: [ { - type: "text", - text: `Failed to reply to message ${message_id} in chat ${chatId}.`, + type: 'text', + text: `Failed to reply to message ${messageId} in chat ${chatId}.`, }, ], isError: true, @@ -61,8 +77,8 @@ export async function replyToMessage( return { content: [ { - type: "text", - text: `Replied to message ${message_id} in chat ${chatId}.`, + type: 'text', + text: `Replied to message ${messageId} in chat ${chatId}.`, }, ], }; diff --git a/src/lib/mcp/telegram/tools/resolveUsername.ts b/src/lib/mcp/telegram/tools/resolveUsername.ts index 48491fcf9..f0432a7b3 100644 --- a/src/lib/mcp/telegram/tools/resolveUsername.ts +++ b/src/lib/mcp/telegram/tools/resolveUsername.ts @@ -21,11 +21,17 @@ export const tool: MCPTool = { }; export async function resolveUsername( - args: { username: string }, + args: Record, _context: TelegramMCPContext, ): Promise { try { - const raw = args.username; + const raw = typeof args.username === 'string' ? args.username : ''; + if (!raw) { + return { + content: [{ type: 'text', text: 'username is required' }], + isError: true, + }; + } const username = raw.startsWith('@') ? raw : `@${raw}`; const chat = getChatById(username); if (!chat) { diff --git a/src/lib/mcp/telegram/tools/searchMessages.ts b/src/lib/mcp/telegram/tools/searchMessages.ts index d79a56e51..ac12659e4 100644 --- a/src/lib/mcp/telegram/tools/searchMessages.ts +++ b/src/lib/mcp/telegram/tools/searchMessages.ts @@ -6,6 +6,7 @@ import type { MCPTool, MCPToolResult } from '../../types'; import type { TelegramMCPContext } from '../types'; import { ErrorCategory, logAndFormatError } from '../../errorHandler'; +import { optNumber } from '../args'; import { formatMessage, getChatById, getMessages } from '../telegramApi'; import { validateId } from '../../validation'; @@ -24,12 +25,20 @@ export const tool: MCPTool = { }; export async function searchMessages( - args: { chat_id: string | number; query: string; limit?: number }, + args: Record, _context: TelegramMCPContext, ): Promise { try { const chatId = validateId(args.chat_id, 'chat_id'); - const { query, limit = 20 } = args; + const query = typeof args.query === 'string' ? args.query : ''; + const limit = optNumber(args, 'limit', 20); + + if (!query) { + return { + content: [{ type: 'text', text: 'query is required' }], + isError: true, + }; + } const chat = getChatById(chatId); if (!chat) { @@ -45,7 +54,9 @@ export async function searchMessages( } const q = query.toLowerCase(); - const filtered = messages.filter((m) => (m.message ?? '').toLowerCase().includes(q)).slice(0, limit); + const filtered = messages + .filter((m) => (m.message ?? '').toLowerCase().includes(q)) + .slice(0, limit); const lines = filtered.map((m) => { const f = formatMessage(m); diff --git a/src/lib/mcp/telegram/tools/searchPublicChats.ts b/src/lib/mcp/telegram/tools/searchPublicChats.ts index c9e393419..92a84f9ce 100644 --- a/src/lib/mcp/telegram/tools/searchPublicChats.ts +++ b/src/lib/mcp/telegram/tools/searchPublicChats.ts @@ -21,11 +21,18 @@ export const tool: MCPTool = { }; export async function searchPublicChats( - args: { query: string }, + args: Record, _context: TelegramMCPContext, ): Promise { try { - const chats = await searchChats(args.query); + const query = typeof args.query === 'string' ? args.query : ''; + if (!query) { + return { + content: [{ type: 'text', text: 'query is required' }], + isError: true, + }; + } + const chats = await searchChats(query); const results = chats.map(formatEntity); return { content: [{ type: 'text', text: JSON.stringify(results, undefined, 2) }], diff --git a/src/lib/mcp/telegram/tools/sendMessage.ts b/src/lib/mcp/telegram/tools/sendMessage.ts index 8068808f7..2412ec30d 100644 --- a/src/lib/mcp/telegram/tools/sendMessage.ts +++ b/src/lib/mcp/telegram/tools/sendMessage.ts @@ -25,13 +25,13 @@ export const tool: MCPTool = { }; export async function sendMessage( - args: { chat_id: string | number; message: string }, + args: Record, _context: TelegramMCPContext, ): Promise { try { const chatId = validateId(args.chat_id, 'chat_id'); - const { message } = args; - if (!message || typeof message !== 'string') { + const message = typeof args.message === 'string' ? args.message : ''; + if (!message) { return { content: [{ type: 'text', text: 'Message content is required' }], isError: true, diff --git a/src/lib/mcp/telegram/types.ts b/src/lib/mcp/telegram/types.ts index 9af309450..fd5e37ac4 100644 --- a/src/lib/mcp/telegram/types.ts +++ b/src/lib/mcp/telegram/types.ts @@ -3,7 +3,7 @@ */ import type { SocketIOMCPTransportImpl } from '../transport'; -import type { MCPToolResult } from '../../mcp/types'; +import type { MCPToolResult } from '../types'; import type { TelegramState } from '../../../store/telegram/types'; export interface TelegramMCPContext { @@ -15,3 +15,87 @@ export type TelegramMCPToolHandler = ( args: Record, context: TelegramMCPContext, ) => Promise; + +export const TELEGRAM_MCP_TOOL_NAMES = [ + 'get_chats', + 'list_chats', + 'get_chat', + 'create_group', + 'invite_to_group', + 'create_channel', + 'edit_chat_title', + 'delete_chat_photo', + 'leave_chat', + 'get_participants', + 'get_admins', + 'get_banned_users', + 'promote_admin', + 'demote_admin', + 'ban_user', + 'unban_user', + 'get_invite_link', + 'export_chat_invite', + 'import_chat_invite', + 'join_chat_by_link', + 'subscribe_public_channel', + 'get_messages', + 'list_messages', + 'list_topics', + 'send_message', + 'reply_to_message', + 'edit_message', + 'delete_message', + 'forward_message', + 'pin_message', + 'unpin_message', + 'mark_as_read', + 'get_message_context', + 'get_history', + 'get_pinned_messages', + 'send_reaction', + 'remove_reaction', + 'get_message_reactions', + 'list_contacts', + 'search_contacts', + 'add_contact', + 'delete_contact', + 'block_user', + 'unblock_user', + 'get_blocked_users', + 'get_me', + 'update_profile', + 'get_user_photos', + 'get_user_status', + 'mute_chat', + 'unmute_chat', + 'archive_chat', + 'unarchive_chat', + 'get_privacy_settings', + 'set_privacy_settings', + 'list_inline_buttons', + 'press_inline_button', + 'save_draft', + 'get_drafts', + 'clear_draft', + 'search_public_chats', + 'search_messages', + 'resolve_username', + 'get_media_info', + 'get_recent_actions', + 'create_poll', + 'get_bot_info', + 'set_bot_commands', + 'set_profile_photo', + 'delete_profile_photo', + 'edit_chat_photo', + 'get_sticker_sets', + 'get_gif_search', + 'get_contact_ids', + 'import_contacts', + 'export_contacts', + 'get_direct_chat_by_contact', + 'get_contact_chats', + 'get_last_interaction', +] as const; + +export type TelegramMCPToolName = (typeof TELEGRAM_MCP_TOOL_NAMES)[number]; diff --git a/src/providers/MCPProvider.tsx b/src/providers/MCPProvider.tsx new file mode 100644 index 000000000..aa441e435 --- /dev/null +++ b/src/providers/MCPProvider.tsx @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useAppSelector } from '../store/hooks'; +import { socketService } from '../services/socketService'; +import { + initTelegramMCPServer, + getTelegramMCPServer, + updateTelegramMCPServerSocket, +} from '../lib/mcp/telegram'; + +/** + * MCPProvider initializes and updates MCP servers when the socket connects. + * Place inside SocketProvider so the socket is available. + */ +const MCPProvider = ({ children }: { children: React.ReactNode }) => { + const socketStatus = useAppSelector((state) => state.socket.status); + + useEffect(() => { + if (socketStatus !== 'connected') return; + + const socket = socketService.getSocket(); + const server = getTelegramMCPServer(); + + if (server) { + updateTelegramMCPServerSocket(socket); + } else { + initTelegramMCPServer(socket); + } + }, [socketStatus]); + + return <>{children}; +}; + +export default MCPProvider;