telegram-bot-mcp/mcp_bridge/__main__.py
Mikkel Georgsen 15e3582787 feat: switch OAuth to authorization code + PKCE flow
Claude Desktop uses authorization code flow, not client credentials.
Added /authorize endpoint that auto-approves (single-user setup) and
redirects with code. Token endpoint now supports both grant types.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:44:05 +00:00

142 lines
4 KiB
Python

"""Entry point: runs both Telegram bot and MCP server in one process."""
import asyncio
import logging
import signal
from starlette.middleware import Middleware
from starlette.requests import Request
from starlette.responses import JSONResponse, Response
from .config import MCP_HOST, MCP_PORT, MEDIA_DIR, DB_PATH
from .db import Database
from .telegram_bot import BridgeBot
from .mcp_server import mcp, init as init_mcp, custom_routes
from .auth import auth_routes, validate_bearer_token
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("mcp_bridge")
# Paths that don't require auth
PUBLIC_PATHS = {
"/.well-known/oauth-authorization-server",
"/authorize",
"/token",
"/api/health",
"/api/ingest", # Local-only, not exposed via NPM
}
class AuthMiddleware:
"""ASGI middleware that validates Bearer tokens on protected endpoints."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
return await self.app(scope, receive, send)
path = scope.get("path", "")
# Skip auth for public paths
if path in PUBLIC_PATHS:
return await self.app(scope, receive, send)
# Check Authorization header
headers = dict(scope.get("headers", []))
auth_header = headers.get(b"authorization", b"").decode()
if auth_header.startswith("Bearer "):
token = auth_header[7:]
if validate_bearer_token(token):
return await self.app(scope, receive, send)
# Reject — send 401
response = JSONResponse(
{"error": "unauthorized", "error_description": "Valid Bearer token required"},
status_code=401,
headers={"WWW-Authenticate": 'Bearer realm="mcp"'},
)
await response(scope, receive, send)
async def run_telegram_bot(bot: BridgeBot):
"""Run the telegram bot polling loop."""
app = bot.build_application()
await app.initialize()
await bot._post_init(app)
await app.start()
updater = app.updater
await updater.start_polling(drop_pending_updates=True)
logger.info("Telegram bot polling started")
try:
while True:
await asyncio.sleep(3600)
except asyncio.CancelledError:
logger.info("Telegram bot shutting down...")
await updater.stop()
await app.stop()
await app.shutdown()
async def run_mcp_server():
"""Run the FastMCP HTTP server with OAuth auth and custom routes."""
import uvicorn
# Get the FastMCP Starlette app
mcp_app = mcp.http_app()
# Add custom routes (API + OAuth)
mcp_app.routes.extend(custom_routes)
mcp_app.routes.extend(auth_routes)
# Wrap with auth middleware
authed_app = AuthMiddleware(mcp_app)
logger.info(f"MCP server starting on {MCP_HOST}:{MCP_PORT} (OAuth enabled)")
config = uvicorn.Config(authed_app, host=MCP_HOST, port=MCP_PORT, log_level="info")
server = uvicorn.Server(config)
await server.serve()
async def main():
"""Start both services."""
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
db = Database()
logger.info(f"Database initialized at {DB_PATH}")
init_mcp(db)
bot = BridgeBot(db)
telegram_task = asyncio.create_task(run_telegram_bot(bot))
mcp_task = asyncio.create_task(run_mcp_server())
loop = asyncio.get_event_loop()
def handle_signal():
logger.info("Received shutdown signal")
telegram_task.cancel()
mcp_task.cancel()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, handle_signal)
try:
await asyncio.gather(telegram_task, mcp_task, return_exceptions=True)
except asyncio.CancelledError:
pass
logger.info("MCP Bridge stopped")
if __name__ == "__main__":
asyncio.run(main())