151 lines
5.5 KiB
Python
151 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
import hmac
|
|
import os
|
|
from typing import Any
|
|
|
|
from starlette.applications import Starlette
|
|
from starlette.routing import Mount, Route
|
|
|
|
from .uploads import UploadEndpoint
|
|
|
|
DEFAULT_HTTP_HOST = "127.0.0.1"
|
|
DEFAULT_HTTP_PORT = 10087
|
|
DEFAULT_HTTP_PATH = "/mcp"
|
|
DEFAULT_TOKEN_ENV = "NOCOBASE_MCP_TOKEN"
|
|
ENABLE_UPLOADS_ENV = "NOCOBASE_MCP_ENABLE_UPLOADS"
|
|
UPLOAD_API_BASE_URL_ENV = "NOCOBASE_MCP_UPLOAD_API_BASE_URL"
|
|
UPLOAD_JWT_ENV = "NOCOBASE_MCP_UPLOAD_JWT"
|
|
UPLOAD_MAX_BYTES_ENV = "NOCOBASE_MCP_UPLOAD_MAX_BYTES"
|
|
API_BASE_URL_ENV = "NOCOBASE_MCP_API_BASE_URL"
|
|
API_TOKEN_ENV = "NOCOBASE_MCP_API_TOKEN"
|
|
DEFAULT_UPLOAD_MAX_BYTES = 25 * 1024 * 1024
|
|
|
|
|
|
def _bearer_token(authorization: str) -> str:
|
|
scheme, _, token = authorization.partition(" ")
|
|
if scheme.lower() != "bearer" or not token:
|
|
return ""
|
|
return token.strip()
|
|
|
|
|
|
def _authorization_header(scope: dict[str, Any]) -> str:
|
|
headers = {key.lower(): value for key, value in scope.get("headers", [])}
|
|
return headers.get(b"authorization", b"").decode("latin1")
|
|
|
|
|
|
class BearerTokenAuthMiddleware:
|
|
def __init__(self, app: Any, token: str) -> None:
|
|
if not token:
|
|
raise ValueError("HTTP MCP token must not be empty")
|
|
self.app = app
|
|
self.token = token
|
|
|
|
async def __call__(self, scope: dict[str, Any], receive: Any, send: Any) -> None:
|
|
if scope.get("type") != "http":
|
|
await self.app(scope, receive, send)
|
|
return
|
|
if str(scope.get("method") or "").upper() == "OPTIONS":
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 204,
|
|
"headers": [
|
|
(b"access-control-allow-origin", b"*"),
|
|
(b"access-control-allow-methods", b"POST, OPTIONS"),
|
|
(b"access-control-allow-headers", b"authorization, content-type, mcp-session-id"),
|
|
],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": b""})
|
|
return
|
|
token = _bearer_token(_authorization_header(scope))
|
|
if not hmac.compare_digest(token, self.token):
|
|
await send(
|
|
{
|
|
"type": "http.response.start",
|
|
"status": 401,
|
|
"headers": [(b"content-type", b"application/json"), (b"www-authenticate", b"Bearer")],
|
|
}
|
|
)
|
|
await send({"type": "http.response.body", "body": b'{"error":"unauthorized"}'})
|
|
return
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
def read_token_from_env(env_name: str) -> str:
|
|
token = os.environ.get(env_name, "").strip()
|
|
if not token:
|
|
raise RuntimeError(f"{env_name} must be set for HTTP MCP transport")
|
|
return token
|
|
|
|
|
|
def create_http_app(server: Any, token: str, upload_endpoint: UploadEndpoint | None = None) -> Any:
|
|
if upload_endpoint is None:
|
|
return BearerTokenAuthMiddleware(server.streamable_http_app(), token)
|
|
|
|
async def upload_route(request: Any) -> Any:
|
|
return await upload_endpoint(request)
|
|
|
|
mcp_app = server.streamable_http_app()
|
|
app = Starlette(
|
|
routes=[Route("/uploads", endpoint=upload_route, methods=["POST"]), Mount("/", app=mcp_app)],
|
|
lifespan=mcp_app.router.lifespan_context,
|
|
)
|
|
return BearerTokenAuthMiddleware(app, token)
|
|
|
|
|
|
def read_nocobase_environment() -> tuple[str | None, str | None, int]:
|
|
canonical_url = os.environ.get(API_BASE_URL_ENV, "").strip().rstrip("/")
|
|
canonical_token = os.environ.get(API_TOKEN_ENV, "").strip()
|
|
legacy_url = os.environ.get(UPLOAD_API_BASE_URL_ENV, "").strip().rstrip("/")
|
|
legacy_token = os.environ.get(UPLOAD_JWT_ENV, "").strip()
|
|
|
|
canonical_any = bool(canonical_url or canonical_token)
|
|
legacy_any = bool(legacy_url or legacy_token)
|
|
if canonical_any and not (canonical_url and canonical_token):
|
|
raise RuntimeError(f"{API_BASE_URL_ENV} and {API_TOKEN_ENV} must be set together")
|
|
if legacy_any and not (legacy_url and legacy_token):
|
|
raise RuntimeError(f"{UPLOAD_API_BASE_URL_ENV} and {UPLOAD_JWT_ENV} must be set together")
|
|
if canonical_any and legacy_any and (canonical_url != legacy_url or canonical_token != legacy_token):
|
|
raise RuntimeError("Canonical and legacy NocoBase credential environment variables conflict")
|
|
if canonical_any:
|
|
api_base_url, api_token = canonical_url, canonical_token
|
|
elif legacy_any:
|
|
api_base_url, api_token = legacy_url, legacy_token
|
|
else:
|
|
api_base_url, api_token = None, None
|
|
|
|
raw_max_bytes = os.environ.get(UPLOAD_MAX_BYTES_ENV, str(DEFAULT_UPLOAD_MAX_BYTES)).strip()
|
|
try:
|
|
max_bytes = int(raw_max_bytes)
|
|
except ValueError as error:
|
|
raise RuntimeError(f"{UPLOAD_MAX_BYTES_ENV} must be a positive integer") from error
|
|
if max_bytes <= 0:
|
|
raise RuntimeError(f"{UPLOAD_MAX_BYTES_ENV} must be a positive integer")
|
|
return api_base_url, api_token, max_bytes
|
|
|
|
|
|
_read_token_from_env = read_token_from_env
|
|
_read_nocobase_environment = read_nocobase_environment
|
|
|
|
__all__ = [
|
|
"API_BASE_URL_ENV",
|
|
"API_TOKEN_ENV",
|
|
"BearerTokenAuthMiddleware",
|
|
"DEFAULT_HTTP_HOST",
|
|
"DEFAULT_HTTP_PATH",
|
|
"DEFAULT_HTTP_PORT",
|
|
"DEFAULT_TOKEN_ENV",
|
|
"DEFAULT_UPLOAD_MAX_BYTES",
|
|
"ENABLE_UPLOADS_ENV",
|
|
"UPLOAD_API_BASE_URL_ENV",
|
|
"UPLOAD_JWT_ENV",
|
|
"UPLOAD_MAX_BYTES_ENV",
|
|
"create_http_app",
|
|
"read_nocobase_environment",
|
|
"read_token_from_env",
|
|
"_read_nocobase_environment",
|
|
"_read_token_from_env",
|
|
]
|