- 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>
141 lines
4 KiB
Python
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())
|