telegram-bot-mcp/mcp_bridge/__main__.py
Mikkel Georgsen 205b978b89 feat: add OAuth client credentials auth to MCP server
- OAuth 2.0 discovery at /.well-known/oauth-authorization-server
- Token endpoint at /token (client_credentials grant)
- Bearer token middleware on /mcp (all MCP requests require auth)
- Health, ingest, and OAuth endpoints remain public
- Tokens expire after 1 hour, stored hashed in memory

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

141 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",
"/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())