Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,11 @@ whitesource/
.whitesource.lock
mend
mend-cli
*.mend.json
*.mend.json
# Local credentials (never commit)
dist/api-key.json
api-key.json

# Local/PyInstaller build artifacts
bzm-mcp-linux-arm64.spec
*.spec
174 changes: 174 additions & 0 deletions config/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
"""
Copyright 2025 Perforce Software, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from __future__ import annotations

from typing import Optional, Protocol, runtime_checkable

from mcp.server.fastmcp import Context, FastMCP
from starlette.requests import Request
from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Receive, Scope, Send

from config.token import BzmToken, BzmTokenError

BZM_TOKEN_STATE_ATTR = "token"
BZM_USER_CONFIG_STATE_ATTR = "user_config"
BZM_CONFIRMATION_MODE_HEADER = "confirmation-mode"


def _normalize_confirmation_mode(raw_value: Optional[str]) -> str:
value = (raw_value or "").strip().upper()
if value in ("DELETE", "CUD", "DISABLE"):
return value
return "DELETE"


# Unauthenticated probe paths for orchestrators / load balancers.
HEALTH_PATHS = frozenset({"/health", "/healthz"})


class AuthError(Exception):
"""Raised when Authorization cannot be parsed into credentials."""


@runtime_checkable
class AuthPort(Protocol):
"""Resolves the BlazeMeter API token for the current tool invocation."""

def get_token(self, ctx: Context) -> Optional[BzmToken]:
...


class StdioAuthProvider:
"""Process-lifetime token from env / api-key.json / Docker secrets."""

def __init__(self, token: Optional[BzmToken]):
self._token = token

def get_token(self, ctx: Context) -> Optional[BzmToken]:
return self._token


class HttpAuthProvider:
"""Per-request token attached by Bearer auth middleware to request.state."""

def get_token(self, ctx: Context) -> Optional[BzmToken]:
request = ctx.request_context.request
if request is None:
return None
return getattr(request.state, BZM_TOKEN_STATE_ATTR, None)


def parse_authorization_header(value: Optional[str]) -> BzmToken:
"""
Parse ``Authorization: Bearer <credentials>`` into a BzmToken.

Credentials may be ``id:secret`` or base64(``id:secret``). Does not call
the BlazeMeter API — parse only.
"""
if not value or not value.strip():
raise AuthError("Missing Authorization header")

scheme, _, credentials = value.strip().partition(" ")
if scheme.lower() != "bearer" or not credentials.strip():
raise AuthError("Authorization header must use Bearer scheme")

try:
return BzmToken.from_bearer_credentials(credentials.strip())
except BzmTokenError as exc:
raise AuthError("Unparseable Bearer credentials") from exc


class BearerAuthMiddleware:
"""
HTTP gate: require a parseable Bearer token on every request.

Attaches BzmToken to ``request.state``; does not validate against BlazeMeter.
"""

def __init__(self, app: ASGIApp):
self.app = app

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return

if scope.get("method") == "OPTIONS":
await self.app(scope, receive, send)
return

path = scope.get("path", "") or ""
if path in HEALTH_PATHS:
await self.app(scope, receive, send)
return

request = Request(scope, receive)
try:
token = parse_authorization_header(request.headers.get("authorization"))
except AuthError:
response = JSONResponse(
{"error": "Unauthorized"},
status_code=401,
headers={"WWW-Authenticate": "Bearer"},
)
await response(scope, receive, send)
return

setattr(request.state, BZM_TOKEN_STATE_ATTR, token)
raw_confirmation_mode = request.headers.get(BZM_CONFIRMATION_MODE_HEADER)
if raw_confirmation_mode is not None and raw_confirmation_mode.strip():
confirmation_mode = _normalize_confirmation_mode(raw_confirmation_mode)
else:
confirmation_mode = "DELETE"
setattr(
request.state,
BZM_USER_CONFIG_STATE_ATTR,
{"confirmation_mode": confirmation_mode},
)
await self.app(scope, receive, send)


def register_health_routes(mcp: FastMCP) -> None:
"""Register unauthenticated health probes on the FastMCP ASGI app."""

@mcp.custom_route("/health", methods=["GET"])
async def health(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})

@mcp.custom_route("/healthz", methods=["GET"])
async def healthz(_request: Request) -> JSONResponse:
return JSONResponse({"status": "ok"})


def run_streamable_http(mcp: FastMCP) -> None:
"""Serve FastMCP over streamable HTTP with Bearer auth middleware."""
import anyio
import uvicorn

register_health_routes(mcp)

async def _serve() -> None:
app = BearerAuthMiddleware(mcp.streamable_http_app())
config = uvicorn.Config(
app,
host=mcp.settings.host,
port=mcp.settings.port,
log_level=mcp.settings.log_level.lower(),
)
await uvicorn.Server(config).serve()

anyio.run(_serve)
50 changes: 50 additions & 0 deletions config/context_resolution.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
"""
Copyright 2025 Perforce Software, Inc.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from typing import Any

from config.auth import BZM_TOKEN_STATE_ATTR, BZM_USER_CONFIG_STATE_ATTR


def get_request_context(ctx: Any) -> Any:
return getattr(ctx, "request_context", None)


def get_request_state(ctx: Any) -> Any:
request_context = get_request_context(ctx)
request = getattr(request_context, "request", None)
return getattr(request, "state", None)


def resolve_ctx_user_config(ctx: Any) -> dict[str, Any]:
request_context = get_request_context(ctx)
request_state = get_request_state(ctx)

request_context_config = getattr(request_context, BZM_USER_CONFIG_STATE_ATTR, None)
if isinstance(request_context_config, dict):
return request_context_config

request_state_config = getattr(request_state, BZM_USER_CONFIG_STATE_ATTR, None)
if isinstance(request_state_config, dict):
return request_state_config

return {}


def resolve_ctx_token(ctx: Any) -> Any:
user_config = resolve_ctx_user_config(ctx)
request_state = get_request_state(ctx)
request_state_token = getattr(request_state, BZM_TOKEN_STATE_ATTR, None)
return user_config.get("token") or request_state_token
138 changes: 138 additions & 0 deletions config/file_access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""
File access abstractions for upload-oriented tools.
"""
from __future__ import annotations

import os
from abc import ABC, abstractmethod
from pathlib import Path

from config.storage import SessionScope


class FileAccessPort(ABC):
"""Abstraction for file path mapping and file content reads."""

@abstractmethod
def map_paths(self, file_paths: list[str], scope: SessionScope | None = None) -> list[str]:
raise NotImplementedError

@abstractmethod
def exists(self, file_path: str, scope: SessionScope | None = None) -> bool:
raise NotImplementedError

@abstractmethod
def is_file(self, file_path: str, scope: SessionScope | None = None) -> bool:
raise NotImplementedError

@abstractmethod
def read_bytes(self, file_path: str, scope: SessionScope | None = None) -> bytes:
raise NotImplementedError


class LocalPathFileSource(FileAccessPort):
"""Use filesystem paths as provided by the caller."""

def map_paths(self, file_paths: list[str], scope: SessionScope | None = None) -> list[str]:
return file_paths

def exists(self, file_path: str, scope: SessionScope | None = None) -> bool:
return os.path.exists(file_path)

def is_file(self, file_path: str, scope: SessionScope | None = None) -> bool:
return os.path.isfile(file_path)

def read_bytes(self, file_path: str, scope: SessionScope | None = None) -> bytes:
return Path(file_path).read_bytes()


class DockerMappedFileSource(LocalPathFileSource):
"""
Map host paths into the mounted container path and then read locally.

This mirrors the old path mapper behavior for Docker stdio mode.
"""

def __init__(
self,
source_working_directory: str,
container_working_directory: str = "/home/bzm-mcp/working_directory",
) -> None:
self._source_working_directory = Path(source_working_directory).resolve()
self._container_working_directory = container_working_directory.rstrip("/\\")

def map_paths(self, file_paths: list[str], scope: SessionScope | None = None) -> list[str]:
mapped_paths: list[str] = []
for file_path in file_paths:
abs_file_path = Path(file_path).resolve()
try:
relative_path = abs_file_path.relative_to(self._source_working_directory)
mapped_path = (
f"{self._container_working_directory}/{relative_path.as_posix()}"
)
mapped_paths.append(mapped_path)
except ValueError:
mapped_paths.append(file_path)
return mapped_paths


class StorageFileSource(FileAccessPort):
"""
Mock placeholder for streamable-http file access.

Real implementation will be provided in a future task where file-upload UI
mediates uploads and storage API integration.
"""

def __init__(self, base_url: str) -> None:
self._base_url = base_url.rstrip("/")

def ensure_available(self) -> None:
# Mocked source: no network checks for now.
return None

def map_paths(self, file_paths: list[str], scope: SessionScope | None = None) -> list[str]:
# Keep paths untouched until backend file-source semantics are defined.
return file_paths

def exists(self, file_path: str, scope: SessionScope | None = None) -> bool:
# Mocked behavior: storage-backed files are not available yet.
return False

def is_file(self, file_path: str, scope: SessionScope | None = None) -> bool:
return False

def read_bytes(self, file_path: str, scope: SessionScope | None = None) -> bytes:
raise NotImplementedError(
"StorageFileSource.read_bytes is not implemented yet. "
"Use file-upload UI flow until storage-backed file access is implemented."
)


def build_file_access(transport: str) -> FileAccessPort:
"""
Build file-access implementation for the runtime transport.

- Docker stdio uses path mapping (host -> mounted container path).
- Streamable HTTP uses storage API-backed file source.
- Other modes use local path access.
"""
if transport == "streamable-http":
base_url = os.getenv("BZM_STORAGE_API_BASE_URL", "").strip()
if not base_url:
raise ValueError(
"BZM_STORAGE_API_BASE_URL is required for streamable-http transport."
)
return StorageFileSource(base_url=base_url)

is_docker = os.getenv("MCP_DOCKER", "false").lower() == "true"
if transport == "stdio" and is_docker:
source_dir = os.getenv("SOURCE_WORKING_DIRECTORY")
if not source_dir:
raise ValueError(
"Working directory must be set in the Docker catalog configuration."
"Without volume mount, actions like upload assets will not work."
"Lack of volume mount results in missing SOURCE_WORKING_DIRECTORY environment variable"
)
return DockerMappedFileSource(source_working_directory=source_dir)
return LocalPathFileSource()
Loading