Защита MCP-сервера через OAuth 2.1 и Scalekit — руководство по анализу сентимента акций

Обзор

В этом руководстве показано, как реализовать OAuth 2.1 для MCP-сервера с помощью Scalekit на примере сервиса анализа сентимента новостей о фондовом рынке. Практическая цель — открыть endpoint метаданных для обнаружения MCP-клиентами и добавить middleware авторизации для контроля доступа по токенам. Scalekit берет на себя сложную логику OAuth, поэтому вам не нужно вручную управлять выдачей, обновлением и проверкой токенов.

Установка зависимостей

Установите необходимые Python-пакеты:

pip install fastapi fastmcp mcp scalekit-sdk-python

Также понадобится Node.js для запуска MCP Inspector при тестировании. Скачайте Node.js с nodejs.org и установите.

Alpha Vantage API

Чтобы получить сентимент новостей, запросите бесплатный API-ключ на Alpha Vantage:

Настройка Scalekit

  1. Зарегистрируйтесь на scalekit.com и войдите в аккаунт.
  2. Активируйте Full-Stack Auth.
  3. В Authorization добавьте permission со значениями:

Permissions соответствуют областям (scopes), ограничивающим доступ к ресурсам. Для примера permission news:read разрешает получение данных сентимента новостей.

  1. Добавьте MCP сервер через MCP Servers → Add MCP Server. Заполните Server Name и Resource Identifier. Для локального теста используйте:
http://localhost:10000/mcp/

Обратите внимание: FastMCP автоматически добавляет /mcp, поэтому оставьте завершающий слеш.

Установите scope — news:read. После создания сервера запомните MCP Server Identifier (например, res_88056357768398086).

Пример metadata

Путь endpoint’а и пример JSON метаданных:

/.well-known/oauth-protected-resource/mcp
{
  "authorization_servers": [
    "https://zapp.scalekit.dev/resources/res_88056357768398086"
  ],
  "bearer_methods_supported": ["header"],
  "resource": "http://localhost:10000/mcp/",
  "resource_documentation": "http://localhost:10000/mcp/docs",
  "scopes_supported": ["news:read"]
}
  1. В Settings → API Credentials скопируйте Client ID и Environment URL. Нажмите Generate New Secret, чтобы получить Client Secret — сохраните их безопасно.

Переменные окружения (.env)

Создайте .env файл с переменными и заполните полученными значениями:

ALPHA_VANTAGE_API_KEY=<YOUR_ALPHA_VANTAGE_API_KEY>
METADATA_JSON_RESPONSE=<YOUR_METADATA_JSON_RESPONSE>

SCALEKIT_ENVIRONMENT_URL=<YOUR_SCALEKIT_ENVIRONMENT_URL>
SCALEKIT_CLIENT_ID=<YOUR_SCALEKIT_CLIENT_ID>
SCALEKIT_CLIENT_SECRET=<YOUR_SCALEKIT_CLIENT_SECRET>
SCALEKIT_RESOURCE_METADATA_URL=<YOUR_SCALEKIT_RESOURCE_METADATA_URL>
SCALEKIT_AUTHORIZATION_SERVERS=<YOUR_SCALEKIT_AUTHORIZATION_SERVERS>
SCALEKIT_AUDIENCE_NAME=<YOUR_SCALEKIT_AUDIENCE_NAME>
SCALEKIT_RESOUCE_NAME=<YOUR_SCALEKIT_RESOURCE_NAME>
SCALEKIT_RESOUCE_DOCS_URL=<YOUR_SCALEKIT_RESOURCE_DOCS_URL>

Коротко о ключевых переменных:

http://localhost:10000/.well-known/oauth-protected-resource/mcp
https://<your-subdomain>.scalekit.dev/resources/res_***************

Файл конфигурации (config.py)

Создайте config.py для загрузки переменных окружения:

import os
from dotenv import load_dotenv

load_dotenv()

class Settings():
    ALPHA_VANTAGE_API_KEY = os.environ.get('ALPHA_VANTAGE_API_KEY')
    METADATA_JSON_RESPONSE = os.environ.get('METADATA_JSON_RESPONSE')
    SCALEKIT_ENVIRONMENT_URL = os.environ.get('SCALEKIT_ENVIRONMENT_URL')
    SCALEKIT_CLIENT_ID = os.environ.get('SCALEKIT_CLIENT_ID')
    SCALEKIT_CLIENT_SECRET = os.environ.get('SCALEKIT_CLIENT_SECRET')
    SCALEKIT_RESOURCE_METADATA_URL = os.environ.get('SCALEKIT_RESOURCE_METADATA_URL')
    SCALEKIT_AUTHORIZATION_SERVERS = os.environ.get('SCALEKIT_AUTHORIZATION_SERVERS')
    SCALEKIT_AUDIENCE_NAME = os.environ.get('SCALEKIT_AUDIENCE_NAME')
    SCALEKIT_RESOUCE_NAME = os.environ.get('SCALEKIT_RESOUCE_NAME')
    SCALEKIT_RESOUCE_DOCS_URL = os.environ.get('SCALEKIT_RESOUCE_DOCS_URL')
    PORT = 10000
    

settings = Settings()

Логика извлечения сентимента (finance.py)

Модуль обращается к Alpha Vantage по функции NEWS_SENTIMENT и собирает заголовки и краткие сведения о трех последних статьях.

from mcp.server.fastmcp import FastMCP
from typing import Any
import os
import httpx
from typing import Dict, List
from config import settings

# Create an MCP server
mcp = FastMCP("finance-news")

BASE_URL = "https://www.alphavantage.co/query"

async def call_alpha_vantage(endpoint: str, params: dict[str, Any]) -> dict[str, Any] | None:
    """Generic async caller to Alpha Vantage."""
    params["apikey"] = settings.ALPHA_VANTAGE_API_KEY
    params["function"] = endpoint
    async with httpx.AsyncClient() as client:
        try:
            response = await client.get(BASE_URL, params=params, timeout=30.0)
            response.raise_for_status()
            return response.json()
        except Exception:
            return None

@mcp.tool()
async def get_news_sentiment(ticker: str) -> str:
    """Get news sentiment data for a stock ticker.

    Args:
        ticker: Stock ticker symbol (e.g., MSFT, AAPL)
    """
    data = await call_alpha_vantage("NEWS_SENTIMENT", {"tickers": ticker.upper()})
    if not data or "feed" not in data:
        return "Couldn't retrieve news sentiment."

    articles = data["feed"][:3]
    result = []
    for item in articles:
        result.append(f"""
     {item['title']}
    Summary: {item['summary']}
    Source: {item['source']} | Published: {item['time_published']}
    """)
    return "n---n".join(result)

Не изменяйте этот код — он используется как инструмент MCP.

Middleware авторизации

Middleware проверяет Bearer-токен через Scalekit. Публичные запросы к /.well-known/ пропускаются, остальные требуют валидного токена. При ошибке возвращается 401 с заголовком WWW-Authenticate, указывающим на metadata URL.

import json
import logging
from fastapi import HTTPException, Request
from fastapi.security import HTTPBearer
from fastapi.responses import JSONResponse
from scalekit import ScalekitClient
from starlette.middleware.base import BaseHTTPMiddleware

from config import settings

# Configure logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)

# Security scheme for Bearer token
security = HTTPBearer()

# Initialize ScaleKit client
scalekit_client = ScalekitClient(
    settings.SCALEKIT_ENVIRONMENT_URL,
    settings.SCALEKIT_CLIENT_ID,
    settings.SCALEKIT_CLIENT_SECRET
)

# Authentication middleware
class AuthMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next):
        if request.url.path.startswith("/.well-known/"):
            return await call_next(request)

        try:
            auth_header = request.headers.get("Authorization")
            if not auth_header or not auth_header.startswith("Bearer "):
                raise HTTPException(status_code=401, detail="Missing or invalid authorization header")

            token = auth_header.split(" ")[1]

            request_body = await request.body()
            
            # Parse JSON from bytes
            try:
                request_data = json.loads(request_body.decode('utf-8'))
            except (json.JSONDecodeError, UnicodeDecodeError):
                request_data = {}
            
            try:
                scalekit_client.validate_access_token(token)
                
            except Exception as e:
                raise HTTPException(status_code=401, detail="Token validation failed")

        except HTTPException as e:
            return JSONResponse(
                status_code=e.status_code,
                content={"error": "unauthorized" if e.status_code == 401 else "forbidden", "error_description": e.detail},
                headers={
                    "WWW-Authenticate": f'Bearer realm="OAuth", resource_metadata="{settings.SCALEKIT_RESOURCE_METADATA_URL}"'
                }
            )

        return await call_next(request)

MCP сервер (server.py)

Скрипт создает FastAPI-приложение, монтирует MCP-приложение, добавляет CORS и AuthMiddleware, а также endpoint метаданных, требуемый для OAuth 2.1.

import contextlib
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
import json
from auth import AuthMiddleware
from config import settings
from finance import mcp as finance_news_server

# Create a combined lifespan to manage the MCP session manager
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
    async with finance_news_server.session_manager.run():
        yield

app = FastAPI(lifespan=lifespan)

# Add CORS middleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # In production, specify your actual origins
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
    allow_headers=["*"],
)

# MCP well-known endpoint
@app.get("/.well-known/oauth-protected-resource/mcp")
async def oauth_protected_resource_metadata():
    """
    OAuth 2.0 Protected Resource Metadata endpoint for MCP client discovery.
    Required by the MCP specification for authorization server discovery.
    """

    return {
        "authorization_servers": [settings.SCALEKIT_AUTHORIZATION_SERVERS],
        "bearer_methods_supported": ["header"],
        "resource": settings.SCALEKIT_RESOURCE_NAME,
        "resource_documentation": settings.SCALEKIT_RESOURCE_DOCS_URL,
        "scopes_supported": [
          "mcp:tools:news:read"
        ],
    }

# Create and mount the MCP server with authentication
mcp_server = finance_news_server.streamable_http_app()
app.add_middleware(AuthMiddleware)
app.mount("/", mcp_server)

def main():
    """Main entry point for the MCP server."""
    uvicorn.run(app, host="localhost", port=settings.PORT, log_level="debug")

if __name__ == "__main__":
    main()

Запуск и тестирование

Запустите сервер:

python server.py

В другом терминале запустите инспектор:

npx @modelcontextprotocol/inspector

В качестве URL сервера укажите http://localhost:10000/mcp. При отсутствии корректных учетных данных вы увидите ошибку соединения. Введите Bearer токен, созданный в Scalekit, и инспектор сможет аутентифицироваться и вызывать инструменты MCP.

Рекомендации

Полные коды и дополнительные материалы доступны в репозитории и на странице проекта.