diff --git a/index.js b/index.js index 950dc5f..4c30121 100644 --- a/index.js +++ b/index.js @@ -1,6 +1,6 @@ const CORS = { 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization, Mcp-Session-Id', }; @@ -15,7 +15,7 @@ const TOOLS = [ type: 'object', properties: { text: { type: 'string', description: 'Message text (supports HTML formatting)' }, - parse_mode: { type: 'string', enum: ['HTML', 'Markdown', 'MarkdownV2'], description: 'Text formatting mode (default: HTML)' }, + parse_mode: { type: 'string', enum: ['HTML', 'Markdown', 'MarkdownV2'], description: 'Formatting mode (default: HTML)' }, }, required: ['text'], }, @@ -37,18 +37,64 @@ const TOOLS = [ }, ]; -function json(id, result) { - return new Response(JSON.stringify({ jsonrpc: '2.0', id, result }), { +// --- Crypto helpers --- + +function b64url(bytes) { + return btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +function b64urlDecode(str) { + str = str.replace(/-/g, '+').replace(/_/g, '/'); + while (str.length % 4) str += '='; + return atob(str); +} + +async function hmacSign(secret, data) { + const key = await crypto.subtle.importKey( + 'raw', new TextEncoder().encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] + ); + const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data)); + return b64url(new Uint8Array(sig)); +} + +async function sha256b64url(str) { + const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str)); + return b64url(new Uint8Array(buf)); +} + +async function makeCode(secret, payload) { + const data = b64url(new TextEncoder().encode(JSON.stringify(payload))); + return `${data}.${await hmacSign(secret, data)}`; +} + +async function parseCode(secret, code) { + const i = code.lastIndexOf('.'); + if (i === -1) return null; + const [data, sig] = [code.slice(0, i), code.slice(i + 1)]; + if (sig !== await hmacSign(secret, data)) return null; + try { return JSON.parse(b64urlDecode(data)); } catch { return null; } +} + +// --- Response helpers --- + +function jsonRes(data, status = 200) { + return new Response(JSON.stringify(data), { + status, headers: { 'Content-Type': 'application/json', ...CORS }, }); } -function error(id, code, message) { - return new Response(JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } }), { - headers: { 'Content-Type': 'application/json', ...CORS }, - }); +function mcpRes(id, result) { + return jsonRes({ jsonrpc: '2.0', id, result }); } +function mcpErr(id, code, message) { + return jsonRes({ jsonrpc: '2.0', id, error: { code, message } }); +} + +// --- Telegram --- + async function tg(token, method, body) { const res = await fetch(`https://api.telegram.org/bot${token}/${method}`, { method: 'POST', @@ -58,115 +104,190 @@ async function tg(token, method, body) { return res.json(); } +// --- OAuth --- + +function oauthMeta(origin) { + return jsonRes({ + issuer: origin, + authorization_endpoint: `${origin}/authorize`, + token_endpoint: `${origin}/token`, + response_types_supported: ['code'], + grant_types_supported: ['authorization_code'], + code_challenge_methods_supported: ['S256'], + }); +} + +function authorizePage(params, err = '') { + const { state = '', code_challenge = '', redirect_uri = '', client_id = '' } = params; + return new Response(` + + + + + Telegram MCP — Authorize + + + +

Telegram MCP

+

Authorize Claude to access your Telegram bot.

+ ${err ? `

${err}

` : ''} +
+ + + + + + +
+ +`, { headers: { 'Content-Type': 'text/html;charset=utf-8' } }); +} + +async function handleAuthorize(request, env) { + if (request.method === 'GET') { + const p = Object.fromEntries(new URL(request.url).searchParams); + return authorizePage(p); + } + + const form = await request.formData(); + const state = form.get('state') || ''; + const code_challenge = form.get('code_challenge') || ''; + const redirect_uri = form.get('redirect_uri') || ''; + const client_id = form.get('client_id') || ''; + const secret = form.get('secret') || ''; + const params = { state, code_challenge, redirect_uri, client_id }; + + if (!env.MCP_SECRET) return authorizePage(params, 'Server error: MCP_SECRET not configured.'); + if (secret !== env.MCP_SECRET) return authorizePage(params, 'Invalid secret. Try again.'); + + const code = await makeCode(env.MCP_SECRET, { + code_challenge, + redirect_uri, + exp: Date.now() + 5 * 60 * 1000, + }); + + const dest = new URL(redirect_uri); + dest.searchParams.set('code', code); + dest.searchParams.set('state', state); + return Response.redirect(dest.toString(), 302); +} + +async function handleToken(request, env) { + let params; + const ct = request.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + params = await request.json(); + } else { + params = Object.fromEntries(await request.formData()); + } + + const { code = '', code_verifier = '', redirect_uri = '' } = params; + + if (!env.MCP_SECRET) return jsonRes({ error: 'server_error' }, 500); + + const payload = await parseCode(env.MCP_SECRET, code); + if (!payload) return jsonRes({ error: 'invalid_grant', error_description: 'Invalid code' }, 400); + if (Date.now() > payload.exp) return jsonRes({ error: 'invalid_grant', error_description: 'Code expired' }, 400); + if (payload.redirect_uri !== redirect_uri) return jsonRes({ error: 'invalid_grant', error_description: 'redirect_uri mismatch' }, 400); + + const challenge = await sha256b64url(code_verifier); + if (challenge !== payload.code_challenge) return jsonRes({ error: 'invalid_grant', error_description: 'PKCE verification failed' }, 400); + + return jsonRes({ access_token: env.MCP_SECRET, token_type: 'bearer' }); +} + +// --- MCP --- + +async function handleMCP(request, env) { + if (env.MCP_SECRET) { + if (request.headers.get('Authorization') !== `Bearer ${env.MCP_SECRET}`) { + return new Response('Unauthorized', { status: 401, headers: CORS }); + } + } + + let body; + try { body = await request.json(); } catch { return mcpErr(null, -32700, 'Parse error'); } + + const { id, method, params } = body; + + if (id === undefined) return new Response(null, { status: 202, headers: CORS }); + + switch (method) { + case 'initialize': + return mcpRes(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO }); + + case 'ping': + return mcpRes(id, {}); + + case 'tools/list': + return mcpRes(id, { tools: TOOLS }); + + case 'tools/call': { + const { name, arguments: args = {} } = params ?? {}; + const { TELEGRAM_TOKEN: token, TELEGRAM_CHAT_ID: chatId } = env; + + if (!token || !chatId) { + return mcpRes(id, { content: [{ type: 'text', text: 'Error: TELEGRAM_TOKEN or TELEGRAM_CHAT_ID not set' }], isError: true }); + } + + if (name === 'send_message') { + const result = await tg(token, 'sendMessage', { chat_id: chatId, text: args.text, parse_mode: args.parse_mode ?? 'HTML' }); + return mcpRes(id, { + content: [{ type: 'text', text: result.ok ? 'Message sent.' : `Error: ${result.description}` }], + isError: !result.ok, + }); + } + + if (name === 'get_updates') { + const result = await tg(token, 'getUpdates', { limit: Math.min(args.limit ?? 10, 100) }); + if (!result.ok) return mcpRes(id, { content: [{ type: 'text', text: `Error: ${result.description}` }], isError: true }); + const messages = result.result + .filter(u => u.message?.text) + .map(u => { + const { message: m } = u; + const from = m.from ? `${m.from.first_name}${m.from.username ? ` (@${m.from.username})` : ''}` : 'Unknown'; + return `[${new Date(m.date * 1000).toISOString()}] ${from}: ${m.text}`; + }) + .join('\n'); + return mcpRes(id, { content: [{ type: 'text', text: messages || 'No pending messages.' }] }); + } + + if (name === 'get_bot_info') { + const result = await tg(token, 'getMe', {}); + if (!result.ok) return mcpRes(id, { content: [{ type: 'text', text: `Error: ${result.description}` }], isError: true }); + const { first_name, username, id: botId } = result.result; + return mcpRes(id, { content: [{ type: 'text', text: `Bot: ${first_name} (@${username}), ID: ${botId}` }] }); + } + + return mcpErr(id, -32601, `Unknown tool: ${name}`); + } + + default: + return mcpErr(id, -32601, `Method not found: ${method}`); + } +} + +// --- Router --- + export default { async fetch(request, env) { - if (request.method === 'OPTIONS') { - return new Response(null, { status: 204, headers: CORS }); - } + const { method } = request; + const { pathname, origin } = new URL(request.url); - if (request.method !== 'POST') { - return new Response('Method Not Allowed', { status: 405, headers: CORS }); - } + if (method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); - if (env.MCP_SECRET) { - const auth = request.headers.get('Authorization'); - if (auth !== `Bearer ${env.MCP_SECRET}`) { - return new Response('Unauthorized', { status: 401, headers: CORS }); - } - } + if (pathname === '/.well-known/oauth-authorization-server') return oauthMeta(origin); + if (pathname === '/authorize') return handleAuthorize(request, env); + if (pathname === '/token' && method === 'POST') return handleToken(request, env); + if (pathname === '/' && method === 'POST') return handleMCP(request, env); - let body; - try { - body = await request.json(); - } catch { - return error(null, -32700, 'Parse error'); - } - - const { id, method, params } = body; - - // Notifications (no id) — no response needed - if (id === undefined) { - return new Response(null, { status: 202, headers: CORS }); - } - - switch (method) { - case 'initialize': - return json(id, { - protocolVersion: PROTOCOL_VERSION, - capabilities: { tools: {} }, - serverInfo: SERVER_INFO, - }); - - case 'ping': - return json(id, {}); - - case 'tools/list': - return json(id, { tools: TOOLS }); - - case 'tools/call': { - const { name, arguments: args = {} } = params ?? {}; - const { TELEGRAM_TOKEN: token, TELEGRAM_CHAT_ID: chatId } = env; - - if (!token || !chatId) { - return json(id, { - content: [{ type: 'text', text: 'Error: TELEGRAM_TOKEN or TELEGRAM_CHAT_ID not set' }], - isError: true, - }); - } - - if (name === 'send_message') { - const result = await tg(token, 'sendMessage', { - chat_id: chatId, - text: args.text, - parse_mode: args.parse_mode ?? 'HTML', - }); - return json(id, { - content: [{ type: 'text', text: result.ok ? 'Message sent.' : `Error: ${result.description}` }], - isError: !result.ok, - }); - } - - if (name === 'get_updates') { - const result = await tg(token, 'getUpdates', { limit: Math.min(args.limit ?? 10, 100) }); - if (!result.ok) { - return json(id, { - content: [{ type: 'text', text: `Error: ${result.description}` }], - isError: true, - }); - } - const messages = result.result - .filter(u => u.message?.text) - .map(u => { - const { message: m } = u; - const from = m.from ? `${m.from.first_name}${m.from.username ? ` (@${m.from.username})` : ''}` : 'Unknown'; - const time = new Date(m.date * 1000).toISOString(); - return `[${time}] ${from}: ${m.text}`; - }) - .join('\n'); - return json(id, { - content: [{ type: 'text', text: messages || 'No pending messages.' }], - }); - } - - if (name === 'get_bot_info') { - const result = await tg(token, 'getMe', {}); - if (!result.ok) { - return json(id, { - content: [{ type: 'text', text: `Error: ${result.description}` }], - isError: true, - }); - } - const { first_name, username, id: botId } = result.result; - return json(id, { - content: [{ type: 'text', text: `Bot: ${first_name} (@${username}), ID: ${botId}` }], - }); - } - - return error(id, -32601, `Unknown tool: ${name}`); - } - - default: - return error(id, -32601, `Method not found: ${method}`); - } + return new Response('Not Found', { status: 404, headers: CORS }); }, };