Coverage for mcpgateway / main.py: 99%
4551 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-06 00:56 +0100
1# -*- coding: utf-8 -*-
2# pylint: disable=wrong-import-position, import-outside-toplevel, no-name-in-module
3"""Location: ./mcpgateway/main.py
4Copyright 2025
5SPDX-License-Identifier: Apache-2.0
6Authors: Mihai Criveti
8ContextForge AI Gateway - Main FastAPI Application.
10This module defines the core FastAPI application for the Model Context Protocol (MCP) Gateway.
11It serves as the entry point for handling all HTTP and WebSocket traffic.
13Features and Responsibilities:
14- Initializes and orchestrates services for tools, resources, prompts, servers, gateways, and roots.
15- Supports full MCP protocol operations: initialize, ping, notify, complete, and sample.
16- Integrates authentication (JWT and basic), CORS, caching, and middleware.
17- Serves a rich Admin UI for managing gateway entities via HTMX-based frontend.
18- Exposes routes for JSON-RPC, SSE, and WebSocket transports.
19- Manages application lifecycle including startup and graceful shutdown of all services.
21Structure:
22- Declares routers for MCP protocol operations and administration.
23- Registers dependencies (e.g., DB sessions, auth handlers).
24- Applies middleware including custom documentation protection.
25- Configures resource caching and session registry using pluggable backends.
26- Provides OpenAPI metadata and redirect handling depending on UI feature flags.
27"""
29# Standard
30import asyncio
31import base64
32from contextlib import asynccontextmanager, suppress
33from datetime import datetime, timezone
34from functools import lru_cache
35import hashlib
36import hmac
37import html
38import json
39import logging
40import re
41import signal
42import sys
43import threading
44from typing import Any, AsyncIterator, Dict, List, Optional, TypeAlias, Union
45from urllib.parse import urlparse, urlunparse
46import uuid
47import warnings
49# Third-Party
50from fastapi import APIRouter, Body, Depends, FastAPI, HTTPException, Query, Request, status, WebSocket, WebSocketDisconnect
51from fastapi.background import BackgroundTasks
52from fastapi.exception_handlers import request_validation_exception_handler as fastapi_default_validation_handler
53from fastapi.exceptions import RequestValidationError
54from fastapi.middleware.cors import CORSMiddleware
55from fastapi.responses import JSONResponse, RedirectResponse, Response, StreamingResponse
56from fastapi.security import HTTPAuthorizationCredentials
57from fastapi.staticfiles import StaticFiles
58from fastapi.templating import Jinja2Templates
59from jinja2 import Environment, FileSystemLoader
60from jsonpath_ng.ext import parse
61from jsonpath_ng.jsonpath import JSONPath
62import orjson
63from pydantic import ValidationError
64from sqlalchemy import text
65from sqlalchemy.exc import IntegrityError
66from sqlalchemy.orm import Session
67from starlette.middleware.base import BaseHTTPMiddleware
68from starlette.requests import Request as starletteRequest
69from starlette.responses import Response as starletteResponse
70from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
72# First-Party
73# Import the admin routes from the new module
74from mcpgateway import __version__
75from mcpgateway import version as version_module
76from mcpgateway.admin import admin_router, set_logging_service
77from mcpgateway.auth import _check_token_revoked_sync, _lookup_api_token_sync, get_current_user, get_user_team_roles, normalize_token_teams, resolve_session_teams
78from mcpgateway.bootstrap_db import main as bootstrap_db
79from mcpgateway.cache import ResourceCache, SessionRegistry
80from mcpgateway.common.models import InitializeResult
81from mcpgateway.common.models import JSONRPCError as PydanticJSONRPCError
82from mcpgateway.common.models import ListResourceTemplatesResult, LogLevel, Root
83from mcpgateway.common.validators import SecurityValidator
84from mcpgateway.config import settings
85from mcpgateway.db import refresh_slugs_on_startup, SessionLocal
86from mcpgateway.db import Tool as DbTool
87from mcpgateway.handlers.sampling import SamplingHandler
88from mcpgateway.middleware.compression import SSEAwareCompressMiddleware
89from mcpgateway.middleware.correlation_id import CorrelationIDMiddleware
90from mcpgateway.middleware.http_auth_middleware import HttpAuthMiddleware, run_pre_request_hooks
91from mcpgateway.middleware.protocol_version import MCPProtocolVersionMiddleware
92from mcpgateway.middleware.rbac import _ACCESS_DENIED_MSG, get_current_user_with_permissions, PermissionChecker, require_permission
93from mcpgateway.middleware.request_logging_middleware import RequestLoggingMiddleware
94from mcpgateway.middleware.security_headers import SecurityHeadersMiddleware
95from mcpgateway.middleware.token_scoping import token_scoping_middleware
96from mcpgateway.middleware.validation_middleware import ValidationMiddleware
97from mcpgateway.observability import init_telemetry, OpenTelemetryRequestMiddleware, otel_tracing_enabled
98from mcpgateway.plugins.framework import HttpHookType, PluginError, PluginManager, PluginViolationError, PromptHookType, ResourceHookType
99from mcpgateway.plugins.framework.constants import PLUGIN_VIOLATION_CODE_MAPPING, PluginViolationCode, VALID_HTTP_STATUS_CODES
100from mcpgateway.routers.server_well_known import router as server_well_known_router
101from mcpgateway.routers.well_known import router as well_known_router
102from mcpgateway.schemas import (
103 A2AAgentCreate,
104 A2AAgentRead,
105 A2AAgentUpdate,
106 CursorPaginatedA2AAgentsResponse,
107 CursorPaginatedGatewaysResponse,
108 CursorPaginatedPromptsResponse,
109 CursorPaginatedResourcesResponse,
110 CursorPaginatedServersResponse,
111 CursorPaginatedToolsResponse,
112 GatewayCreate,
113 GatewayRead,
114 GatewayRefreshResponse,
115 GatewayUpdate,
116 JsonPathModifier,
117 MetricsResponse,
118 PromptCreate,
119 PromptExecuteArgs,
120 PromptRead,
121 PromptUpdate,
122 ResourceCreate,
123 ResourceRead,
124 ResourceSubscription,
125 ResourceUpdate,
126 RPCRequest,
127 ServerCreate,
128 ServerRead,
129 ServerUpdate,
130 TaggedEntity,
131 TagInfo,
132 ToolCreate,
133 ToolRead,
134 ToolUpdate,
135)
136from mcpgateway.services.a2a_service import A2AAgentError, A2AAgentNameConflictError, A2AAgentNotFoundError, A2AAgentService
137from mcpgateway.services.cancellation_service import cancellation_service
138from mcpgateway.services.completion_service import CompletionService
139from mcpgateway.services.content_security import ContentSizeError, ContentTypeError
140from mcpgateway.services.email_auth_service import EmailAuthService
141from mcpgateway.services.export_service import ExportError, ExportService
142from mcpgateway.services.gateway_service import GatewayConnectionError, GatewayDuplicateConflictError, GatewayError, GatewayNameConflictError, GatewayNotFoundError
143from mcpgateway.services.import_service import ConflictStrategy, ImportConflictError
144from mcpgateway.services.import_service import ImportError as ImportServiceError
145from mcpgateway.services.import_service import ImportService, ImportValidationError
146from mcpgateway.services.log_aggregator import get_log_aggregator
147from mcpgateway.services.logging_service import LoggingService
148from mcpgateway.services.metrics import setup_metrics
149from mcpgateway.services.permission_service import PermissionService
150from mcpgateway.services.prompt_service import PromptError, PromptLockConflictError, PromptNameConflictError, PromptNotFoundError
151from mcpgateway.services.resource_service import ResourceError, ResourceLockConflictError, ResourceNotFoundError, ResourceURIConflictError
152from mcpgateway.services.server_service import ServerError, ServerLockConflictError, ServerNameConflictError, ServerNotFoundError
153from mcpgateway.services.tag_service import TagService
154from mcpgateway.services.tool_service import ToolError, ToolLockConflictError, ToolNameConflictError, ToolNotFoundError
155from mcpgateway.transports.rust_mcp_runtime_proxy import RustMCPRuntimeProxy
156from mcpgateway.transports.sse_transport import SSETransport
157from mcpgateway.transports.streamablehttp_transport import (
158 _validate_streamable_session_access,
159 get_streamable_http_auth_context,
160 SessionManagerWrapper,
161 set_shared_session_registry,
162 streamable_http_auth,
163 user_context_var,
164)
165from mcpgateway.utils.db_isready import wait_for_db_ready
166from mcpgateway.utils.error_formatter import ErrorFormatter
167from mcpgateway.utils.internal_http import internal_loopback_base_url, internal_loopback_verify
168from mcpgateway.utils.metadata_capture import MetadataCapture
169from mcpgateway.utils.orjson_response import ORJSONResponse
170from mcpgateway.utils.passthrough_headers import set_global_passthrough_headers
171from mcpgateway.utils.redis_client import close_redis_client, get_redis_client
172from mcpgateway.utils.redis_isready import wait_for_redis_ready
173from mcpgateway.utils.retry_manager import ResilientHttpClient
174from mcpgateway.utils.token_scoping import validate_server_access
175from mcpgateway.utils.trace_context import clear_trace_context, set_trace_context_from_teams, set_trace_session_id
176from mcpgateway.utils.verify_credentials import extract_websocket_bearer_token, is_proxy_auth_trust_active, require_admin_auth, require_docs_auth_override, verify_jwt_token
177from mcpgateway.validation.jsonrpc import JSONRPCError
178from mcpgateway.version import router as version_router
180# Initialize logging service first
181logging_service = LoggingService()
182logger = logging_service.get_logger("mcpgateway")
184# Share the logging service with admin module
185set_logging_service(logging_service)
187# Note: Logging configuration is handled by LoggingService during startup
188# Don't use basicConfig here as it conflicts with our dual logging setup
190# Wait for database to be ready before creating tables
191wait_for_db_ready(max_tries=int(settings.db_max_retries), interval=int(settings.db_retry_interval_ms) / 1000, sync=True) # Converting ms to s
193# Create database tables
194try:
195 loop = asyncio.get_running_loop()
196except RuntimeError:
197 asyncio.run(bootstrap_db())
198else:
199 loop.create_task(bootstrap_db())
201# Initialize plugin manager as a singleton.
202_PLUGINS_ENABLED = settings.plugins.enabled
203if _PLUGINS_ENABLED:
204 _plugin_settings = settings.plugins
205 # First-Party
206 from mcpgateway.plugins.policy import HOOK_PAYLOAD_POLICIES # noqa: E402
208 plugin_manager: PluginManager | None = PluginManager(_plugin_settings.config_file, timeout=_plugin_settings.plugin_timeout, hook_policies=HOOK_PAYLOAD_POLICIES)
209else:
210 plugin_manager = None # pylint: disable=invalid-name
213# First-Party
214# First-Party - import module-level service singletons
215from mcpgateway.services.gateway_service import gateway_service # noqa: E402
216from mcpgateway.services.prompt_service import prompt_service # noqa: E402
217from mcpgateway.services.resource_service import resource_service # noqa: E402
218from mcpgateway.services.root_service import root_service, RootServiceNotFoundError # noqa: E402
219from mcpgateway.services.server_service import server_service # noqa: E402
220from mcpgateway.services.tool_service import tool_service # noqa: E402
222# Services that do not expose module-level singletons are instantiated here
223completion_service = CompletionService()
224sampling_handler = SamplingHandler()
225tag_service = TagService()
226export_service = ExportService()
227import_service = ImportService()
228# Initialize A2A service only if A2A features are enabled
229a2a_service = A2AAgentService() if settings.mcpgateway_a2a_enabled else None
231# Initialize session manager for Streamable HTTP transport
232streamable_http_session = SessionManagerWrapper()
234# Wait for redis to be ready
235if settings.cache_type == "redis" and settings.redis_url is not None:
236 wait_for_redis_ready(redis_url=settings.redis_url, max_retries=int(settings.redis_max_retries), retry_interval_ms=int(settings.redis_retry_interval_ms), sync=True)
238# Initialize session registry
239session_registry = SessionRegistry(
240 backend=settings.cache_type,
241 redis_url=settings.redis_url if settings.cache_type == "redis" else None,
242 database_url=settings.database_url if settings.cache_type == "database" else None,
243 session_ttl=settings.session_ttl,
244 message_ttl=settings.message_ttl,
245)
246set_shared_session_registry(session_registry)
249# Helper function for authentication compatibility
250def get_user_email(user):
251 """Extract email from user object, handling both string and dict formats.
253 Args:
254 user: User object, can be either a dict (new RBAC format) or string (legacy format)
256 Returns:
257 str: User email address or 'unknown' if not available
259 Examples:
260 Test with dictionary user containing email:
261 >>> from mcpgateway import main
262 >>> user_dict = {'email': 'alice@example.com', 'role': 'admin'}
263 >>> main.get_user_email(user_dict)
264 'alice@example.com'
266 Test with dictionary user containing sub (JWT standard claim):
267 >>> user_dict_sub = {'sub': 'bob@example.com', 'role': 'user'}
268 >>> main.get_user_email(user_dict_sub)
269 'bob@example.com'
271 Test with dictionary user containing both email and sub (email takes precedence):
272 >>> user_dict_both = {'email': 'alice@example.com', 'sub': 'bob@example.com'}
273 >>> main.get_user_email(user_dict_both)
274 'alice@example.com'
276 Test with dictionary user without email or sub:
277 >>> user_dict_no_email = {'username': 'charlie', 'role': 'user'}
278 >>> main.get_user_email(user_dict_no_email)
279 'unknown'
281 Test with string user (legacy format):
282 >>> user_string = 'charlie@company.com'
283 >>> main.get_user_email(user_string)
284 'charlie@company.com'
286 Test with None user:
287 >>> main.get_user_email(None)
288 'unknown'
290 Test with empty dictionary:
291 >>> main.get_user_email({})
292 'unknown'
294 Test with integer (non-string, non-dict):
295 >>> main.get_user_email(123)
296 '123'
298 Test with user object having various data types:
299 >>> user_complex = {'email': 'david@test.org', 'id': 456, 'active': True}
300 >>> main.get_user_email(user_complex)
301 'david@test.org'
303 Test with empty string user:
304 >>> main.get_user_email('')
305 'unknown'
307 Test with boolean user:
308 >>> main.get_user_email(True)
309 'True'
310 >>> main.get_user_email(False)
311 'unknown'
312 """
313 if isinstance(user, dict):
314 # First try 'email', then 'sub' (JWT standard claim)
315 return user.get("email") or user.get("sub") or "unknown"
316 return str(user) if user else "unknown"
319_INTERNAL_MCP_AUTH_CONTEXT_HEADER = "x-contextforge-auth-context"
320_INTERNAL_MCP_RUNTIME_AUTH_HEADER = "x-contextforge-mcp-runtime-auth"
321_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT = "contextforge-internal-mcp-runtime-v1"
322_INTERNAL_MCP_SESSION_VALIDATED_HEADER = "x-contextforge-session-validated"
325def _get_internal_mcp_auth_context(request: Request) -> Optional[Dict[str, Any]]:
326 """Return trusted auth context forwarded from the StreamableHTTP MCP auth layer.
328 Args:
329 request: Incoming request that may carry trusted MCP auth context on state.
331 Returns:
332 The forwarded auth context dictionary when present, otherwise ``None``.
333 """
334 internal_auth_context = getattr(request.state, "_mcp_internal_auth_context", None)
335 if isinstance(internal_auth_context, dict):
336 return internal_auth_context
337 return None
340def _decode_internal_mcp_auth_context(header_value: str) -> Dict[str, Any]:
341 """Decode the trusted internal MCP auth header payload.
343 Args:
344 header_value: Base64url-encoded trusted auth context header value.
346 Returns:
347 Decoded auth context dictionary.
349 Raises:
350 ValueError: If the decoded payload is not a JSON object.
351 """
352 padding = "=" * (-len(header_value) % 4)
353 decoded = base64.urlsafe_b64decode(f"{header_value}{padding}".encode("ascii"))
354 payload = orjson.loads(decoded)
355 if not isinstance(payload, dict):
356 raise ValueError("Decoded internal MCP auth context must be an object")
357 return payload
360def _auth_encryption_secret_value() -> str:
361 """Return the configured auth-encryption secret as a plain string.
363 Returns:
364 The auth-encryption secret, normalized to a regular string.
365 """
366 secret = settings.auth_encryption_secret
367 if hasattr(secret, "get_secret_value"):
368 return secret.get_secret_value()
369 return str(secret)
372@lru_cache(maxsize=8)
373def _expected_internal_mcp_runtime_auth_header_for_secret(secret: str) -> str:
374 """Return the shared secret-derived trust header for Rust->Python MCP hops.
376 Args:
377 secret: Auth-encryption secret to derive the trust header from.
379 Returns:
380 Hex-encoded SHA-256 digest derived from the provided auth secret.
381 """
382 material = f"{secret}:{_INTERNAL_MCP_RUNTIME_AUTH_CONTEXT}".encode("utf-8")
383 return hashlib.sha256(material).hexdigest()
386def _expected_internal_mcp_runtime_auth_header() -> str:
387 """Return the current shared secret-derived trust header for Rust->Python MCP hops.
389 Returns:
390 Hex-encoded SHA-256 digest derived from the current auth secret.
391 """
392 return _expected_internal_mcp_runtime_auth_header_for_secret(_auth_encryption_secret_value())
395def _has_valid_internal_mcp_runtime_auth_header(request: Request) -> bool:
396 """Validate the shared secret-derived trust header for internal MCP requests.
398 Args:
399 request: Incoming internal MCP request.
401 Returns:
402 ``True`` when the derived trust header matches the expected value.
403 """
404 provided = request.headers.get(_INTERNAL_MCP_RUNTIME_AUTH_HEADER)
405 if not provided:
406 return False
407 return hmac.compare_digest(provided, _expected_internal_mcp_runtime_auth_header())
410def _is_trusted_internal_mcp_runtime_request(request: Request) -> bool:
411 """Return whether the request came from the local Rust runtime sidecar.
413 Args:
414 request: Incoming request to inspect.
416 Returns:
417 ``True`` when the request carries the trusted Rust runtime marker from
418 loopback, otherwise ``False``.
419 """
420 runtime_marker = request.headers.get("x-contextforge-mcp-runtime")
421 client_host = getattr(getattr(request, "client", None), "host", None)
422 return runtime_marker == "rust" and _has_valid_internal_mcp_runtime_auth_header(request) and client_host in ("127.0.0.1", "::1")
425def _build_internal_mcp_forwarded_user(request: Request) -> Dict[str, Any]:
426 """Build the authenticated user payload for internal Rust -> Python MCP dispatch.
428 Args:
429 request: Trusted internal request forwarded from the Rust runtime.
431 Returns:
432 Synthetic authenticated user payload used by internal MCP handlers.
434 Raises:
435 HTTPException: If the request is not trusted or the forwarded auth context
436 is missing or invalid.
437 """
438 if not _is_trusted_internal_mcp_runtime_request(request):
439 raise HTTPException(status_code=403, detail="Internal MCP dispatch is only available to the local Rust runtime")
441 header_value = request.headers.get(_INTERNAL_MCP_AUTH_CONTEXT_HEADER)
442 if not header_value:
443 raise HTTPException(status_code=400, detail="Missing trusted MCP auth context")
445 try:
446 auth_context = _decode_internal_mcp_auth_context(header_value)
447 except Exception as exc:
448 raise HTTPException(status_code=400, detail=f"Invalid trusted MCP auth context: {exc}") from exc
450 setattr(request.state, "_mcp_internal_auth_context", auth_context)
452 if "teams" in auth_context and (auth_context["teams"] is None or isinstance(auth_context["teams"], list)):
453 request.state.token_teams = auth_context["teams"]
455 if request.headers.get(_INTERNAL_MCP_SESSION_VALIDATED_HEADER) == "rust":
456 auth_context["_rust_session_validated"] = True
458 forwarded_auth_method = auth_context.get("auth_method") or "mcp_internal_forward"
460 set_trace_context_from_teams(
461 auth_context.get("teams"),
462 user_email=auth_context.get("email"),
463 is_admin=bool(auth_context.get("permission_is_admin", auth_context.get("is_admin", False))),
464 auth_method=forwarded_auth_method,
465 team_name=auth_context.get("team_name"),
466 )
468 return {
469 "email": auth_context.get("email"),
470 "full_name": auth_context.get("email") or "MCP Internal Forward",
471 "is_admin": bool(auth_context.get("permission_is_admin", auth_context.get("is_admin", False))),
472 "auth_method": forwarded_auth_method,
473 "token_use": auth_context.get("token_use"),
474 }
477def _enforce_internal_mcp_server_scope(request: Request, server_id: str) -> None:
478 """Validate trusted internal server scope against any forwarded token server scope.
480 Args:
481 request: Trusted internal MCP request.
482 server_id: Effective virtual server identifier for the operation.
484 Raises:
485 HTTPException: If the forwarded token scope does not authorize the server.
486 """
487 auth_context = _get_internal_mcp_auth_context(request)
488 if not isinstance(auth_context, dict):
489 return
491 scoped_server_id = auth_context.get("scoped_server_id")
492 if isinstance(scoped_server_id, str) and scoped_server_id and not validate_server_access({"server_id": scoped_server_id}, server_id):
493 raise HTTPException(status_code=403, detail=f"Token not authorized for server: {server_id}")
496async def _authorize_internal_mcp_request(request: Request, db: Session, *, permission: str, method: str, server_id: Optional[str] = None):
497 """Authorize trusted Rust-side MCP dispatch while preserving permissive MCP semantics.
499 For authenticated callers, this enforces the same token-scope and RBAC rules as
500 the regular RPC dispatcher. For unauthenticated MCP callers in permissive mode,
501 StreamableHTTP middleware already downgraded them to public-only scope and
502 enforced per-server OAuth, so the internal Rust -> Python hop should not re-deny
503 public-only requests merely because there is no authenticated RBAC identity.
505 Args:
506 request: Trusted internal MCP request.
507 db: Active database session.
508 permission: RBAC permission required for the method.
509 method: MCP method name being authorized.
510 server_id: Optional virtual server identifier used for additional scope checks.
512 Returns:
513 The forwarded user payload used for downstream authorization and scoping.
514 """
515 user = _build_internal_mcp_forwarded_user(request)
516 auth_context = _get_internal_mcp_auth_context(request) or {}
518 if server_id:
519 _enforce_internal_mcp_server_scope(request, server_id)
521 if auth_context.get("is_authenticated", True) is True:
522 await _ensure_rpc_permission(user, db, permission, method, request=request)
524 return user
527def _build_internal_mcp_auth_scope(
528 *,
529 method: str,
530 path: str,
531 query_string: str,
532 headers: Dict[str, str],
533 client_ip: Optional[str],
534) -> Dict[str, Any]:
535 """Construct a synthetic ASGI scope for internal Rust -> Python MCP auth.
537 Args:
538 method: HTTP method of the original public MCP request.
539 path: Public MCP path, for example ``/mcp`` or ``/servers/<id>/mcp``.
540 query_string: Raw query string without the leading ``?``.
541 headers: Public request headers to replay through auth/token scoping.
542 client_ip: Effective client IP derived by Rust from the public request.
544 Returns:
545 ASGI scope dictionary suitable for token scoping and ``streamable_http_auth``.
546 """
547 raw_headers = []
548 for name, value in headers.items():
549 if not isinstance(name, str) or not isinstance(value, str):
550 continue
551 raw_headers.append((name.lower().encode("latin-1"), value.encode("latin-1")))
553 return {
554 "type": "http",
555 "method": method.upper(),
556 "path": path,
557 "raw_path": path.encode("latin-1"),
558 "query_string": query_string.encode("latin-1"),
559 "headers": raw_headers,
560 "client": (client_ip or "unknown", 0),
561 "state": {},
562 }
565async def _run_internal_mcp_authentication(
566 *,
567 method: str,
568 path: str,
569 query_string: str,
570 headers: Dict[str, str],
571 client_ip: Optional[str],
572) -> tuple[Optional[Response], Dict[str, Any]]:
573 """Run token scoping and MCP transport auth for a direct Rust ingress request.
575 Runs HTTP_PRE_REQUEST plugin hooks (e.g. WXO auth token exchange) before
576 authentication so the Rust MCP path gets identical plugin behavior to the
577 Python middleware chain.
579 Args:
580 method: HTTP method of the public request.
581 path: Public request path.
582 query_string: Raw query string without the leading ``?``.
583 headers: Public request headers replayed from Rust.
584 client_ip: Effective client IP for token-scope IP restriction checks.
586 Returns:
587 Tuple of ``(error_response, auth_context)``.
588 ``error_response`` is ``None`` on success; otherwise it contains the exact
589 response generated by the existing token-scoping/auth layers.
590 """
591 # Run pre-request plugin hooks (e.g. WXO JWT → team token exchange)
592 # before building the auth scope, so plugins can transform headers.
593 if plugin_manager and plugin_manager.has_hooks_for(HttpHookType.HTTP_PRE_REQUEST):
594 headers, _, _ = await run_pre_request_hooks(
595 plugin_manager=plugin_manager,
596 headers=headers,
597 path=path,
598 method=method,
599 client_host=client_ip,
600 )
602 scope = _build_internal_mcp_auth_scope(
603 method=method,
604 path=path,
605 query_string=query_string,
606 headers=headers,
607 client_ip=client_ip,
608 )
609 request = starletteRequest(scope)
610 sent_messages: list[dict[str, Any]] = []
612 async def _receive() -> dict[str, Any]:
613 """Return an empty request body for the synthetic auth probe.
615 Returns:
616 Minimal ASGI ``http.request`` message with no body content.
617 """
618 return {"type": "http.request", "body": b"", "more_body": False}
620 async def _send(message: dict[str, Any]) -> None:
621 """Capture ASGI response messages emitted by auth middleware.
623 Args:
624 message: ASGI response message emitted by the auth stack.
625 """
626 sent_messages.append(message)
628 def _captured_response() -> Response:
629 """Build a concrete response from the captured ASGI messages.
631 Returns:
632 Response reconstructed from the captured auth middleware output.
633 """
634 status_code = 500
635 response_headers: Dict[str, str] = {}
636 body = b""
637 for message in sent_messages:
638 if message.get("type") == "http.response.start":
639 status_code = int(message.get("status", 500))
640 response_headers = {
641 key.decode("latin-1"): value.decode("latin-1") for key, value in message.get("headers", []) if isinstance(key, (bytes, bytearray)) and isinstance(value, (bytes, bytearray))
642 }
643 elif message.get("type") == "http.response.body":
644 body += message.get("body", b"")
645 return Response(content=body, status_code=status_code, headers=response_headers)
647 async def _call_next(_request: starletteRequest) -> Response:
648 """Run the existing Streamable HTTP auth layer for the synthetic request.
650 Returns:
651 Success response when authentication passes, otherwise the captured
652 failure response emitted by the existing middleware chain.
653 """
654 auth_ok = await streamable_http_auth(scope, _receive, _send)
655 if auth_ok:
656 return ORJSONResponse(status_code=200, content={"authenticated": True})
657 return _captured_response()
659 original_context = user_context_var.get()
660 user_context_var.set({})
661 try:
662 if settings.email_auth_enabled:
663 response = await token_scoping_middleware(request, _call_next)
664 else:
665 response = await _call_next(request)
667 if response is None:
668 response = _captured_response()
670 if response.status_code >= 400:
671 return response, {}
673 return None, get_streamable_http_auth_context()
674 finally:
675 user_context_var.set(original_context)
678def _normalize_token_teams(teams: Optional[List]) -> List[str]:
679 """
680 Normalize token teams to list of team IDs.
682 SSO tokens may contain team dicts like {"id": "...", "name": "..."}.
683 This normalizes to just IDs for consistent filtering.
685 Args:
686 teams: Raw teams from token payload (may be None, list of IDs, or list of dicts)
688 Returns:
689 List of team ID strings (empty list if None)
691 Examples:
692 >>> from mcpgateway import main
693 >>> main._normalize_token_teams(None)
694 []
695 >>> main._normalize_token_teams([])
696 []
697 >>> main._normalize_token_teams(["team_a", "team_b"])
698 ['team_a', 'team_b']
699 >>> main._normalize_token_teams([{"id": "team_a", "name": "Team A"}])
700 ['team_a']
701 >>> main._normalize_token_teams([{"id": "t1"}, "t2", {"name": "no_id"}])
702 ['t1', 't2']
703 """
704 if not teams:
705 return []
707 normalized = []
708 for team in teams:
709 if isinstance(team, dict):
710 team_id = team.get("id")
711 if team_id:
712 normalized.append(team_id)
713 elif isinstance(team, str):
714 normalized.append(team)
715 return normalized
718def _get_token_teams_from_request(request: Request) -> Optional[List[str]]:
719 """
720 Extract and normalize teams from verified JWT token.
722 SECURITY: Uses normalize_token_teams for consistent secure-first semantics:
723 - teams key missing → [] (public-only, secure default)
724 - teams key null + is_admin=true → None (admin bypass)
725 - teams key null + is_admin=false → [] (public-only)
726 - teams key [] → [] (explicit public-only)
727 - teams key [...] → normalized list of string IDs
729 First checks request.state.token_teams (set by auth.py), then falls back
730 to calling normalize_token_teams on the JWT payload.
732 Args:
733 request: FastAPI request object
735 Returns:
736 None for admin bypass, [] for public-only, or list of normalized team ID strings.
738 Examples:
739 >>> from mcpgateway import main
740 >>> from unittest.mock import MagicMock
741 >>> req = MagicMock()
742 >>> req.state = MagicMock()
743 >>> req.state.token_teams = ["team_a"] # Already normalized by auth.py
744 >>> main._get_token_teams_from_request(req)
745 ['team_a']
746 >>> req.state.token_teams = [] # Public-only
747 >>> main._get_token_teams_from_request(req)
748 []
749 """
750 internal_auth_context = _get_internal_mcp_auth_context(request)
751 if isinstance(internal_auth_context, dict) and "teams" in internal_auth_context:
752 internal_teams = internal_auth_context.get("teams")
753 if internal_teams is None or isinstance(internal_teams, list):
754 return internal_teams
756 # SECURITY: First check request.state.token_teams (already normalized by auth.py)
757 # This is the preferred path as auth.py has already applied normalize_token_teams
758 # Use getattr with a sentinel to distinguish "not set" from "set to None"
759 _not_set = object()
760 token_teams = getattr(request.state, "token_teams", _not_set)
761 if token_teams is not _not_set and (token_teams is None or isinstance(token_teams, list)):
762 return token_teams
764 # Fallback: Use cached verified payload and call normalize_token_teams
765 cached = getattr(request.state, "_jwt_verified_payload", None)
766 if cached and isinstance(cached, tuple) and len(cached) == 2:
767 _, payload = cached
768 if payload:
769 # Use normalize_token_teams for consistent secure-first semantics
770 return normalize_token_teams(payload)
772 # No JWT payload - return [] for public-only (secure default)
773 return []
776def _get_rpc_filter_context(request: Request, user) -> tuple:
777 """
778 Extract user_email, token_teams, and is_admin for RPC filtering.
780 Args:
781 request: FastAPI request object
782 user: User object from auth dependency
784 Returns:
785 Tuple of (user_email, token_teams, is_admin)
787 Examples:
788 >>> from mcpgateway import main
789 >>> from unittest.mock import MagicMock
790 >>> req = MagicMock()
791 >>> req.state = MagicMock()
792 >>> req.state._jwt_verified_payload = ("token", {"teams": ["t1"], "is_admin": True})
793 >>> user = {"email": "test@x.com", "is_admin": True} # User's is_admin is ignored
794 >>> email, teams, is_admin = main._get_rpc_filter_context(req, user)
795 >>> email
796 'test@x.com'
797 >>> teams
798 ['t1']
799 >>> is_admin # From token payload, not user dict
800 True
801 """
802 # Get user email
803 if hasattr(user, "email"):
804 user_email = getattr(user, "email", None)
805 elif isinstance(user, dict):
806 user_email = user.get("sub") or user.get("email")
807 else:
808 user_email = str(user) if user else None
810 # Get normalized teams from verified token
811 token_teams = _get_token_teams_from_request(request)
813 # Check if user is admin - MUST come from token, not DB user
814 # This ensures that tokens with restricted scope (empty teams) don't inherit admin bypass
815 is_admin = False
816 internal_auth_context = _get_internal_mcp_auth_context(request)
817 if isinstance(internal_auth_context, dict):
818 if user_email is None:
819 user_email = internal_auth_context.get("email")
820 is_admin = bool(internal_auth_context.get("is_admin", False))
821 if token_teams is not None and len(token_teams) == 0:
822 is_admin = False
823 return user_email, token_teams, is_admin
825 cached = getattr(request.state, "_jwt_verified_payload", None)
826 if cached and isinstance(cached, tuple) and len(cached) == 2:
827 _, payload = cached
828 if payload:
829 # Check both top-level is_admin and nested user.is_admin in token
830 is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False)
832 # If token has empty teams array (public-only token), admin bypass is disabled
833 # This allows admins to create properly scoped tokens for restricted access
834 if token_teams is not None and len(token_teams) == 0:
835 is_admin = False
837 return user_email, token_teams, is_admin
840def _has_verified_jwt_payload(request: Request) -> bool:
841 """Return whether request has a verified JWT payload cached in request state.
843 Args:
844 request: Incoming request context.
846 Returns:
847 ``True`` when a verified payload tuple is present, otherwise ``False``.
848 """
849 internal_auth_context = _get_internal_mcp_auth_context(request)
850 if isinstance(internal_auth_context, dict):
851 return True
852 cached = getattr(request.state, "_jwt_verified_payload", None)
853 return bool(cached and isinstance(cached, tuple) and len(cached) == 2 and cached[1])
856def _get_request_identity(request: Request, user) -> tuple[str, bool]:
857 """Return requester email and admin state honoring scoped-token semantics.
859 Args:
860 request: Incoming request context.
861 user: Authenticated user context from dependency resolution.
863 Returns:
864 Tuple of ``(requester_email, requester_is_admin)``.
865 """
866 user_email, _token_teams, token_is_admin = _get_rpc_filter_context(request, user)
867 resolved_email = user_email or get_user_email(user)
869 # If a JWT payload exists, respect token-derived admin semantics (including
870 # public-only admin tokens where bypass is intentionally disabled).
871 if _has_verified_jwt_payload(request):
872 return resolved_email, token_is_admin
874 fallback_is_admin = False
875 if hasattr(user, "is_admin"):
876 fallback_is_admin = bool(getattr(user, "is_admin", False))
877 elif isinstance(user, dict):
878 fallback_is_admin = bool(user.get("is_admin", False) or user.get("user", {}).get("is_admin", False))
880 return resolved_email, token_is_admin or fallback_is_admin
883def _get_scoped_resource_access_context(request: Request, user) -> tuple[Optional[str], Optional[List[str]]]:
884 """Resolve scoped resource access context for the current requester.
886 Args:
887 request: Incoming request context.
888 user: Authenticated user context from dependency resolution.
890 Returns:
891 Tuple of ``(user_email, token_teams)`` where ``(None, None)`` represents
892 unrestricted admin access and ``[]`` represents public-only scope.
893 """
894 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
896 # Non-JWT admin contexts (for example basic-auth development mode) should
897 # keep unrestricted access semantics.
898 if not _has_verified_jwt_payload(request):
899 _requester_email, fallback_admin = _get_request_identity(request, user)
900 if fallback_admin:
901 return None, None
903 if is_admin and token_teams is None:
904 return None, None
905 if token_teams is None:
906 return user_email, []
907 return user_email, token_teams
910def _build_rpc_permission_user(user, db: Session) -> dict[str, Any]:
911 """Build PermissionChecker user payload for method-level RPC checks.
913 Args:
914 user: Authenticated user context.
915 db: Active database session.
917 Returns:
918 Permission checker payload with email and ``db`` keys.
919 """
920 permission_user = dict(user) if isinstance(user, dict) else {"email": get_user_email(user)}
921 if not permission_user.get("email"):
922 permission_user["email"] = get_user_email(user)
923 permission_user["db"] = db
924 return permission_user
927def _extract_scoped_permissions(request: Request) -> set[str] | None:
928 """Extract token scopes.permissions from cached JWT payload.
930 Args:
931 request: Incoming request context.
933 Returns:
934 None: no explicit scope cap (empty permissions or no JWT — defer to RBAC)
935 set: explicit permission set (may contain '*' for wildcard)
936 """
937 internal_auth_context = _get_internal_mcp_auth_context(request)
938 if isinstance(internal_auth_context, dict):
939 permissions = internal_auth_context.get("scoped_permissions")
940 if not permissions:
941 return None
942 return set(permissions)
944 cached = getattr(request.state, "_jwt_verified_payload", None)
945 if not cached or not isinstance(cached, tuple) or len(cached) != 2:
946 return None
947 _, payload = cached
948 if not payload or not isinstance(payload, dict):
949 return None
950 scopes = payload.get("scopes")
951 if not scopes or not isinstance(scopes, dict):
952 return None
953 permissions = scopes.get("permissions")
954 if not permissions: # Empty list or None = defer to RBAC
955 return None
956 return set(permissions)
959def _is_permission_admin_user(user) -> bool:
960 """Return whether the caller already has permission-layer admin authority.
962 This is stricter than token-scope admin semantics. It is used only to skip
963 redundant RBAC DB lookups after token scope caps have already been enforced.
965 Args:
966 user: Authenticated user object or dict-like payload.
968 Returns:
969 ``True`` when the caller already has permission-layer admin authority.
970 """
971 if hasattr(user, "is_admin"):
972 return bool(getattr(user, "is_admin", False))
973 if isinstance(user, dict):
974 if "permission_is_admin" in user:
975 return bool(user.get("permission_is_admin", False))
976 return False
977 return False
980async def _ensure_rpc_permission(user, db: Session, permission: str, method: str, request: Request | None = None) -> None:
981 """Require a specific RPC permission for a method branch.
983 Enforces both layers:
984 1. Token scopes.permissions cap (if explicit permissions present)
985 2. RBAC role-based permission check
987 Args:
988 user: Authenticated user context.
989 db: Active database session.
990 permission: Permission required for the method.
991 method: JSON-RPC method name being authorized.
992 request: Optional FastAPI request for extracting token scopes.
994 Raises:
995 JSONRPCError: If the requester lacks the required permission.
996 """
997 # Layer 1: Token scope cap
998 if request is not None:
999 scoped = _extract_scoped_permissions(request)
1000 if scoped is not None and "*" not in scoped and permission not in scoped:
1001 logger.warning("RPC permission denied (token scope): method=%s, required=%s", method, permission)
1002 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
1004 if permission == "admin.system_config" and _is_permission_admin_user(user):
1005 return
1007 # Layer 2: RBAC check
1008 # Session tokens have no explicit team_id, so check across all team-scoped roles.
1009 # Mirrors the @require_permission decorator's check_any_team fallback (rbac.py:562-576).
1010 check_any_team = isinstance(user, dict) and user.get("token_use") == "session"
1011 checker = PermissionChecker(_build_rpc_permission_user(user, db))
1012 if not await checker.has_permission(permission, check_any_team=check_any_team):
1013 logger.warning("RPC permission denied (RBAC): method=%s, required=%s", method, permission)
1014 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
1017def _serialize_mcp_tool_definition(tool: Any) -> Dict[str, Any]:
1018 """Return an MCP-compliant tool definition without API-only metadata fields.
1020 Args:
1021 tool: Tool ORM object, pydantic model, or dict-like payload.
1023 Returns:
1024 MCP-compatible tool definition dictionary.
1025 """
1026 if hasattr(tool, "model_dump"):
1027 data = tool.model_dump(by_alias=True, exclude_none=True)
1028 elif isinstance(tool, dict):
1029 data = dict(tool)
1030 else:
1031 data = {}
1033 name = data.get("name", getattr(tool, "name", None))
1034 description = data.get("description", getattr(tool, "description", None))
1035 input_schema = data.get("inputSchema", getattr(tool, "input_schema", None))
1037 payload: Dict[str, Any] = {}
1038 if name is not None:
1039 payload["name"] = name
1040 if description is not None or name is not None or input_schema is not None:
1041 payload["description"] = description or ""
1042 if input_schema is not None:
1043 payload["inputSchema"] = input_schema
1045 output_schema = data.get("outputSchema", getattr(tool, "output_schema", None))
1046 if output_schema is not None:
1047 payload["outputSchema"] = output_schema
1049 annotations = data.get("annotations", getattr(tool, "annotations", None))
1050 if annotations is not None:
1051 payload["annotations"] = annotations
1053 return {key: value for key, value in payload.items() if value is not None}
1056def _serialize_mcp_tool_definitions(tools: List[Any]) -> List[Dict[str, Any]]:
1057 """Serialize tool records to MCP tool definitions.
1059 Args:
1060 tools: Iterable of tool-like records to serialize.
1062 Returns:
1063 List of MCP-compatible tool definitions.
1064 """
1065 return [_serialize_mcp_tool_definition(tool) for tool in tools]
1068def _serialize_legacy_tool_payloads(tools: List[Any]) -> List[Dict[str, Any]]:
1069 """Serialize tool records using the legacy JSON-RPC shape.
1071 Args:
1072 tools: Iterable of tool-like records to serialize.
1074 Returns:
1075 List of legacy tool payload dictionaries.
1076 """
1077 payloads: List[Dict[str, Any]] = []
1078 for tool in tools:
1079 if hasattr(tool, "model_dump"):
1080 payload = tool.model_dump(by_alias=True, exclude_none=True)
1081 elif isinstance(tool, dict):
1082 payload = dict(tool)
1083 else:
1084 payload = {}
1085 payloads.append(payload)
1086 return payloads
1089def _enforce_scoped_resource_access(request: Request, db: Session, user, resource_path: str) -> None:
1090 """Apply token-scope ownership checks for a concrete resource path.
1092 This provides defense-in-depth for ID-based handlers so they continue to
1093 enforce visibility even if middleware coverage regresses.
1095 Args:
1096 request: Incoming request context.
1097 db: Active database session.
1098 user: Authenticated user context.
1099 resource_path: Canonical resource path (e.g. ``/tools/{id}``).
1101 Raises:
1102 HTTPException: If access to the target resource is not allowed.
1103 """
1104 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user)
1106 # Admin bypass / unrestricted scope
1107 if scoped_token_teams is None:
1108 return
1110 if not token_scoping_middleware._check_resource_team_ownership( # pylint: disable=protected-access
1111 resource_path,
1112 scoped_token_teams,
1113 db=db,
1114 _user_email=scoped_user_email,
1115 ):
1116 logger.warning("Scoped resource access denied: user=%s, resource=%s", scoped_user_email, resource_path)
1117 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_ACCESS_DENIED_MSG)
1120async def _assert_session_owner_or_admin(request: Request, user, session_id: str) -> None:
1121 """Ensure session operations are limited to the owner unless requester is admin.
1123 Args:
1124 request: Incoming request context.
1125 user: Authenticated user context.
1126 session_id: Target session identifier.
1128 Raises:
1129 HTTPException: If session is missing or requester is not authorized.
1130 """
1131 session_owner = await session_registry.get_session_owner(session_id)
1132 if not session_owner:
1133 session_exists = await session_registry.session_exists(session_id)
1134 if session_exists is False:
1135 raise HTTPException(status_code=404, detail="Session not found")
1136 raise HTTPException(status_code=403, detail="Session owner metadata unavailable")
1138 requester_email, requester_is_admin = _get_request_identity(request, user)
1139 if requester_is_admin:
1140 return
1141 if requester_email and requester_email == session_owner:
1142 return
1143 raise HTTPException(status_code=403, detail="Session access denied")
1146async def _authorize_run_cancellation(request: Request, user, request_id: str, *, as_jsonrpc_error: bool) -> None:
1147 """Authorize a notifications/cancelled request for a specific run id.
1149 Args:
1150 request: Incoming request context.
1151 user: Authenticated user context.
1152 request_id: Run/request identifier to cancel.
1153 as_jsonrpc_error: Raise ``JSONRPCError`` when True, otherwise ``HTTPException``.
1155 Raises:
1156 JSONRPCError: When ``as_jsonrpc_error`` is True and cancellation is not authorized.
1157 HTTPException: When ``as_jsonrpc_error`` is False and cancellation is not authorized.
1158 """
1159 requester_email, requester_token_teams, requester_is_admin = _get_rpc_filter_context(request, user)
1160 requester_teams = [] if requester_token_teams is None else list(requester_token_teams)
1161 run_status = await cancellation_service.get_status(request_id)
1163 if run_status is None:
1164 # Notifications are best-effort; unknown request ids should be accepted
1165 # as no-ops rather than rejected as authorization failures.
1166 return
1168 run_owner_email = run_status.get("owner_email")
1169 run_owner_team_ids = run_status.get("owner_team_ids") or []
1170 requester_is_owner = bool(run_owner_email and requester_email and run_owner_email == requester_email)
1171 requester_shares_team = bool(run_owner_team_ids and requester_teams and any(team in run_owner_team_ids for team in requester_teams))
1172 unauthorized = not requester_is_admin and not requester_is_owner and not requester_shares_team
1174 if unauthorized:
1175 if as_jsonrpc_error:
1176 raise JSONRPCError(-32003, "Not authorized to cancel this run", {"requestId": request_id})
1177 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not authorized to cancel this run")
1180# Initialize cache
1181resource_cache = ResourceCache(max_size=settings.resource_cache_size, ttl=settings.resource_cache_ttl)
1184def _rust_build_included() -> bool:
1185 """Return whether the current image includes Rust MCP artifacts.
1187 Returns:
1188 ``True`` when the current image contains the Rust MCP binaries/plugins.
1189 """
1190 return version_module.rust_build_included()
1193def _rust_runtime_managed() -> bool:
1194 """Return whether the gateway expects to manage the Rust MCP sidecar locally.
1196 Returns:
1197 ``True`` when the gateway should launch and supervise the Rust sidecar.
1198 """
1199 return version_module.rust_runtime_managed()
1202def _current_mcp_transport_mount() -> str:
1203 """Return which public /mcp transport is currently mounted.
1205 Returns:
1206 Runtime label identifying the currently mounted public MCP transport.
1207 """
1208 return version_module.current_mcp_transport_mount()
1211def _should_mount_public_rust_transport() -> bool:
1212 """Return whether the public ``/mcp`` path should be served directly by Rust.
1214 Returns:
1215 ``True`` only when the Rust runtime is enabled and the session-auth reuse
1216 path is enabled, allowing Rust to safely own steady-state public MCP
1217 session traffic. Otherwise returns ``False`` and leaves public MCP on
1218 the Python ingress path.
1219 """
1220 return version_module.should_mount_public_rust_transport()
1223def _should_use_rust_public_session_stack() -> bool:
1224 """Return whether Rust should own the effective public MCP session stack.
1226 Returns:
1227 ``True`` only when the Rust runtime is enabled and session-auth reuse is
1228 enabled, allowing the public transport, session metadata, replay/resume,
1229 live-stream, and affinity behavior to stay on a consistent Rust-backed
1230 path. Otherwise returns ``False`` so the public MCP session stack falls
1231 back to Python semantics.
1232 """
1233 return version_module.should_use_rust_public_session_stack()
1236def _current_mcp_runtime_mode() -> str:
1237 """Return a compact runtime-mode label for observability.
1239 Returns:
1240 Human-readable runtime mode label for health/readiness reporting.
1241 """
1242 return version_module.current_mcp_runtime_mode()
1245def _current_mcp_session_core_mode() -> str:
1246 """Return which session core currently owns MCP session metadata.
1248 Returns:
1249 ``"rust"`` when the Rust session core is enabled, otherwise ``"python"``.
1250 """
1251 return version_module.current_mcp_session_core_mode()
1254def _current_mcp_event_store_mode() -> str:
1255 """Return which runtime currently owns MCP resumable event-store semantics.
1257 Returns:
1258 ``"rust"`` when the Rust event store is enabled, otherwise ``"python"``.
1259 """
1260 return version_module.current_mcp_event_store_mode()
1263def _current_mcp_resume_core_mode() -> str:
1264 """Return which runtime currently owns public MCP replay/resume behavior.
1266 Returns:
1267 ``"rust"`` when Rust owns replay/resume, otherwise ``"python"``.
1268 """
1269 return version_module.current_mcp_resume_core_mode()
1272def _current_mcp_live_stream_core_mode() -> str:
1273 """Return which runtime currently owns non-resume public GET /mcp SSE behavior.
1275 Returns:
1276 ``"rust"`` when Rust owns live GET /mcp streaming, otherwise ``"python"``.
1277 """
1278 return version_module.current_mcp_live_stream_core_mode()
1281def _current_mcp_affinity_core_mode() -> str:
1282 """Return which runtime currently owns MCP multi-worker session-affinity forwarding.
1284 Returns:
1285 ``"rust"`` when Rust owns session-affinity forwarding, otherwise ``"python"``.
1286 """
1287 return version_module.current_mcp_affinity_core_mode()
1290def _current_mcp_session_auth_reuse_mode() -> str:
1291 """Return which runtime currently owns MCP session-bound auth-context reuse.
1293 Returns:
1294 ``"rust"`` when Rust session auth reuse is enabled, otherwise ``"python"``.
1295 """
1296 return version_module.current_mcp_session_auth_reuse_mode()
1299def _mcp_runtime_status_payload() -> Dict[str, Any]:
1300 """Return MCP runtime diagnostics for health/readiness endpoints.
1302 Returns:
1303 Diagnostic payload describing the active MCP runtime configuration.
1304 """
1305 return version_module.mcp_runtime_status_payload()
1308def _apply_runtime_mode_headers(response: Response) -> None:
1309 """Attach MCP runtime mode headers to a response.
1311 Args:
1312 response: Response object to annotate.
1313 """
1314 response.headers["x-contextforge-mcp-runtime-mode"] = _current_mcp_runtime_mode()
1315 response.headers["x-contextforge-mcp-transport-mounted"] = _current_mcp_transport_mount()
1316 response.headers["x-contextforge-rust-build-included"] = "true" if _rust_build_included() else "false"
1317 response.headers["x-contextforge-mcp-session-core-mode"] = _current_mcp_session_core_mode()
1318 response.headers["x-contextforge-mcp-event-store-mode"] = _current_mcp_event_store_mode()
1319 response.headers["x-contextforge-mcp-resume-core-mode"] = _current_mcp_resume_core_mode()
1320 response.headers["x-contextforge-mcp-live-stream-core-mode"] = _current_mcp_live_stream_core_mode()
1321 response.headers["x-contextforge-mcp-affinity-core-mode"] = _current_mcp_affinity_core_mode()
1322 response.headers["x-contextforge-mcp-session-auth-reuse-mode"] = _current_mcp_session_auth_reuse_mode()
1325# Type aliases for improved readability
1326ToolsResponse: TypeAlias = Union[List[ToolRead], CursorPaginatedToolsResponse, List[Dict[Any, Any]], Dict[Any, Any], ORJSONResponse]
1327ToolResponse: TypeAlias = Union[ToolRead, Dict[Any, Any], ORJSONResponse]
1330@lru_cache(maxsize=512)
1331def _parse_jsonpath(jsonpath: str) -> JSONPath:
1332 """Cache parsed JSONPath expression.
1334 Args:
1335 jsonpath: The JSONPath expression string.
1337 Returns:
1338 Parsed JSONPath object.
1340 Raises:
1341 Exception: If the JSONPath expression is invalid.
1342 """
1343 return parse(jsonpath)
1346def _parse_apijsonpath(raw: Optional[Union[str, JsonPathModifier]]) -> Optional[JsonPathModifier]:
1347 """
1348 Parse apijsonpath parameter from either a JSON string or a JsonPathModifier model.
1350 Performs early validation of JSONPath syntax to fail fast and provide clear error messages.
1352 Args:
1353 raw: Either a JSON-encoded string or a JsonPathModifier instance
1355 Returns:
1356 Parsed JsonPathModifier or None if raw is None
1358 Raises:
1359 HTTPException: If the JSON string is invalid, unexpected type provided,
1360 jsonpath expression is empty, or JSONPath syntax is invalid (400 Bad Request)
1361 """
1362 if raw is None:
1363 return None
1365 if isinstance(raw, str):
1366 try:
1367 parsed = JsonPathModifier.model_validate(json.loads(raw))
1368 # Validate jsonpath is not empty if provided
1369 if parsed.jsonpath is not None:
1370 if not parsed.jsonpath.strip():
1371 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="JSONPath expression cannot be empty")
1372 # Early validation: ensure JSONPath syntax is valid
1373 try:
1374 _parse_jsonpath(parsed.jsonpath)
1375 except Exception as parse_ex:
1376 detail = f"Invalid JSONPath syntax: {parse_ex}" if settings.log_level == "DEBUG" else "Invalid JSONPath expression"
1377 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
1378 return parsed
1379 except HTTPException:
1380 # Re-raise HTTPException as-is (includes empty jsonpath and syntax validation)
1381 raise
1382 except json.JSONDecodeError as ex:
1383 # User error: malformed JSON (JSONDecodeError is subclass of ValueError, so catch it specifically)
1384 detail = f"Invalid apijsonpath JSON: {ex}" if settings.log_level == "DEBUG" else "Invalid apijsonpath format"
1385 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
1386 except ValidationError as ex:
1387 # Pydantic validation error
1388 detail = f"Invalid apijsonpath structure: {ex}" if settings.log_level == "DEBUG" else "Invalid apijsonpath structure"
1389 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
1390 except Exception as ex:
1391 # Unexpected error - log it and return generic message
1392 logger.error(f"Unexpected error parsing apijsonpath: {ex}", exc_info=True)
1393 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to parse apijsonpath")
1394 elif isinstance(raw, JsonPathModifier):
1395 # Validate jsonpath is not empty if provided
1396 if raw.jsonpath is not None:
1397 if not raw.jsonpath.strip():
1398 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="JSONPath expression cannot be empty")
1399 # Early validation: ensure JSONPath syntax is valid
1400 try:
1401 _parse_jsonpath(raw.jsonpath)
1402 except Exception as parse_ex:
1403 detail = f"Invalid JSONPath syntax: {parse_ex}" if settings.log_level == "DEBUG" else "Invalid JSONPath expression"
1404 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
1405 return raw
1407 # Unexpected type - fail fast with clear error message
1408 # Only show type name in debug mode to avoid information disclosure
1409 type_info = f": got {type(raw).__name__}" if settings.log_level == "DEBUG" else ""
1410 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid apijsonpath type{type_info}")
1413def jsonpath_modifier(data: Any, jsonpath: str = "$[*]", mappings: Optional[Dict[str, str]] = None) -> Union[List, Dict]:
1414 """
1415 Applies the given JSONPath expression and mappings to the data.
1416 Uses cached parsed expressions for performance.
1418 Args:
1419 data: The JSON data to query.
1420 jsonpath: The JSONPath expression to apply.
1421 mappings: Optional dictionary of mappings where keys are new field names
1422 and values are JSONPath expressions.
1424 Returns:
1425 Union[List, Dict]: A list (or mapped list) or a Dict of extracted data.
1427 Raises:
1428 HTTPException: If there's an error parsing or executing the JSONPath expressions.
1430 Examples:
1431 >>> jsonpath_modifier({'a': 1, 'b': 2}, '$.a')
1432 [1]
1433 >>> jsonpath_modifier([{'a': 1}, {'a': 2}], '$[*].a')
1434 [1, 2]
1435 >>> jsonpath_modifier({'a': {'b': 2}}, '$.a.b')
1436 [2]
1437 >>> jsonpath_modifier({'a': 1}, '$.b')
1438 []
1439 """
1440 if not jsonpath:
1441 jsonpath = "$[*]"
1443 # Log jsonpath_modifier invocation with structured data (only if debug enabled)
1444 if logger.isEnabledFor(logging.DEBUG):
1445 data_length = len(data) if isinstance(data, list) else None
1446 logger.debug(
1447 f"jsonpath_modifier: path='{SecurityValidator.sanitize_log_message(jsonpath)}', has_mappings={mappings is not None}, " f"data_type={type(data).__name__}, data_length={data_length}"
1448 )
1450 try:
1451 main_expr: JSONPath = _parse_jsonpath(jsonpath)
1452 except Exception as e:
1453 raise HTTPException(status_code=400, detail=f"Invalid main JSONPath expression: {e}")
1455 try:
1456 main_matches = main_expr.find(data)
1457 except Exception as e:
1458 raise HTTPException(status_code=400, detail=f"Error executing main JSONPath: {e}")
1460 results = [match.value for match in main_matches]
1462 if mappings:
1463 results = transform_data_with_mappings(results, mappings)
1465 if len(results) == 1 and isinstance(results[0], dict):
1466 return results[0]
1468 return results
1471def transform_data_with_mappings(data: list[Any], mappings: dict[str, str]) -> list[Any]:
1472 """
1473 Applies mappings to data using cached JSONPath expressions.
1474 Parses each mapping expression once per call, not per item.
1476 Args:
1477 data: The set of data to apply mappings to.
1478 mappings: dictionary of mappings where keys are new field names
1480 Returns:
1481 list[Any]: A list (or mapped list) of re-mapped data
1483 Raises:
1484 HTTPException: If there's an error parsing or executing the JSONPath expressions.
1486 Examples:
1487 >>> transform_data_with_mappings([{'first_name': "Bruce", 'second_name': "Wayne"},{'first_name': "Diana", 'second_name': "Prince"}], {"n": "$.first_name"})
1488 [{'n': 'Bruce'}, {'n': 'Diana'}]
1489 """
1490 # Pre-parse all mapping expressions once (not per item)
1491 parsed_mappings: Dict[str, JSONPath] = {}
1492 for new_key, mapping_expr_str in mappings.items():
1493 try:
1494 parsed_mappings[new_key] = _parse_jsonpath(mapping_expr_str)
1495 except Exception as e:
1496 raise HTTPException(status_code=400, detail=f"Invalid mapping JSONPath for key '{new_key}': {e}")
1498 mapped_results = []
1499 for item in data:
1500 mapped_item = {}
1501 for new_key, mapping_expr in parsed_mappings.items():
1502 try:
1503 mapping_matches = mapping_expr.find(item)
1504 except Exception as e:
1505 raise HTTPException(status_code=400, detail=f"Error executing mapping JSONPath for key '{new_key}': {e}")
1507 if not mapping_matches:
1508 mapped_item[new_key] = None
1509 elif len(mapping_matches) == 1:
1510 mapped_item[new_key] = mapping_matches[0].value
1511 else:
1512 mapped_item[new_key] = [m.value for m in mapping_matches]
1513 mapped_results.append(mapped_item)
1515 return mapped_results
1518async def attempt_to_bootstrap_sso_providers():
1519 """
1520 Try to bootstrap SSO provider services based on settings.
1521 """
1522 try:
1523 # First-Party
1524 from mcpgateway.utils.sso_bootstrap import bootstrap_sso_providers # pylint: disable=import-outside-toplevel
1526 await bootstrap_sso_providers()
1527 logger.info("SSO providers bootstrapped successfully")
1528 except Exception as e:
1529 logger.warning(f"Failed to bootstrap SSO providers: {e}")
1532####################
1533# Startup/Shutdown #
1534####################
1535def _can_manage_sighup_handler() -> bool:
1536 """Return whether this runtime context can safely install process signal handlers.
1538 Returns:
1539 ``True`` when startup is running on the process main thread and SIGHUP is available.
1540 """
1541 return hasattr(signal, "SIGHUP") and threading.current_thread() is threading.main_thread()
1544def _install_sighup_handler() -> bool:
1545 """Install the SIGHUP handler when the current runtime context supports it.
1547 Returns:
1548 ``True`` when the handler was installed in the current runtime context.
1549 """
1550 if not _can_manage_sighup_handler():
1551 logger.debug("Skipping SIGHUP handler registration outside the main thread")
1552 return False
1554 # First-Party
1555 from mcpgateway.handlers.signal_handlers import sighup_handler # pylint: disable=import-outside-toplevel
1557 signal.signal(signal.SIGHUP, sighup_handler)
1558 return True
1561def _restore_default_sighup_handler() -> None:
1562 """Restore the default SIGHUP handler when the current runtime context supports it.
1564 Returns:
1565 ``None``.
1566 """
1567 if not _can_manage_sighup_handler():
1568 return
1569 signal.signal(signal.SIGHUP, signal.SIG_DFL)
1572@asynccontextmanager
1573async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
1574 """
1575 Manage the application's startup and shutdown lifecycle.
1577 The function initialises every core service on entry and then
1578 shuts them down in reverse order on exit.
1580 Args:
1581 _app (FastAPI): FastAPI app
1583 Yields:
1584 None
1586 Raises:
1587 SystemExit: When a critical startup error occurs that prevents
1588 the application from starting successfully.
1589 Exception: Any unhandled error that occurs during service
1590 initialisation or shutdown is re-raised to the caller.
1591 """
1592 aggregation_stop_event: Optional[asyncio.Event] = None
1593 aggregation_loop_task: Optional[asyncio.Task] = None
1594 aggregation_backfill_task: Optional[asyncio.Task] = None
1596 # Initialize logging service FIRST to ensure all logging goes to dual output
1597 await logging_service.initialize()
1598 logger.info("Starting ContextForge services")
1600 # Initialize Redis client early (shared pool for all services)
1601 await get_redis_client()
1603 # Initialize shared HTTP client (connection pool for all outbound requests)
1604 # First-Party
1605 from mcpgateway.services.http_client_service import SharedHttpClient # pylint: disable=import-outside-toplevel
1607 await SharedHttpClient.get_instance()
1609 # Update HTTP pool metrics after SharedHttpClient is initialized
1610 if hasattr(app.state, "update_http_pool_metrics"):
1611 app.state.update_http_pool_metrics()
1613 # Initialize MCP session pool (for session reuse across tool invocations)
1614 # Also initialize if session affinity is enabled (needs the ownership registry)
1615 if settings.mcp_session_pool_enabled or settings.mcpgateway_session_affinity_enabled:
1616 # First-Party
1617 from mcpgateway.services.mcp_session_pool import init_mcp_session_pool # pylint: disable=import-outside-toplevel
1619 # Auto-align pool health check interval to min of pool and gateway settings
1620 effective_health_check_interval = min(
1621 settings.health_check_interval,
1622 settings.mcp_session_pool_health_check_interval,
1623 )
1625 max_sessions_per_key = settings.mcpgateway_session_affinity_max_sessions if settings.mcpgateway_session_affinity_enabled else settings.mcp_session_pool_max_per_key
1626 init_mcp_session_pool(
1627 max_sessions_per_key=max_sessions_per_key,
1628 session_ttl_seconds=settings.mcp_session_pool_ttl,
1629 health_check_interval_seconds=effective_health_check_interval,
1630 acquire_timeout_seconds=settings.mcp_session_pool_acquire_timeout,
1631 session_create_timeout_seconds=settings.mcp_session_pool_create_timeout,
1632 circuit_breaker_threshold=settings.mcp_session_pool_circuit_breaker_threshold,
1633 circuit_breaker_reset_seconds=settings.mcp_session_pool_circuit_breaker_reset,
1634 identity_headers=frozenset(settings.mcp_session_pool_identity_headers),
1635 idle_pool_eviction_seconds=settings.mcp_session_pool_idle_eviction,
1636 # Use dedicated transport timeout (default 30s to match MCP SDK default).
1637 # This is separate from health_check_timeout to allow long-running tool calls.
1638 default_transport_timeout_seconds=settings.mcp_session_pool_transport_timeout,
1639 # Configurable health check chain - ordered list of methods to try.
1640 health_check_methods=settings.mcp_session_pool_health_check_methods,
1641 health_check_timeout_seconds=settings.mcp_session_pool_health_check_timeout,
1642 )
1643 logger.info("MCP session pool initialized")
1645 # Initialize LLM chat router Redis client
1646 # First-Party
1647 from mcpgateway.routers.llmchat_router import init_redis as init_llmchat_redis # pylint: disable=import-outside-toplevel
1649 await init_llmchat_redis()
1651 # Initialize observability (Phoenix tracing)
1652 init_telemetry()
1653 logger.info("Observability initialized")
1655 try:
1656 # Validate security configuration
1657 validate_security_configuration()
1659 if plugin_manager:
1660 logger.debug("plugin_manager.initialize() starting...")
1661 try:
1662 await plugin_manager.initialize()
1663 logger.info(f"Plugin manager initialized with {plugin_manager.plugin_count} plugins")
1664 except Exception as diag_exc:
1665 logger.error(f"plugin_manager.initialize() failed: {diag_exc}", exc_info=True)
1666 raise
1668 if settings.enable_header_passthrough:
1669 await setup_passthrough_headers()
1670 else:
1671 logger.info("🔒 Header Passthrough: DISABLED")
1673 await tool_service.initialize()
1674 await resource_service.initialize()
1675 await prompt_service.initialize()
1676 await gateway_service.initialize()
1678 # Start notification service for event-driven refresh (after gateway_service is ready)
1679 if settings.mcp_session_pool_enabled:
1680 # First-Party
1681 from mcpgateway.services.mcp_session_pool import start_pool_notification_service # pylint: disable=import-outside-toplevel
1683 await start_pool_notification_service(gateway_service)
1685 # Start heartbeat and RPC listener for multi-worker session affinity.
1686 # This must be outside the mcp_session_pool_enabled guard because
1687 # affinity-only deployments (pool disabled, affinity enabled) still
1688 # need heartbeat and RPC forwarding.
1689 if settings.mcpgateway_session_affinity_enabled:
1690 # First-Party
1691 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel
1693 pool = get_mcp_session_pool()
1694 pool.start_heartbeat()
1695 pool._rpc_listener_task = asyncio.create_task(pool.start_rpc_listener()) # pylint: disable=protected-access
1696 logger.info("Multi-worker session affinity heartbeat and RPC listener started")
1698 await root_service.initialize()
1699 await completion_service.initialize()
1700 await sampling_handler.initialize()
1701 await export_service.initialize()
1702 await import_service.initialize()
1703 if a2a_service:
1704 await a2a_service.initialize()
1705 await resource_cache.initialize()
1706 await streamable_http_session.initialize()
1707 await session_registry.initialize()
1709 # Initialize OrchestrationService for tool cancellation if enabled
1710 if settings.mcpgateway_tool_cancellation_enabled:
1711 await cancellation_service.initialize()
1712 logger.info("Tool cancellation feature enabled")
1713 else:
1714 logger.info("Tool cancellation feature disabled")
1716 # Initialize elicitation service
1717 if settings.mcpgateway_elicitation_enabled:
1718 # First-Party
1719 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
1721 elicitation_service = get_elicitation_service()
1722 await elicitation_service.start()
1723 logger.info("Elicitation service initialized")
1725 # Initialize metrics buffer service for batching metric writes
1726 if settings.metrics_buffer_enabled:
1727 # First-Party
1728 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel
1730 metrics_buffer_service = get_metrics_buffer_service()
1731 await metrics_buffer_service.start()
1732 if settings.db_metrics_recording_enabled:
1733 logger.info("Metrics buffer service initialized")
1734 else:
1735 logger.info("Metrics buffer service initialized (recording disabled)")
1737 # Initialize metrics cleanup service for automatic deletion of old metrics
1738 if settings.metrics_cleanup_enabled:
1739 # First-Party
1740 from mcpgateway.services.metrics_cleanup_service import get_metrics_cleanup_service # pylint: disable=import-outside-toplevel
1742 metrics_cleanup_service = get_metrics_cleanup_service()
1743 await metrics_cleanup_service.start()
1744 logger.info("Metrics cleanup service initialized (retention: %d days)", settings.metrics_retention_days)
1746 # Initialize metrics rollup service for hourly aggregation
1747 if settings.metrics_rollup_enabled:
1748 # First-Party
1749 from mcpgateway.services.metrics_rollup_service import get_metrics_rollup_service # pylint: disable=import-outside-toplevel
1751 metrics_rollup_service = get_metrics_rollup_service()
1752 await metrics_rollup_service.start()
1753 logger.info("Metrics rollup service initialized (interval: %dh)", settings.metrics_rollup_interval_hours)
1755 refresh_slugs_on_startup()
1757 # Bootstrap SSO providers from environment configuration
1758 if settings.sso_enabled:
1759 await attempt_to_bootstrap_sso_providers()
1761 logger.info("All services initialized successfully")
1763 _install_sighup_handler()
1765 # Start cache invalidation subscriber for cross-worker cache synchronization
1766 # First-Party
1767 from mcpgateway.cache.registry_cache import get_cache_invalidation_subscriber # pylint: disable=import-outside-toplevel
1769 cache_invalidation_subscriber = get_cache_invalidation_subscriber()
1770 await cache_invalidation_subscriber.start()
1772 # Reconfigure uvicorn loggers after startup to capture access logs in dual output
1773 logging_service.configure_uvicorn_after_startup()
1775 if settings.metrics_aggregation_enabled and settings.metrics_aggregation_auto_start:
1776 aggregation_stop_event = asyncio.Event()
1777 log_aggregator = get_log_aggregator()
1779 async def run_log_backfill() -> None:
1780 """Backfill log aggregation metrics for configured hours."""
1781 hours = getattr(settings, "metrics_aggregation_backfill_hours", 0)
1782 if hours <= 0:
1783 return
1784 try:
1785 await asyncio.to_thread(log_aggregator.backfill, hours)
1786 logger.info("Log aggregation backfill completed for last %s hour(s)", hours)
1787 except Exception as backfill_error: # pragma: no cover - defensive logging
1788 logger.warning("Log aggregation backfill failed: %s", backfill_error)
1790 async def run_log_aggregation_loop() -> None:
1791 """Run continuous log aggregation at configured intervals.
1793 Raises:
1794 asyncio.CancelledError: When aggregation is stopped
1795 """
1796 interval_seconds = max(1, int(settings.metrics_aggregation_window_minutes)) * 60
1797 logger.info(
1798 "Starting log aggregation loop (window=%s min)",
1799 log_aggregator.aggregation_window_minutes,
1800 )
1801 try:
1802 while not aggregation_stop_event.is_set():
1803 try:
1804 await asyncio.to_thread(log_aggregator.aggregate_all_components)
1805 except Exception as agg_error: # pragma: no cover - defensive logging
1806 logger.warning("Log aggregation loop iteration failed: %s", agg_error)
1808 try:
1809 await asyncio.wait_for(aggregation_stop_event.wait(), timeout=interval_seconds)
1810 except asyncio.TimeoutError:
1811 continue
1812 except asyncio.CancelledError:
1813 logger.debug("Log aggregation loop cancelled")
1814 raise
1815 finally:
1816 logger.info("Log aggregation loop stopped")
1818 aggregation_backfill_task = asyncio.create_task(run_log_backfill())
1819 aggregation_loop_task = asyncio.create_task(run_log_aggregation_loop())
1820 elif settings.metrics_aggregation_enabled:
1821 logger.info("Metrics aggregation auto-start disabled; performance metrics will be generated on-demand when requested.")
1823 yield
1824 except Exception as e:
1825 logger.error(f"Error during startup: {str(e)}")
1826 # For plugin errors, exit cleanly without stack trace spam
1827 if "Plugin initialization failed" in str(e):
1828 # Suppress uvicorn error logging for clean exit
1829 logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL)
1830 raise SystemExit(1)
1831 raise
1832 finally:
1833 # Restore default SIGHUP handling in case we reset signal handlers.
1834 try:
1835 _restore_default_sighup_handler()
1836 except Exception as exc: # pragma: no cover - defensive
1837 logger.debug(f"Failed to restore default SIGHUP handler: {exc}")
1839 if aggregation_stop_event is not None:
1840 aggregation_stop_event.set()
1841 for task in (aggregation_backfill_task, aggregation_loop_task):
1842 if task:
1843 task.cancel()
1844 with suppress(asyncio.CancelledError):
1845 await task
1847 # Shutdown plugin manager
1848 if plugin_manager:
1849 try:
1850 await plugin_manager.shutdown()
1851 logger.info("Plugin manager shutdown complete")
1852 except Exception as e:
1853 logger.error(f"Error shutting down plugin manager: {str(e)}")
1855 # Stop cache invalidation subscriber
1856 try:
1857 # First-Party
1858 from mcpgateway.cache.registry_cache import get_cache_invalidation_subscriber # pylint: disable=import-outside-toplevel
1860 cache_invalidation_subscriber = get_cache_invalidation_subscriber()
1861 await cache_invalidation_subscriber.stop()
1862 except Exception as e:
1863 logger.debug(f"Error stopping cache invalidation subscriber: {e}")
1865 logger.info("Shutting down ContextForge services")
1866 # await stop_streamablehttp()
1867 # Build service list conditionally
1868 services_to_shutdown: List[Any] = [
1869 resource_cache,
1870 sampling_handler,
1871 import_service,
1872 export_service,
1873 logging_service,
1874 completion_service,
1875 root_service,
1876 gateway_service,
1877 prompt_service,
1878 resource_service,
1879 tool_service,
1880 streamable_http_session,
1881 session_registry,
1882 ]
1884 # Add cancellation service if enabled
1885 if settings.mcpgateway_tool_cancellation_enabled:
1886 services_to_shutdown.insert(0, cancellation_service) # Shutdown early to stop accepting new cancellations
1888 if a2a_service:
1889 services_to_shutdown.insert(4, a2a_service) # Insert after export_service
1891 # Add elicitation service if enabled
1892 if settings.mcpgateway_elicitation_enabled:
1893 # First-Party
1894 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
1896 elicitation_service = get_elicitation_service()
1897 services_to_shutdown.insert(5, elicitation_service)
1899 # Add metrics buffer service if enabled (flush remaining metrics before shutdown)
1900 if settings.metrics_buffer_enabled:
1901 # First-Party
1902 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel
1904 metrics_buffer_service = get_metrics_buffer_service()
1905 services_to_shutdown.insert(0, metrics_buffer_service) # Shutdown first to flush metrics
1907 # Add metrics rollup service if enabled (shutdown before cleanup)
1908 if settings.metrics_rollup_enabled:
1909 # First-Party
1910 from mcpgateway.services.metrics_rollup_service import get_metrics_rollup_service # pylint: disable=import-outside-toplevel
1912 metrics_rollup_service = get_metrics_rollup_service()
1913 services_to_shutdown.insert(1, metrics_rollup_service)
1915 # Add metrics cleanup service if enabled
1916 if settings.metrics_cleanup_enabled:
1917 # First-Party
1918 from mcpgateway.services.metrics_cleanup_service import get_metrics_cleanup_service # pylint: disable=import-outside-toplevel
1920 metrics_cleanup_service = get_metrics_cleanup_service()
1921 services_to_shutdown.insert(2, metrics_cleanup_service)
1923 await shutdown_services(services_to_shutdown)
1925 # Shutdown MCP session pool (before shared HTTP client)
1926 # Must match the init condition (pool OR affinity) to cover affinity-only deployments.
1927 if settings.mcp_session_pool_enabled or settings.mcpgateway_session_affinity_enabled:
1928 # First-Party
1929 from mcpgateway.services.mcp_session_pool import close_mcp_session_pool # pylint: disable=import-outside-toplevel
1931 await close_mcp_session_pool()
1933 # Shutdown shared HTTP client (after services, before Redis)
1934 await SharedHttpClient.shutdown()
1936 # Close Redis client last (after all services that use it)
1937 await close_redis_client()
1939 logger.info("Shutdown complete")
1942async def shutdown_services(services_to_shutdown: list[Any]):
1943 """
1944 Awaits shutdown of services provided in a list
1946 Args:
1947 services_to_shutdown (list[Any]): list of services to shutdown
1948 """
1949 for service in services_to_shutdown:
1950 try:
1951 await service.shutdown()
1952 except Exception as e:
1953 logger.error(f"Error shutting down {service.__class__.__name__}: {str(e)}")
1956async def setup_passthrough_headers():
1957 """
1958 Enables configuration and logs active settings as needed for when passthrough headers are enabled.
1959 """
1960 logger.info(f"🔄 Header Passthrough: ENABLED (default headers: {settings.default_passthrough_headers})")
1961 if settings.enable_overwrite_base_headers:
1962 logger.warning("⚠️ Base Header Override: ENABLED - Client headers can override gateway headers")
1963 else:
1964 logger.info("🔒 Base Header Override: DISABLED - Gateway headers take precedence")
1965 db_gen = get_db()
1966 db = next(db_gen) # pylint: disable=stop-iteration-return
1967 try:
1968 await set_global_passthrough_headers(db)
1969 finally:
1970 db.commit() # End transaction cleanly
1971 db.close()
1974# Initialize FastAPI app with orjson for 2-3x faster JSON serialization
1975app = FastAPI(
1976 title=settings.app_name,
1977 version=__version__,
1978 description="ContextForge AI Gateway — an AI gateway, registry, and proxy for MCP, A2A, and REST/gRPC APIs. Exposes a unified control plane with centralized governance, discovery, and observability. Optimizes agent and tool calling, and supports plugins.",
1979 root_path=settings.app_root_path,
1980 lifespan=lifespan,
1981 default_response_class=ORJSONResponse, # Use orjson for high-performance JSON serialization
1982)
1984# Setup metrics instrumentation
1985setup_metrics(app)
1988def validate_security_configuration():
1989 """
1990 Validate security configuration on startup.
1991 This function encapsulates:
1992 - verifying the configuration,
1993 - logging the output for warnings,
1994 - critical issues
1995 - security recommendations
1997 Args: None
1998 Raises: Passthrough Errors/Exceptions but doesn't raise any of its own.
1999 """
2000 logger.info("🔒 Validating security configuration...")
2002 # Get security status
2003 security_status: settings.SecurityStatus = settings.get_security_status()
2004 security_warnings = security_status["warnings"]
2006 log_security_warnings(security_warnings)
2008 # Critical security checks (fail startup only if REQUIRE_STRONG_SECRETS=true)
2009 critical_issues = []
2011 if settings.jwt_secret_key in ("my-test-key", "my-test-key-but-now-longer-than-32-bytes") and not settings.dev_mode: # nosec B105 - checking for default values
2012 critical_issues.append("Using default JWT secret in non-dev mode. Set JWT_SECRET_KEY environment variable!")
2014 if settings.basic_auth_password.get_secret_value() == "changeme" and settings.mcpgateway_ui_enabled: # nosec B105 - checking for default value
2015 critical_issues.append("Admin UI enabled with default password. Set BASIC_AUTH_PASSWORD environment variable!")
2017 log_critical_issues(critical_issues)
2019 # Warn about ephemeral storage without strict user-in-DB mode
2020 if not getattr(settings, "require_user_in_db", False):
2021 is_ephemeral = ":memory:" in settings.database_url or settings.database_url == "sqlite:///./mcp.db"
2022 if is_ephemeral:
2023 logger.warning("Using potentially ephemeral storage with platform admin bootstrap enabled. Consider using persistent storage or setting REQUIRE_USER_IN_DB=true for production.")
2025 # Warn about default JWT issuer/audience in non-development environments
2026 if settings.environment != "development":
2027 if settings.jwt_issuer == "mcpgateway":
2028 logger.warning("Using default JWT_ISSUER in %s environment. Set a unique JWT_ISSUER per environment to prevent cross-environment token acceptance.", settings.environment)
2029 if settings.jwt_audience == "mcpgateway-api":
2030 logger.warning("Using default JWT_AUDIENCE in %s environment. Set a unique JWT_AUDIENCE per environment to prevent cross-environment token acceptance.", settings.environment)
2032 log_security_recommendations(security_status)
2035def log_security_warnings(security_warnings: list[str]):
2036 """Log warnings from list of security warnings provided.
2038 Args:
2039 security_warnings: List of security warning messages.
2040 """
2041 if security_warnings:
2042 logger.warning("=" * 60)
2043 logger.warning("🚨 SECURITY WARNINGS DETECTED:")
2044 logger.warning("=" * 60)
2045 for warning in security_warnings:
2046 logger.warning(f" {warning}")
2047 logger.warning("=" * 60)
2050def log_critical_issues(critical_issues: list[Any]):
2051 """
2052 Log critical based on configuration settings
2053 If REQUIRE_STRONG_SECRETS set, this will output critical errors and exit the mcpgateway server.
2055 Args:
2056 critical_issues: List
2058 Returns: None
2059 """
2060 # Handle critical issues based on REQUIRE_STRONG_SECRETS setting
2061 if critical_issues:
2062 if settings.require_strong_secrets:
2063 logger.error("=" * 60)
2064 logger.error("💀 CRITICAL SECURITY ISSUES DETECTED:")
2065 logger.error("=" * 60)
2066 for issue in critical_issues:
2067 logger.error(f" ❌ {issue}")
2068 logger.error("=" * 60)
2069 logger.error("Startup aborted due to REQUIRE_STRONG_SECRETS=true")
2070 logger.error("To proceed anyway, set REQUIRE_STRONG_SECRETS=false")
2071 logger.error("=" * 60)
2072 sys.exit(1)
2073 else:
2074 # Log as warnings if not enforcing
2075 logger.warning("=" * 60)
2076 logger.warning("⚠️ Critical security issues detected (REQUIRE_STRONG_SECRETS=false):")
2077 for issue in critical_issues:
2078 logger.warning(f" • {issue}")
2079 logger.warning("=" * 60)
2082def log_security_recommendations(security_status: settings.SecurityStatus):
2083 """
2084 Log security recommendations based on configuration settings
2086 Args:
2087 security_status (settings.SecurityStatus): The SecurityStatus object for checking and logging current security settings from MCPGateway.
2089 Returns: None
2090 """
2091 if not security_status["secure_secrets"] or not security_status["auth_enabled"]:
2092 logger.info("=" * 60)
2093 logger.info("📋 SECURITY RECOMMENDATIONS:")
2094 logger.info("=" * 60)
2096 if settings.jwt_secret_key in ("my-test-key", "my-test-key-but-now-longer-than-32-bytes"): # nosec B105 - checking for default value
2097 logger.info(" • Generate a strong JWT secret:")
2098 logger.info(" python3 -c 'import secrets; print(secrets.token_urlsafe(32))'")
2100 if settings.basic_auth_password.get_secret_value() == "changeme": # nosec B105 - checking for default value
2101 logger.info(" • Set a strong admin password in BASIC_AUTH_PASSWORD")
2103 if not settings.auth_required:
2104 logger.info(" • Enable authentication: AUTH_REQUIRED=true")
2106 if settings.skip_ssl_verify:
2107 logger.info(" • Enable SSL verification: SKIP_SSL_VERIFY=false")
2109 logger.info("=" * 60)
2111 logger.info("✅ Security validation completed")
2114# Global exceptions handlers
2115@app.exception_handler(ValidationError)
2116async def validation_exception_handler(_request: Request, exc: ValidationError):
2117 """Handle Pydantic validation errors globally.
2119 Intercepts ValidationError exceptions raised anywhere in the application
2120 and returns a properly formatted JSON error response with detailed
2121 validation error information.
2123 Args:
2124 _request: The FastAPI request object that triggered the validation error.
2125 (Unused but required by FastAPI's exception handler interface)
2126 exc: The Pydantic ValidationError exception containing validation
2127 failure details.
2129 Returns:
2130 JSONResponse: A 422 Unprocessable Entity response with formatted
2131 validation error details.
2133 Examples:
2134 >>> from pydantic import ValidationError, BaseModel
2135 >>> from fastapi import Request
2136 >>> import asyncio
2137 >>>
2138 >>> class TestModel(BaseModel):
2139 ... name: str
2140 ... age: int
2141 >>>
2142 >>> # Create a validation error
2143 >>> try:
2144 ... TestModel(name="", age="invalid")
2145 ... except ValidationError as e:
2146 ... # Test our handler
2147 ... result = asyncio.run(validation_exception_handler(None, e))
2148 ... result.status_code
2149 422
2150 """
2151 return ORJSONResponse(status_code=422, content=ErrorFormatter.format_validation_error(exc))
2154@app.exception_handler(RequestValidationError)
2155async def request_validation_exception_handler(_request: Request, exc: RequestValidationError):
2156 """Handle FastAPI request validation errors (automatic request parsing).
2158 This handles ValidationErrors that occur during FastAPI's automatic request
2159 parsing before the request reaches your endpoint.
2161 Args:
2162 _request: The FastAPI request object that triggered validation error.
2163 exc: The RequestValidationError exception containing failure details.
2165 Returns:
2166 JSONResponse: A 422 Unprocessable Entity response with error details.
2167 """
2168 if _request.url.path.startswith("/tools"):
2169 error_details = []
2171 for error in exc.errors():
2172 loc = error.get("loc", [])
2173 msg = error.get("msg", "Unknown error")
2174 ctx = error.get("ctx", {"error": {}})
2175 type_ = error.get("type", "value_error")
2176 # Ensure ctx is JSON serializable
2177 if isinstance(ctx, dict):
2178 ctx_serializable = {k: (str(v) if isinstance(v, Exception) else v) for k, v in ctx.items()}
2179 else:
2180 ctx_serializable = str(ctx)
2181 error_detail = {"type": type_, "loc": loc, "msg": msg, "ctx": ctx_serializable}
2182 error_details.append(error_detail)
2184 response_content = {"detail": error_details}
2185 return ORJSONResponse(status_code=422, content=response_content)
2186 return await fastapi_default_validation_handler(_request, exc)
2189@app.exception_handler(IntegrityError)
2190async def database_exception_handler(_request: Request, exc: IntegrityError):
2191 """Handle SQLAlchemy database integrity constraint violations globally.
2193 Intercepts IntegrityError exceptions (e.g., unique constraint violations,
2194 foreign key constraints) and returns a properly formatted JSON error response.
2195 This provides consistent error handling for database constraint violations
2196 across the entire application.
2198 Args:
2199 _request: The FastAPI request object that triggered the database error.
2200 (Unused but required by FastAPI's exception handler interface)
2201 exc: The SQLAlchemy IntegrityError exception containing constraint
2202 violation details.
2204 Returns:
2205 JSONResponse: A 409 Conflict response with formatted database error details.
2207 Examples:
2208 >>> from sqlalchemy.exc import IntegrityError
2209 >>> from fastapi import Request
2210 >>> import asyncio
2211 >>>
2212 >>> # Create a mock integrity error
2213 >>> mock_error = IntegrityError("statement", {}, Exception("duplicate key"))
2214 >>> result = asyncio.run(database_exception_handler(None, mock_error))
2215 >>> result.status_code
2216 409
2217 >>> # Verify ErrorFormatter.format_database_error is called
2218 >>> hasattr(result, 'body')
2219 True
2220 """
2221 return ORJSONResponse(status_code=409, content=ErrorFormatter.format_database_error(exc))
2224@app.exception_handler(ContentSizeError)
2225async def content_size_exception_handler(_request: Request, exc: ContentSizeError):
2226 """Handle content size limit violations globally.
2228 Args:
2229 _request: The incoming request (unused, required by FastAPI handler interface).
2230 exc: The ContentSizeError with actual_size, max_size, and content_type.
2232 Returns:
2233 ORJSONResponse: A 413 Payload Too Large response with structured error details.
2234 """
2235 return ORJSONResponse(status_code=413, content={"detail": {"error": f"{exc.content_type} size limit exceeded", "message": str(exc), "actual_size": exc.actual_size, "max_size": exc.max_size}})
2238# RFC 9110 §5.6.2 'token' pattern for header field names:
2239# token = 1*tchar
2240# tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
2241# / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
2242# / DIGIT / ALPHA
2243_RFC9110_TOKEN_RE = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
2246def _validate_http_headers(headers: dict[str, str]) -> Optional[dict[str, str]]:
2247 """Validate headers according to RFC 9110.
2249 Args:
2250 headers: dict of headers
2252 Returns:
2253 Optional[dict[str, str]]: dictionary of valid headers
2255 Rules enforced:
2256 - Header name must match RFC 9110 'token'.
2257 - No whitespace before colon (enforced by dictionary usage).
2258 - Header value must not contain CTL characters (0x00–0x1F, 0x7F),
2259 except SP (0x20) and HTAB (0x09) which are allowed.
2260 """
2261 validated: dict[str, str] = {}
2262 for key, value in headers.items():
2263 # Validate header name (RFC 9110 token)
2264 if not _RFC9110_TOKEN_RE.match(key):
2265 logger.warning(f"Invalid header name: {key}")
2266 continue
2267 # RFC 9110: Reject CTLs (0x00–0x1F, 0x7F). Allow SP (0x20) and HTAB (0x09).
2268 valid = True
2269 for ch in value:
2270 code = ord(ch)
2271 if (0 <= code <= 31 or code == 127) and code not in (9, 32):
2272 valid = False
2273 break
2274 if not valid:
2275 logger.warning(f"Header value contains invalid characters: {key}")
2276 continue
2277 validated[key] = value
2278 return validated if validated else None
2281@app.exception_handler(PluginViolationError)
2282async def plugin_violation_exception_handler(_request: Request, exc: PluginViolationError):
2283 """Handle plugins violations globally.
2285 Intercepts PluginViolationError exceptions (e.g., OPA policy violation) and returns a properly formatted JSON error response.
2286 This provides consistent error handling for plugin violation across the entire application.
2288 Args:
2289 _request: The FastAPI request object that triggered the database error.
2290 (Unused but required by FastAPI's exception handler interface)
2291 exc: The PluginViolationError exception containing constraint
2292 violation details.
2294 Returns:
2295 JSONResponse: A response with error details in JSON-RPC format.
2296 Uses HTTP status code from violation if present (e.g., 429 for rate limiting),
2297 otherwise defaults to 200 for JSON-RPC compliance.
2299 Examples:
2300 >>> from mcpgateway.plugins.framework import PluginViolationError
2301 >>> from mcpgateway.plugins.framework.models import PluginViolation
2302 >>> from fastapi import Request
2303 >>> import asyncio
2304 >>> import json
2305 >>>
2306 >>> # Create a plugin violation error
2307 >>> mock_error = PluginViolationError(message="plugin violation",violation = PluginViolation(
2308 ... reason="Invalid input",
2309 ... description="The input contains prohibited content",
2310 ... code="PROHIBITED_CONTENT",
2311 ... details={"field": "message", "value": "test"}
2312 ... ))
2313 >>> result = asyncio.run(plugin_violation_exception_handler(None, mock_error))
2314 >>> result.status_code
2315 422
2316 >>> content = orjson.loads(result.body.decode())
2317 >>> content["error"]["code"]
2318 -32602
2319 >>> "Plugin Violation:" in content["error"]["message"]
2320 True
2321 >>> content["error"]["data"]["plugin_error_code"]
2322 'PROHIBITED_CONTENT'
2323 """
2324 policy_violation = exc.violation.model_dump() if exc.violation else {}
2325 message = exc.violation.description if exc.violation else "A plugin violation occurred."
2326 policy_violation["message"] = exc.message
2327 status_code = exc.violation.mcp_error_code if exc.violation and exc.violation.mcp_error_code else -32602
2328 violation_details: dict[str, Any] = {}
2329 http_status = 200
2330 if exc.violation:
2331 if exc.violation.description:
2332 violation_details["description"] = exc.violation.description
2333 if exc.violation.details:
2334 violation_details["details"] = exc.violation.details
2335 if exc.violation.code:
2336 violation_details["plugin_error_code"] = exc.violation.code
2337 if exc.violation.plugin_name:
2338 violation_details["plugin_name"] = exc.violation.plugin_name
2340 # Use HTTP status code from violation if present (e.g., 429 for rate limiting)
2341 http_status = exc.violation.http_status_code if exc.violation.http_status_code else None
2342 if http_status and not VALID_HTTP_STATUS_CODES.get(http_status):
2343 logger.warning(f"Invalid HTTP status code {http_status} from violation, defaulting to 200")
2344 http_status = None
2345 if not http_status:
2346 logger.debug("Using Plugin violation code mapping for lack of http_status_code")
2347 mapping: Optional[PluginViolationCode] = PLUGIN_VIOLATION_CODE_MAPPING.get(exc.violation.code) if exc.violation.code else None
2348 if not mapping:
2349 http_status = 200
2350 else:
2351 http_status = mapping.code
2353 json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Violation: " + message, data=violation_details)
2355 # Collect HTTP headers from violation if present
2356 headers = exc.violation.http_headers if exc.violation and exc.violation.http_headers else None
2358 response = ORJSONResponse(status_code=http_status, content={"error": json_rpc_error.model_dump()})
2359 if headers:
2360 validated_headers = _validate_http_headers(headers)
2361 if validated_headers:
2362 response.headers.update(validated_headers)
2363 return response
2366@app.exception_handler(PluginError)
2367async def plugin_exception_handler(_request: Request, exc: PluginError):
2368 """Handle plugins errors globally.
2370 Intercepts PluginError exceptions and returns a properly formatted JSON error response.
2371 This provides consistent error handling for plugin error across the entire application.
2373 Args:
2374 _request: The FastAPI request object that triggered the database error.
2375 (Unused but required by FastAPI's exception handler interface)
2376 exc: The PluginError exception containing constraint
2377 violation details.
2379 Returns:
2380 JSONResponse: A 200 response with error details in JSON-RPC format.
2382 Examples:
2383 >>> from mcpgateway.plugins.framework import PluginError
2384 >>> from mcpgateway.plugins.framework.models import PluginErrorModel
2385 >>> from fastapi import Request
2386 >>> import asyncio
2387 >>> import json
2388 >>>
2389 >>> # Create a plugin error
2390 >>> mock_error = PluginError(error = PluginErrorModel(
2391 ... message="plugin error",
2392 ... code="timeout",
2393 ... plugin_name="abc",
2394 ... details={"field": "message", "value": "test"}
2395 ... ))
2396 >>> result = asyncio.run(plugin_exception_handler(None, mock_error))
2397 >>> result.status_code
2398 200
2399 >>> content = orjson.loads(result.body.decode())
2400 >>> content["error"]["code"]
2401 -32603
2402 >>> "Plugin Error:" in content["error"]["message"]
2403 True
2404 >>> content["error"]["data"]["plugin_error_code"]
2405 'timeout'
2406 >>> content["error"]["data"]["plugin_name"]
2407 'abc'
2408 """
2409 message = exc.error.message if exc.error else "A plugin error occurred."
2410 status_code = exc.error.mcp_error_code if exc.error else -32603
2411 error_details: dict[str, Any] = {}
2412 if exc.error:
2413 if exc.error.details:
2414 error_details["details"] = exc.error.details
2415 if exc.error.code:
2416 error_details["plugin_error_code"] = exc.error.code
2417 if exc.error.plugin_name:
2418 error_details["plugin_name"] = exc.error.plugin_name
2419 json_rpc_error = PydanticJSONRPCError(code=status_code, message="Plugin Error: " + message, data=error_details)
2420 return ORJSONResponse(status_code=200, content={"error": json_rpc_error.model_dump()})
2423@app.exception_handler(ContentTypeError)
2424async def content_type_exception_handler(_request: Request, exc: ContentTypeError):
2425 """Handle MIME type validation failures globally.
2427 Args:
2428 _request: The incoming request (unused, required by FastAPI handler interface).
2429 exc: The ContentTypeError with mime_type and allowed_types.
2431 Returns:
2432 ORJSONResponse: A 415 Unsupported Media Type response with error details.
2433 """
2434 return ORJSONResponse(
2435 status_code=415,
2436 content={
2437 "detail": {
2438 "error": "Unsupported MIME type",
2439 "message": str(exc),
2440 "mime_type": exc.mime_type,
2441 "allowed_types": exc.allowed_types[:5], # Limit to first 5
2442 }
2443 },
2444 )
2447def _normalize_scope_path(scope_path: str, root_path: str) -> str:
2448 """Strip ``root_path`` prefix from *scope_path* when a reverse proxy forwards the full path.
2450 Returns the route-only path (e.g. ``"/qa/gateway/docs"`` -> ``"/docs"``).
2451 A ``root_path`` of ``"/"`` is ignored to avoid stripping the leading slash
2452 from every path. Trailing slashes on *root_path* are stripped before
2453 comparison so that ``"/qa/gateway/"`` is handled identically to
2454 ``"/qa/gateway"``.
2456 Args:
2457 scope_path: The full path from the request scope.
2458 root_path: The root path prefix to be stripped.
2460 Returns:
2461 The normalized path with the root_path prefix removed.
2462 """
2463 if root_path and len(root_path) > 1:
2464 root_path = root_path.rstrip("/")
2465 if root_path and len(root_path) > 1 and scope_path.startswith(root_path):
2466 rest = scope_path[len(root_path) :]
2467 # Ensure we matched a full path segment, not a partial prefix
2468 # e.g. root_path="/app" must not strip from "/application/admin"
2469 if not rest or rest[0] == "/":
2470 return rest or "/"
2471 return scope_path
2474class DocsAuthMiddleware(BaseHTTPMiddleware):
2475 """
2476 Middleware to protect FastAPI's auto-generated documentation routes
2477 (/docs, /redoc, and /openapi.json) using Bearer token authentication.
2479 If a request to one of these paths is made without a valid token,
2480 the request is rejected with a 401 or 403 error.
2482 Note:
2483 OPTIONS requests are exempt from authentication to support CORS preflight
2484 as per RFC 7231 Section 4.3.7 (OPTIONS must not require authentication).
2486 Note:
2487 When DOCS_ALLOW_BASIC_AUTH is enabled, Basic Authentication
2488 is also accepted using BASIC_AUTH_USER and BASIC_AUTH_PASSWORD credentials.
2489 """
2491 async def dispatch(self, request: Request, call_next):
2492 """
2493 Intercepts incoming requests to check if they are accessing protected documentation routes.
2494 If so, it requires a valid Bearer token; otherwise, it allows the request to proceed.
2496 Args:
2497 request (Request): The incoming HTTP request.
2498 call_next (Callable): The function to call the next middleware or endpoint.
2500 Returns:
2501 Response: Either the standard route response or a 401/403 error response.
2503 Examples:
2504 >>> import asyncio
2505 >>> from unittest.mock import Mock, AsyncMock, patch
2506 >>> from fastapi import HTTPException
2507 >>> from fastapi.responses import JSONResponse
2508 >>>
2509 >>> # Test unprotected path - should pass through
2510 >>> middleware = DocsAuthMiddleware(None)
2511 >>> request = Mock()
2512 >>> request.url.path = "/api/tools"
2513 >>> request.scope = {"path": "/api/tools", "root_path": ""}
2514 >>> request.method = "GET"
2515 >>> request.headers.get.return_value = None
2516 >>> call_next = AsyncMock(return_value="response")
2517 >>>
2518 >>> result = asyncio.run(middleware.dispatch(request, call_next))
2519 >>> result
2520 'response'
2521 >>>
2522 >>> # Test that middleware checks protected paths
2523 >>> request.url.path = "/docs"
2524 >>> isinstance(middleware, DocsAuthMiddleware)
2525 True
2526 """
2527 protected_paths = ["/docs", "/redoc", "/openapi.json"]
2529 # Allow OPTIONS requests to pass through for CORS preflight (RFC 7231)
2530 if request.method == "OPTIONS":
2531 return await call_next(request)
2533 # Get path from scope to handle root_path correctly
2534 scope_path = request.scope.get("path", request.url.path)
2535 root_path = request.scope.get("root_path", "")
2536 scope_path = _normalize_scope_path(scope_path, root_path)
2538 is_protected = any(scope_path.startswith(p) for p in protected_paths)
2540 if is_protected:
2541 try:
2542 token = request.headers.get("Authorization")
2543 cookie_token = request.cookies.get("jwt_token")
2545 # Use dedicated docs authentication that bypasses global auth settings
2546 await require_docs_auth_override(token, cookie_token)
2547 except HTTPException as e:
2548 return ORJSONResponse(status_code=e.status_code, content={"detail": e.detail}, headers=e.headers if e.headers else None)
2550 # Proceed to next middleware or route
2551 return await call_next(request)
2554class AdminAuthMiddleware(BaseHTTPMiddleware):
2555 """
2556 Middleware to protect Admin UI routes (/admin/*) requiring admin privileges.
2558 Exempts login-related paths and static assets:
2559 - /admin/login - login page
2560 - /admin/logout - logout action
2561 - /admin/forgot-password - self-service password reset request page
2562 - /admin/reset-password/* - self-service password reset completion page
2563 - /admin/static/* - static assets
2565 All other /admin/* routes require the user to be authenticated AND be an admin.
2566 Non-admin authenticated users receive a 403 Forbidden response.
2568 Note: This middleware respects the auth_required setting. When auth_required=False
2569 (typically in test environments), the middleware allows requests to pass through
2570 and relies on endpoint-level authentication which can be mocked in tests.
2571 """
2573 # Public paths under /admin that do not require prior authentication.
2574 EXEMPT_PATHS = [
2575 "/admin/login",
2576 "/admin/logout",
2577 "/admin/forgot-password",
2578 "/admin/reset-password",
2579 "/admin/static",
2580 ]
2582 @staticmethod
2583 def _error_response(request: Request, root_path: str, status_code: int, detail: str, error_param: str = None):
2584 """Return appropriate error response based on request Accept header.
2586 Args:
2587 request: The incoming HTTP request.
2588 root_path: The root path prefix for the application.
2589 status_code: HTTP status code for JSON responses.
2590 detail: Error message detail.
2591 error_param: Optional error parameter for login redirect URL.
2593 Returns:
2594 Response with HX-Redirect for HTMX requests, RedirectResponse for HTML requests, ORJSONResponse for API requests.
2595 """
2596 accept_header = request.headers.get("accept", "")
2597 is_htmx = request.headers.get("hx-request") == "true"
2598 if "text/html" in accept_header or is_htmx:
2599 login_url = f"{root_path}/admin/login" if root_path else "/admin/login"
2600 if error_param:
2601 login_url = f"{login_url}?error={error_param}"
2602 if is_htmx:
2603 return Response(status_code=200, headers={"HX-Redirect": login_url})
2604 return RedirectResponse(url=login_url, status_code=302)
2605 return ORJSONResponse(status_code=status_code, content={"detail": detail})
2607 async def dispatch(self, request: Request, call_next): # pylint: disable=too-many-return-statements
2608 """
2609 Check admin privileges for admin routes.
2611 Args:
2612 request (Request): The incoming HTTP request.
2613 call_next (Callable): The function to call the next middleware or endpoint.
2615 Returns:
2616 Response: Either the standard route response or a 401/403 error response.
2617 """
2618 # Skip admin auth check if auth is not required (e.g., test environments)
2619 # This allows tests to mock authentication at the dependency level
2620 if not settings.auth_required:
2621 return await call_next(request)
2623 # Get path from scope to handle root_path correctly
2624 scope_path = request.scope.get("path", request.url.path)
2625 root_path = request.scope.get("root_path", "")
2626 scope_path = _normalize_scope_path(scope_path, root_path)
2628 # Allow OPTIONS requests for CORS preflight (RFC 7231)
2629 if request.method == "OPTIONS":
2630 return await call_next(request)
2632 # Check if this is an admin route
2633 is_admin_route = scope_path.startswith("/admin")
2635 if not is_admin_route:
2636 return await call_next(request)
2638 # Check if path is exempt (login, logout, static)
2639 is_exempt = any(scope_path.startswith(p) for p in self.EXEMPT_PATHS)
2640 if is_exempt:
2641 return await call_next(request)
2643 # For protected admin routes, verify admin status
2644 try:
2645 token = request.headers.get("Authorization")
2646 cookie_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
2648 # Extract token from header or cookie
2649 jwt_token = None
2650 if cookie_token:
2651 jwt_token = cookie_token
2652 elif token and token.startswith("Bearer "):
2653 jwt_token = token.split(" ", 1)[1]
2655 username = None
2656 token_teams = None
2658 if jwt_token:
2659 # Try JWT authentication first
2660 try:
2661 payload = await verify_jwt_token(jwt_token)
2662 username = payload.get("sub") or payload.get("email")
2664 if not username:
2665 return ORJSONResponse(status_code=401, content={"detail": "Invalid token"})
2667 # Check if token is revoked (if JTI exists)
2668 jti = payload.get("jti")
2669 if jti:
2670 try:
2671 is_revoked = await asyncio.to_thread(_check_token_revoked_sync, jti)
2672 if is_revoked:
2673 logger.warning(f"Admin access denied for revoked token: {SecurityValidator.sanitize_log_message(str(username))}")
2674 return self._error_response(request, root_path, 401, "Token has been revoked", "token_revoked")
2675 except Exception as revoke_error:
2676 logger.warning(f"Token revocation check failed: {revoke_error}")
2677 # Continue - don't fail auth if revocation check fails
2679 # SECURITY: Apply token scope semantics for admin paths.
2680 # Use the same token_use-aware resolution as auth.py.
2681 token_use = payload.get("token_use")
2682 if token_use == "session": # nosec B105 - Not a password; token_use is a JWT claim type
2683 is_admin = payload.get("is_admin", False) or payload.get("user", {}).get("is_admin", False)
2684 token_teams = await resolve_session_teams(payload, username, {"is_admin": is_admin})
2685 else:
2686 # API token or legacy path: embedded teams claim semantics
2687 token_teams = normalize_token_teams(payload)
2688 except Exception:
2689 # JWT validation failed, try API token
2690 token_hash = hashlib.sha256(jwt_token.encode()).hexdigest()
2691 api_token_info = await asyncio.to_thread(_lookup_api_token_sync, token_hash)
2693 if api_token_info:
2694 if api_token_info.get("expired"):
2695 return ORJSONResponse(status_code=401, content={"detail": "API token expired"})
2696 if api_token_info.get("revoked"):
2697 return ORJSONResponse(status_code=401, content={"detail": "API token has been revoked"})
2698 username = api_token_info["user_email"]
2699 logger.debug(f"Admin auth via API token: {SecurityValidator.sanitize_log_message(str(username))}")
2701 # NOTE: Basic auth is NOT supported for admin UI endpoints.
2702 # While AdminAuthMiddleware could validate Basic credentials, the admin
2703 # endpoints use get_current_user_with_permissions which requires JWT tokens.
2704 # Supporting Basic auth would require passing auth context to routes,
2705 # which increases complexity and attack surface. Use JWT or API tokens instead.
2707 if not username and is_proxy_auth_trust_active(settings):
2708 # Proxy authentication path (when MCP client auth is disabled and proxy auth is trusted)
2709 proxy_user = request.headers.get(settings.proxy_user_header)
2710 if proxy_user:
2711 username = proxy_user
2712 logger.debug(f"Admin auth via proxy header: {SecurityValidator.sanitize_log_message(str(username))}")
2714 if not username:
2715 # No authentication method succeeded - redirect to login or return 401
2716 return self._error_response(request, root_path, 401, "Authentication required")
2718 # SECURITY: Public-only tokens (teams=[]) never grant admin-path access,
2719 # even for admin identities. Admin bypass requires explicit teams=null + is_admin=true.
2720 if token_teams is not None and len(token_teams) == 0:
2721 logger.warning(f"Admin access denied for public-only token: {SecurityValidator.sanitize_log_message(str(username))}")
2722 return self._error_response(request, root_path, 403, "Admin privileges required", "admin_required")
2724 # Check if user exists, is active, and has admin permissions
2725 db = next(get_db())
2726 try:
2727 auth_service = EmailAuthService(db)
2728 user = await auth_service.get_user_by_email(username)
2730 if not user:
2731 # Platform admin bootstrap (when REQUIRE_USER_IN_DB=false)
2732 platform_admin_email = getattr(settings, "platform_admin_email", "admin@example.com")
2733 if not settings.require_user_in_db and username == platform_admin_email:
2734 logger.info(f"Platform admin bootstrap authentication for {SecurityValidator.sanitize_log_message(str(username))}")
2735 # Allow platform admin through - they have implicit admin privileges
2736 else:
2737 return self._error_response(request, root_path, 401, "User not found")
2738 else:
2739 # User exists in DB - check active status
2740 if not user.is_active:
2741 logger.warning(f"Admin access denied for disabled user: {SecurityValidator.sanitize_log_message(str(username))}")
2742 return self._error_response(request, root_path, 403, "Account is disabled", "account_disabled")
2744 # Check if user has admin permissions (either is_admin flag OR admin.* RBAC permissions)
2745 # This allows granular admin access for users with specific admin permissions.
2746 # When the request is team-scoped (?team_id=...), include team-scoped roles
2747 # so that developer/viewer roles with admin.dashboard can access the UI.
2748 permission_service = PermissionService(db)
2749 request_team_id = request.query_params.get("team_id")
2750 # Normalize to hex so hyphenated UUIDs match DB-stored hex IDs.
2751 # Fall back to raw value for non-UUID team IDs (e.g. from legacy tokens).
2752 if request_team_id:
2753 try:
2754 request_team_id = uuid.UUID(request_team_id).hex
2755 except (ValueError, AttributeError):
2756 pass # keep raw value for non-UUID token_teams
2757 # Only trust team_id if it is in the user's DB-resolved teams
2758 validated_team_id = request_team_id if (token_teams and request_team_id and request_team_id in token_teams) else None
2759 has_admin_access = await permission_service.has_admin_permission(username, team_id=validated_team_id, token_teams=token_teams)
2760 if not has_admin_access:
2761 logger.warning(f"Admin access denied for user without admin permissions: {SecurityValidator.sanitize_log_message(str(username))}")
2762 return self._error_response(request, root_path, 403, "Admin privileges required", "admin_required")
2763 finally:
2764 db.close()
2766 except HTTPException as e:
2767 return self._error_response(request, root_path, e.status_code, e.detail)
2768 except Exception as e:
2769 logger.error(f"Admin auth middleware error: {e}")
2770 return ORJSONResponse(status_code=500, content={"detail": "Authentication error"})
2772 # Proceed to next middleware or route
2773 return await call_next(request)
2776class MCPPathRewriteMiddleware:
2777 """
2778 Middleware that rewrites paths ending with '/mcp' to '/mcp/', after performing authentication.
2780 - Rewrites paths like '/servers/<server_id>/mcp' to '/mcp/'.
2781 - Only paths ending with '/mcp' or '/mcp/' (but not exactly '/mcp' or '/mcp/') are rewritten.
2782 - Authentication is performed before any path rewriting.
2783 - If authentication fails, the request is not processed further.
2784 - All other requests are passed through without change.
2785 - Routes through the middleware stack (including CORSMiddleware) for proper CORS preflight handling.
2787 Attributes:
2788 application (Callable): The next ASGI application to process the request.
2789 """
2791 def __init__(self, application, dispatch=None):
2792 """
2793 Initialize the middleware with the ASGI application.
2795 Args:
2796 application (Callable): The next ASGI application to handle the request.
2797 dispatch (Callable, optional): An optional dispatch function for additional middleware processing.
2799 Example:
2800 >>> import asyncio
2801 >>> from unittest.mock import AsyncMock, patch
2802 >>> app_mock = AsyncMock()
2803 >>> middleware = MCPPathRewriteMiddleware(app_mock)
2804 >>> isinstance(middleware.application, AsyncMock)
2805 True
2806 """
2807 self.application = application
2808 self.dispatch = dispatch # this can be TokenScopingMiddleware
2810 async def __call__(self, scope, receive, send):
2811 """
2812 Intercept and potentially rewrite the incoming HTTP request path.
2814 Args:
2815 scope (dict): The ASGI connection scope.
2816 receive (Callable): Awaitable that yields events from the client.
2817 send (Callable): Awaitable used to send events to the client.
2819 Examples:
2820 >>> import asyncio
2821 >>> from unittest.mock import AsyncMock, patch
2822 >>> app_mock = AsyncMock()
2823 >>> middleware = MCPPathRewriteMiddleware(app_mock)
2825 >>> # Test path rewriting for /servers/123/mcp
2826 >>> scope = { "type": "http", "path": "/servers/123/mcp", "headers": [(b"host", b"example.com")] }
2827 >>> receive = AsyncMock()
2828 >>> send = AsyncMock()
2829 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True):
2830 ... asyncio.run(middleware(scope, receive, send))
2831 >>> scope["path"]
2832 '/mcp/'
2833 >>> app_mock.assert_called()
2835 >>> # Test regular path (no rewrite)
2836 >>> scope = { "type": "http","path": "/tools","headers": [(b"host", b"example.com")] }
2837 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True):
2838 ... asyncio.run(middleware(scope, receive, send))
2839 ... scope["path"]
2840 '/tools'
2841 """
2842 if scope["type"] != "http":
2843 await self.application(scope, receive, send)
2844 return
2846 # If a dispatch (request middleware) is provided, adapt it
2847 if self.dispatch is not None:
2848 request = starletteRequest(scope, receive=receive)
2850 async def call_next(_req: starletteRequest) -> starletteResponse:
2851 """
2852 Handles the next request in the middleware chain by calling a streamable HTTP response.
2854 Args:
2855 _req (starletteRequest): The incoming request to be processed.
2857 Returns:
2858 starletteResponse: A response generated from the streamable HTTP call.
2859 """
2860 return await self._call_streamable_http(scope, receive, send)
2862 response = await self.dispatch(request, call_next)
2864 if response is None:
2865 # Either the dispatch handled the response itself,
2866 # or it blocked the request. Just return.
2867 return
2869 await response(scope, receive, send)
2870 return
2872 # Otherwise, just continue as normal
2873 await self._call_streamable_http(scope, receive, send)
2875 async def _call_streamable_http(self, scope, receive, send):
2876 """
2877 Handles the streamable HTTP request after authentication and path rewriting.
2879 If auth succeeds and path ends with /mcp, rewrites to /mcp/ and calls self.application
2880 (continuing through middleware stack including CORSMiddleware).
2882 Args:
2883 scope (dict): The ASGI connection scope containing request metadata.
2884 receive (Callable): The function to receive events from the client.
2885 send (Callable): The function to send events to the client.
2887 Example:
2888 >>> import asyncio
2889 >>> from unittest.mock import AsyncMock, patch
2890 >>> app_mock = AsyncMock()
2891 >>> middleware = MCPPathRewriteMiddleware(app_mock)
2892 >>> scope = {"type": "http", "path": "/servers/123/mcp"}
2893 >>> receive = AsyncMock()
2894 >>> send = AsyncMock()
2895 >>> with patch('mcpgateway.main.streamable_http_auth', return_value=True):
2896 ... asyncio.run(middleware._call_streamable_http(scope, receive, send))
2897 >>> app_mock.assert_called_once_with(scope, receive, send)
2898 """
2899 # Auth check first
2900 auth_ok = await streamable_http_auth(scope, receive, send)
2901 if not auth_ok:
2902 return
2904 original_path = scope.get("path", "")
2905 scope["modified_path"] = original_path
2907 # Skip rewriting for well-known URIs (RFC 9728 OAuth metadata, etc.)
2908 # These paths may end with /mcp but should not be rewritten to the MCP transport
2909 if not original_path.startswith("/.well-known/"):
2910 if (original_path.endswith("/mcp") and original_path != "/mcp") or (original_path.endswith("/mcp/") and original_path != "/mcp/"):
2911 # SECURITY: Only rewrite recognised MCP paths — /servers/{id}/mcp.
2912 # Arbitrary prefixes (e.g. /foo/mcp) must NOT be rewritten to
2913 # /mcp/ as that would expose the global MCP transport under
2914 # undocumented aliases, broadening the externally reachable
2915 # route surface.
2916 if original_path.startswith("/servers/"):
2917 # Validate that a non-empty server_id segment is present.
2918 # Without this check, paths like /servers//mcp (empty ID)
2919 # would be rewritten and silently fall through (#3891).
2920 _srv_match = re.match(r"/servers/([^/]+)/mcp", original_path)
2921 if not _srv_match:
2922 response = ORJSONResponse({"detail": "Invalid server identifier"}, status_code=404)
2923 await response(scope, receive, send)
2924 return
2925 else:
2926 # Not a /servers/ path — do not rewrite, pass through
2927 await self.application(scope, receive, send)
2928 return
2929 # Rewrite to /mcp/ and continue through middleware (lets CORSMiddleware handle preflight)
2930 scope["path"] = "/mcp/"
2931 await self.application(scope, receive, send)
2932 return
2933 await self.application(scope, receive, send)
2936# Configure CORS with environment-aware origins
2937cors_origins = list(settings.allowed_origins) if settings.allowed_origins else []
2939# Ensure we never use wildcard in production
2940if settings.environment == "production" and not cors_origins:
2941 logger.warning("No CORS origins configured for production environment. CORS will be disabled.")
2942 cors_origins = []
2944app.add_middleware(
2945 CORSMiddleware,
2946 allow_origins=cors_origins,
2947 allow_credentials=settings.cors_allow_credentials,
2948 allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
2949 allow_headers=["*"],
2950 expose_headers=["Content-Length", "X-Request-ID", "X-Password-Change-Required"],
2951 max_age=600, # Cache preflight requests for 10 minutes
2952)
2954# Add response compression middleware (Brotli, Zstd, GZip)
2955# Automatically negotiates compression algorithm based on client Accept-Encoding header
2956# Priority: Brotli (best compression) > Zstd (fast) > GZip (universal fallback)
2957# Only compress responses larger than minimum_size to avoid overhead
2958# NOTE: When json_response_enabled=False (SSE mode), /mcp paths are excluded from
2959# compression to prevent buffering/breaking of streaming responses. See middleware/compression.py.
2960if settings.compression_enabled:
2961 app.add_middleware(
2962 SSEAwareCompressMiddleware,
2963 minimum_size=settings.compression_minimum_size,
2964 gzip_level=settings.compression_gzip_level,
2965 brotli_quality=settings.compression_brotli_quality,
2966 zstd_level=settings.compression_zstd_level,
2967 )
2968 logger.info(
2969 f"🗜️ Response compression enabled (SSE-aware): minimum_size={settings.compression_minimum_size}B, "
2970 f"gzip_level={settings.compression_gzip_level}, "
2971 f"brotli_quality={settings.compression_brotli_quality}, "
2972 f"zstd_level={settings.compression_zstd_level}"
2973 )
2974else:
2975 logger.info("🚫 Response compression disabled")
2977# Add security headers middleware
2978app.add_middleware(SecurityHeadersMiddleware)
2980# Add validation middleware if explicitly enabled
2981if settings.validation_middleware_enabled:
2982 app.add_middleware(ValidationMiddleware)
2983 logger.info("🔒 Input validation and output sanitization middleware enabled")
2984else:
2985 logger.info("🔒 Input validation and output sanitization middleware disabled")
2987# Add MCP Protocol Version validation middleware (validates MCP-Protocol-Version header)
2988app.add_middleware(MCPProtocolVersionMiddleware)
2990# Add token scoping middleware (only when email auth is enabled)
2991if settings.email_auth_enabled:
2992 app.add_middleware(BaseHTTPMiddleware, dispatch=token_scoping_middleware)
2993 # Add streamable HTTP middleware for /mcp routes with token scoping
2994 app.add_middleware(MCPPathRewriteMiddleware, dispatch=token_scoping_middleware)
2995else:
2996 # Add streamable HTTP middleware for /mcp routes
2997 app.add_middleware(MCPPathRewriteMiddleware)
2999# Add HTTP authentication hook middleware for plugins (before auth dependencies)
3000if plugin_manager:
3001 app.add_middleware(HttpAuthMiddleware, plugin_manager=plugin_manager)
3002 logger.info("🔌 HTTP authentication hooks enabled for plugins")
3004# Add request logging middleware FIRST (always enabled for gateway boundary logging)
3005# IMPORTANT: Must be registered BEFORE CorrelationIDMiddleware so it executes AFTER correlation ID is set
3006# Gateway boundary logging (request_started/completed) runs regardless of log_requests setting
3007# Detailed payload logging only runs if log_detailed_requests=True
3008app.add_middleware(
3009 RequestLoggingMiddleware,
3010 enable_gateway_logging=True,
3011 log_detailed_requests=settings.log_requests,
3012 log_level=settings.log_level,
3013 max_body_size=settings.log_detailed_max_body_size,
3014 log_resolve_user_identity=settings.log_resolve_user_identity,
3015 log_detailed_skip_endpoints=settings.log_detailed_skip_endpoints,
3016 log_detailed_sample_rate=settings.log_detailed_sample_rate,
3017)
3019# Add custom DocsAuthMiddleware
3020app.add_middleware(DocsAuthMiddleware)
3022# Add AdminAuthMiddleware to protect admin routes (requires admin privileges)
3023# This ensures all /admin/* routes (except login/logout) require admin status
3024app.add_middleware(AdminAuthMiddleware)
3026# Trust all proxies (or lock down with a list of host patterns)
3027app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
3029# Add correlation ID middleware if enabled
3030# Note: Registered AFTER RequestLoggingMiddleware so correlation ID is available when RequestLoggingMiddleware executes
3031if settings.correlation_id_enabled:
3032 app.add_middleware(CorrelationIDMiddleware)
3033 logger.info(f"✅ Correlation ID tracking enabled (header: {settings.correlation_id_header})")
3035# Add authentication context middleware if security logging is enabled
3036# This middleware extracts user context and logs security events (authentication attempts)
3037# Note: This is independent of observability - security logging is always important
3038if settings.security_logging_enabled:
3039 # First-Party
3040 from mcpgateway.middleware.auth_middleware import AuthContextMiddleware
3042 app.add_middleware(AuthContextMiddleware)
3043 logger.info("🔐 Authentication context middleware enabled - logging security events")
3044else:
3045 logger.info("🔐 Security event logging disabled")
3047# Add token usage logging middleware
3048# This tracks API token usage for analytics and security monitoring
3049# Note: Runs after AuthContextMiddleware so request.state.auth_method is available
3050if settings.token_usage_logging_enabled:
3051 # First-Party
3052 from mcpgateway.middleware.token_usage_middleware import TokenUsageMiddleware # noqa: E402
3054 app.add_middleware(TokenUsageMiddleware)
3055 logger.info("📊 Token usage logging middleware enabled - tracking API token usage")
3056else:
3057 logger.info("📊 Token usage logging middleware disabled")
3059# Add observability middleware if enabled
3060# Note: Middleware runs in REVERSE order (last added runs first)
3061# If AuthContextMiddleware is already registered, ObservabilityMiddleware wraps it
3062# Execution order will be: AuthContext -> Observability -> Request Handler
3063# Wire observability adapter into the plugin manager when observability is enabled
3064if settings.observability_enabled:
3065 # First-Party
3066 from mcpgateway.middleware.observability_middleware import ObservabilityMiddleware
3067 from mcpgateway.plugins.observability_adapter import ObservabilityServiceAdapter
3068 from mcpgateway.services.observability_service import ObservabilityService
3070 _service = ObservabilityService()
3071 app.add_middleware(ObservabilityMiddleware, enabled=True, service=_service)
3072 if plugin_manager:
3073 plugin_manager.observability = ObservabilityServiceAdapter(service=_service)
3074 logger.info("🔍 Observability middleware enabled - tracing include-listed requests")
3075else:
3076 logger.info("🔍 Observability middleware disabled")
3078# Add OTEL request-root tracing middleware when external tracing is enabled.
3079# Registered last so it wraps the full request path, including mounted /mcp ASGI handling.
3080if otel_tracing_enabled():
3081 app.add_middleware(OpenTelemetryRequestMiddleware)
3082 logger.info("🧵 OTEL request tracing middleware enabled for transport request roots")
3083else:
3084 logger.info("🧵 OTEL request tracing middleware disabled")
3086# Database query logging middleware (for N+1 detection)
3087if settings.db_query_log_enabled:
3088 # First-Party
3089 from mcpgateway.db import engine
3090 from mcpgateway.middleware.db_query_logging import setup_query_logging
3092 setup_query_logging(app, engine)
3093 logger.info(f"📊 Database query logging enabled - logs: {settings.db_query_log_file}")
3094else:
3095 logger.debug("📊 Database query logging disabled (enable with DB_QUERY_LOG_ENABLED=true)")
3097# Set up Jinja2 templates and store in app state for later use
3098# auto_reload=False in production prevents re-parsing templates on each request (performance)
3099jinja_env = Environment(
3100 loader=FileSystemLoader(str(settings.templates_dir)),
3101 autoescape=True,
3102 auto_reload=settings.templates_auto_reload,
3103)
3106# Add custom filter to decode HTML entities for backward compatibility with old database records
3107# that were stored with HTML entities (e.g., ' instead of ')
3108# NOTE: This filter can be removed after all deployments have run the c1c2c3c4c5c6 migration,
3109# which decodes all existing HTML entities in the database. After that migration, this filter
3110# becomes a no-op since new data is stored without HTML encoding.
3111def decode_html_entities(value: str) -> str:
3112 """Decode HTML entities in strings for display.
3114 This filter handles legacy data that was stored with HTML entities.
3115 New data is stored without encoding, but this ensures old records display correctly.
3117 TEMPORARY: Can be removed after c1c2c3c4c5c6 migration has been applied to all deployments.
3119 Args:
3120 value: String that may contain HTML entities
3122 Returns:
3123 String with HTML entities decoded to their original characters
3124 """
3125 if not value:
3126 return value
3128 return html.unescape(value)
3131jinja_env.filters["decode_html"] = decode_html_entities
3134def tojson_attr(value: object) -> str:
3135 """JSON-encode a value for safe use inside double-quoted HTML attributes.
3137 Unlike the built-in ``|tojson`` filter (which returns ``Markup``, bypassing
3138 autoescape), this filter returns a plain ``str``. Jinja2 autoescape then
3139 HTML-encodes the ``"`` characters to ``"``, keeping the enclosing
3140 ``"``-delimited HTML attribute intact. The browser decodes the entities
3141 back to ``"`` before passing the value to the JS engine.
3143 Use ``|tojson_attr`` for inline event handlers (``onclick``, ``onsubmit``).
3144 Use the built-in ``|tojson`` for ``<script>`` blocks (where ``Markup`` is fine).
3146 Args:
3147 value: Any JSON-serialisable object.
3149 Returns:
3150 Plain string with JSON content (autoescape will HTML-encode it).
3151 """
3152 s = orjson.dumps(value, default=str).decode()
3153 # Same HTML-safety replacements as Jinja2's htmlsafe_json_dumps,
3154 # but we return a plain str so autoescape encodes the remaining `"`.
3155 s = s.replace("&", "\\u0026").replace("<", "\\u003c").replace(">", "\\u003e").replace("'", "\\u0027")
3156 return s
3159jinja_env.filters["tojson_attr"] = tojson_attr
3161templates = Jinja2Templates(env=jinja_env)
3162if not settings.templates_auto_reload:
3163 logger.info("🎨 Template auto-reload disabled (production mode)")
3164app.state.templates = templates
3166# Store plugin manager in app state for access in routes
3167app.state.plugin_manager = plugin_manager
3169# Initialize plugin service with plugin manager
3170if plugin_manager:
3171 # First-Party
3172 from mcpgateway.services.plugin_service import get_plugin_service
3174 plugin_service = get_plugin_service()
3175 plugin_service.set_plugin_manager(plugin_manager)
3177# Create API routers
3178protocol_router = APIRouter(prefix="/protocol", tags=["Protocol"])
3179tool_router = APIRouter(prefix="/tools", tags=["Tools"])
3180resource_router = APIRouter(prefix="/resources", tags=["Resources"])
3181prompt_router = APIRouter(prefix="/prompts", tags=["Prompts"])
3182gateway_router = APIRouter(prefix="/gateways", tags=["Gateways"])
3183root_router = APIRouter(prefix="/roots", tags=["Roots"])
3184utility_router = APIRouter(tags=["Utilities"])
3185server_router = APIRouter(prefix="/servers", tags=["Servers"])
3186metrics_router = APIRouter(prefix="/metrics", tags=["Metrics"])
3187tag_router = APIRouter(prefix="/tags", tags=["Tags"])
3188export_import_router = APIRouter(tags=["Export/Import"])
3189a2a_router = APIRouter(prefix="/a2a", tags=["A2A Agents"])
3191# Basic Auth setup
3194# Database dependency
3195def get_db(request: Request = None):
3196 """
3197 Dependency function to provide a database session.
3199 When observability is enabled, this reuses the session created by
3200 ObservabilityMiddleware (stored in request.state.db) to avoid duplicate
3201 session creation. When observability is disabled or the middleware hasn't
3202 created a session, this creates its own session.
3204 **Transaction Control**: This function ALWAYS controls transaction boundaries
3205 (commit/rollback) regardless of whether it creates the session or reuses one
3206 from middleware. This ensures predictable transaction semantics for route
3207 handlers and maintains data integrity.
3209 **Session Lifecycle**: Middleware manages session lifecycle (create/close)
3210 while this function manages transactions (commit/rollback). This separation
3211 of concerns prevents the transaction management violation described in #3731.
3213 Commits the transaction on successful completion to avoid implicit rollbacks
3214 for read-only operations. Rolls back explicitly on exception.
3216 This function handles connection failures gracefully by invalidating broken
3217 connections. When a connection is broken (e.g., due to PgBouncer timeout or
3218 network issues), the rollback will fail. In this case, we invalidate the
3219 session to ensure the broken connection is discarded from the pool rather
3220 than being returned in a bad state.
3222 Args:
3223 request: Optional FastAPI request object (injected automatically)
3225 Yields:
3226 Session: A SQLAlchemy session object for interacting with the database.
3228 Raises:
3229 Exception: Re-raises any exception after rolling back the transaction.
3231 Ensures:
3232 - Transaction is committed on success (for both owned and reused sessions)
3233 - Transaction is rolled back on error (for both owned and reused sessions)
3234 - Session is closed only if created by this function (not if reused from middleware)
3235 - Broken connections are invalidated to prevent pool corruption
3237 Examples:
3238 >>> # Test that get_db returns a generator
3239 >>> db_gen = get_db()
3240 >>> hasattr(db_gen, '__next__')
3241 True
3242 >>> # Test cleanup happens
3243 >>> try:
3244 ... db = next(db_gen)
3245 ... type(db).__name__
3246 ... finally:
3247 ... try:
3248 ... next(db_gen)
3249 ... except StopIteration:
3250 ... pass # Expected - generator cleanup
3251 'ResilientSession'
3252 """
3253 # Check if ObservabilityMiddleware already created a request-scoped session
3254 # This eliminates duplicate session creation when observability is enabled (Issue #3467)
3255 if request is not None and hasattr(request, "state") and hasattr(request.state, "db"):
3256 db = request.state.db
3257 if db is not None:
3258 logger.debug(f"[GET_DB] Reusing session from middleware: {id(db)}")
3259 # Yield the middleware's session. We control transactions, middleware controls lifecycle.
3260 try:
3261 yield db
3262 # Commit on successful completion (only if transaction still active)
3263 # The transaction can become inactive if an exception occurred during
3264 # async context manager cleanup (e.g., CancelledError during MCP session teardown).
3265 if db.is_active:
3266 db.commit()
3267 except Exception:
3268 try:
3269 # Always call rollback() in exception handler.
3270 # rollback() is safe to call even when is_active=False - it succeeds and
3271 # restores the session to a usable state. When is_active=False (e.g., after
3272 # IntegrityError), rollback() is actually REQUIRED to clear the failed state.
3273 # Skipping rollback when is_active=False would leave the session unusable.
3274 db.rollback()
3275 except Exception:
3276 # Connection is broken - invalidate to remove from pool
3277 # This handles cases like PgBouncer query_wait_timeout where
3278 # the connection is dead and rollback itself fails
3279 try:
3280 db.invalidate()
3281 except Exception:
3282 pass # nosec B110 - Best effort cleanup on connection failure
3283 raise
3284 # Don't close - middleware owns the session lifecycle
3285 return
3287 # Fallback: Create our own session (observability disabled or middleware didn't create one)
3288 db = SessionLocal()
3289 logger.debug(f"[GET_DB] DB session created: {id(db)}")
3290 try:
3291 yield db
3292 # Only commit if the transaction is still active.
3293 # The transaction can become inactive if an exception occurred during
3294 # async context manager cleanup (e.g., CancelledError during MCP session teardown).
3295 if db.is_active:
3296 db.commit()
3297 except Exception:
3298 try:
3299 # Always call rollback() in exception handler.
3300 # rollback() is safe to call even when is_active=False - it succeeds and
3301 # restores the session to a usable state. When is_active=False (e.g., after
3302 # IntegrityError), rollback() is actually REQUIRED to clear the failed state.
3303 # Skipping rollback when is_active=False would leave the session unusable.
3304 db.rollback()
3305 except Exception:
3306 # Connection is broken - invalidate to remove from pool
3307 # This handles cases like PgBouncer query_wait_timeout where
3308 # the connection is dead and rollback itself fails
3309 try:
3310 db.invalidate()
3311 except Exception:
3312 pass # nosec B110 - Best effort cleanup on connection failure
3313 raise
3314 finally:
3315 try:
3316 db.close()
3317 except Exception:
3318 pass # nosec B110 - Best effort cleanup on already-failed prompt bridge sessions
3321async def require_valid_server(server_id: str, db: Session = Depends(get_db)) -> str:
3322 """FastAPI dependency that validates a server_id exists in the database.
3324 Provides a reusable, fail-closed guard for any server-scoped endpoint.
3325 Uses the lightweight ``entity_exists()`` check — no eager loading.
3327 Args:
3328 server_id: Path parameter extracted by FastAPI.
3329 db: Database session from the ``get_db`` dependency.
3331 Returns:
3332 The validated server_id string.
3334 Raises:
3335 HTTPException: 404 if the server does not exist, 503 on database errors.
3336 """
3337 try:
3338 if not await server_service.entity_exists(db, server_id):
3339 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Server not found")
3340 except HTTPException:
3341 raise
3342 except Exception:
3343 raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Service unavailable — unable to verify server")
3344 return server_id
3347async def _read_request_json(request: Request) -> Any:
3348 """Read JSON payload using orjson.
3350 Args:
3351 request: Incoming FastAPI request to read JSON from.
3353 Returns:
3354 Parsed JSON payload.
3356 Raises:
3357 HTTPException: 400 for invalid JSON bodies.
3358 """
3359 body = await request.body()
3360 if not body:
3361 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body")
3362 try:
3363 return orjson.loads(body)
3364 except orjson.JSONDecodeError as exc:
3365 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid JSON in request body") from exc
3368def require_api_key(api_key: str) -> None:
3369 """Validates the provided API key.
3371 This function checks if the provided API key matches the expected one
3372 based on the settings. If the validation fails, it raises an HTTPException
3373 with a 401 Unauthorized status.
3375 Args:
3376 api_key (str): The API key provided by the user or client.
3378 Raises:
3379 HTTPException: If the API key is invalid, a 401 Unauthorized error is raised.
3381 Examples:
3382 >>> from mcpgateway.config import settings
3383 >>> from pydantic import SecretStr
3384 >>> settings.auth_required = True
3385 >>> settings.basic_auth_user = "admin"
3386 >>> settings.basic_auth_password = SecretStr("secret")
3387 >>>
3388 >>> # Valid API key
3389 >>> require_api_key("admin:secret") # Should not raise
3390 >>>
3391 >>> # Invalid API key
3392 >>> try:
3393 ... require_api_key("wrong:key")
3394 ... except HTTPException as e:
3395 ... e.status_code
3396 401
3397 """
3398 if settings.auth_required:
3399 expected = f"{settings.basic_auth_user}:{settings.basic_auth_password.get_secret_value()}"
3400 if api_key != expected:
3401 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key")
3404async def invalidate_resource_cache(uri: Optional[str] = None) -> None:
3405 """
3406 Invalidates the resource cache.
3408 If a specific URI is provided, only that resource will be removed from the cache.
3409 If no URI is provided, the entire resource cache will be cleared.
3411 Args:
3412 uri (Optional[str]): The URI of the resource to invalidate from the cache. If None, the entire cache is cleared.
3414 Examples:
3415 >>> import asyncio
3416 >>> # Test clearing specific URI from cache
3417 >>> resource_cache.set("/test/resource", {"content": "test data"})
3418 >>> resource_cache.get("/test/resource") is not None
3419 True
3420 >>> asyncio.run(invalidate_resource_cache("/test/resource"))
3421 >>> resource_cache.get("/test/resource") is None
3422 True
3423 >>>
3424 >>> # Test clearing entire cache
3425 >>> resource_cache.set("/resource1", {"content": "data1"})
3426 >>> resource_cache.set("/resource2", {"content": "data2"})
3427 >>> asyncio.run(invalidate_resource_cache())
3428 >>> resource_cache.get("/resource1") is None and resource_cache.get("/resource2") is None
3429 True
3430 """
3431 if uri:
3432 resource_cache.delete(uri)
3433 else:
3434 resource_cache.clear()
3437def get_protocol_from_request(request: Request) -> str:
3438 """
3439 Return "https" or "http" based on:
3440 1) X-Forwarded-Proto (if set by a proxy)
3441 2) request.url.scheme (e.g. when Gunicorn/Uvicorn is terminating TLS)
3443 Args:
3444 request (Request): The FastAPI request object.
3446 Returns:
3447 str: The protocol used for the request, either "http" or "https".
3449 Examples:
3450 Test with X-Forwarded-Proto header (proxy scenario):
3451 >>> from mcpgateway import main
3452 >>> from fastapi import Request
3453 >>> from urllib.parse import urlparse
3454 >>>
3455 >>> # Mock request with X-Forwarded-Proto
3456 >>> scope = {
3457 ... 'type': 'http',
3458 ... 'scheme': 'http',
3459 ... 'headers': [(b'x-forwarded-proto', b'https')],
3460 ... 'server': ('testserver', 80),
3461 ... 'path': '/',
3462 ... }
3463 >>> req = Request(scope)
3464 >>> main.get_protocol_from_request(req)
3465 'https'
3467 Test with comma-separated X-Forwarded-Proto:
3468 >>> scope_multi = {
3469 ... 'type': 'http',
3470 ... 'scheme': 'http',
3471 ... 'headers': [(b'x-forwarded-proto', b'https,http')],
3472 ... 'server': ('testserver', 80),
3473 ... 'path': '/',
3474 ... }
3475 >>> req_multi = Request(scope_multi)
3476 >>> main.get_protocol_from_request(req_multi)
3477 'https'
3479 Test without X-Forwarded-Proto (direct connection):
3480 >>> scope_direct = {
3481 ... 'type': 'http',
3482 ... 'scheme': 'https',
3483 ... 'headers': [],
3484 ... 'server': ('testserver', 443),
3485 ... 'path': '/',
3486 ... }
3487 >>> req_direct = Request(scope_direct)
3488 >>> main.get_protocol_from_request(req_direct)
3489 'https'
3491 Test with HTTP direct connection:
3492 >>> scope_http = {
3493 ... 'type': 'http',
3494 ... 'scheme': 'http',
3495 ... 'headers': [],
3496 ... 'server': ('testserver', 80),
3497 ... 'path': '/',
3498 ... }
3499 >>> req_http = Request(scope_http)
3500 >>> main.get_protocol_from_request(req_http)
3501 'http'
3502 """
3503 forwarded = request.headers.get("x-forwarded-proto")
3504 if forwarded:
3505 # may be a comma-separated list; take the first
3506 return forwarded.split(",")[0].strip()
3507 return request.url.scheme
3510def update_url_protocol(request: Request) -> str:
3511 """
3512 Update the base URL protocol based on the request's scheme or forwarded headers.
3514 Args:
3515 request (Request): The FastAPI request object.
3517 Returns:
3518 str: The base URL with the correct protocol.
3520 Examples:
3521 Test URL protocol update with HTTPS proxy:
3522 >>> from mcpgateway import main
3523 >>> from fastapi import Request
3524 >>>
3525 >>> # Mock request with HTTPS forwarded proto
3526 >>> scope_https = {
3527 ... 'type': 'http',
3528 ... 'scheme': 'http',
3529 ... 'server': ('example.com', 80),
3530 ... 'path': '/',
3531 ... 'headers': [(b'x-forwarded-proto', b'https')],
3532 ... }
3533 >>> req_https = Request(scope_https)
3534 >>> url = main.update_url_protocol(req_https)
3535 >>> url.startswith('https://example.com')
3536 True
3538 Test URL protocol update with HTTP direct:
3539 >>> scope_http = {
3540 ... 'type': 'http',
3541 ... 'scheme': 'http',
3542 ... 'server': ('localhost', 8000),
3543 ... 'path': '/',
3544 ... 'headers': [],
3545 ... }
3546 >>> req_http = Request(scope_http)
3547 >>> url = main.update_url_protocol(req_http)
3548 >>> url.startswith('http://localhost:8000')
3549 True
3551 Test URL protocol update preserves host and port:
3552 >>> scope_port = {
3553 ... 'type': 'http',
3554 ... 'scheme': 'https',
3555 ... 'server': ('api.test.com', 443),
3556 ... 'path': '/',
3557 ... 'headers': [],
3558 ... }
3559 >>> req_port = Request(scope_port)
3560 >>> url = main.update_url_protocol(req_port)
3561 >>> 'api.test.com' in url and url.startswith('https://')
3562 True
3564 Test trailing slash removal:
3565 >>> # URL should not end with trailing slash
3566 >>> url = main.update_url_protocol(req_http)
3567 >>> url.endswith('/')
3568 False
3569 """
3570 parsed = urlparse(str(request.base_url))
3571 proto = get_protocol_from_request(request)
3572 new_parsed = parsed._replace(scheme=proto)
3573 # urlunparse keeps netloc and path intact
3574 return str(urlunparse(new_parsed)).rstrip("/")
3577# Protocol APIs #
3578@protocol_router.post("/initialize")
3579async def initialize(request: Request, user=Depends(get_current_user)) -> InitializeResult:
3580 """
3581 Initialize a protocol.
3583 This endpoint handles the initialization process of a protocol by accepting
3584 a JSON request body and processing it. The `require_auth` dependency ensures that
3585 the user is authenticated before proceeding.
3587 Args:
3588 request (Request): The incoming request object containing the JSON body.
3589 user (str): The authenticated user (from `require_auth` dependency).
3591 Returns:
3592 InitializeResult: The result of the initialization process.
3594 Raises:
3595 HTTPException: If the request body contains invalid JSON, a 400 Bad Request error is raised.
3596 """
3597 try:
3598 body = await _read_request_json(request)
3600 logger.debug(f"Authenticated user {SecurityValidator.sanitize_log_message(str(user))} is initializing the protocol.")
3601 return await session_registry.handle_initialize_logic(body)
3603 except orjson.JSONDecodeError:
3604 raise HTTPException(
3605 status_code=status.HTTP_400_BAD_REQUEST,
3606 detail="Invalid JSON in request body",
3607 )
3610@protocol_router.post("/ping")
3611async def ping(request: Request, user=Depends(get_current_user)) -> JSONResponse:
3612 """
3613 Handle a ping request according to the MCP specification.
3615 This endpoint expects a JSON-RPC request with the method "ping" and responds
3616 with a JSON-RPC response containing an empty result, as required by the protocol.
3618 Args:
3619 request (Request): The incoming FastAPI request.
3620 user (str): The authenticated user (dependency injection).
3622 Returns:
3623 JSONResponse: A JSON-RPC response with an empty result or an error response.
3625 Raises:
3626 HTTPException: If the request method is not "ping".
3627 """
3628 req_id: Optional[str] = None
3629 try:
3630 body: dict = await _read_request_json(request)
3631 if body.get("method") != "ping":
3632 raise HTTPException(status_code=400, detail="Invalid method")
3633 req_id = body.get("id")
3634 logger.debug(f"Authenticated user {SecurityValidator.sanitize_log_message(str(user))} sent ping request.")
3635 # Return an empty result per the MCP ping specification.
3636 response: dict = {"jsonrpc": "2.0", "id": req_id, "result": {}}
3637 return ORJSONResponse(content=response)
3638 except Exception as e:
3639 error_response: dict = {
3640 "jsonrpc": "2.0",
3641 "id": req_id, # Now req_id is always defined
3642 "error": {"code": -32603, "message": "Internal error", "data": str(e)},
3643 }
3644 return ORJSONResponse(status_code=500, content=error_response)
3647@protocol_router.post("/notifications")
3648async def handle_notification(request: Request, user=Depends(get_current_user)) -> None:
3649 """
3650 Handles incoming notifications from clients. Depending on the notification method,
3651 different actions are taken (e.g., logging initialization, cancellation, or messages).
3653 Args:
3654 request (Request): The incoming request containing the notification data.
3655 user (str): The authenticated user making the request.
3656 """
3657 body = await _read_request_json(request)
3658 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} sent a notification")
3659 if body.get("method") == "notifications/initialized":
3660 logger.info("Client initialized")
3661 await logging_service.notify("Client initialized", LogLevel.INFO)
3662 elif body.get("method") == "notifications/cancelled":
3663 # Note: requestId can be 0 (valid per JSON-RPC), so use 'is not None' and normalize to string
3664 raw_request_id = body.get("params", {}).get("requestId")
3665 request_id = str(raw_request_id) if raw_request_id is not None else None
3666 reason = body.get("params", {}).get("reason")
3667 logger.info(f"Request cancelled: {request_id}, reason: {reason}")
3668 # Attempt local cancellation per MCP spec
3669 if request_id is not None:
3670 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=False)
3671 await cancellation_service.cancel_run(request_id, reason=reason)
3672 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO)
3673 elif body.get("method") == "notifications/message":
3674 params = body.get("params", {})
3675 await logging_service.notify(
3676 params.get("data"),
3677 LogLevel(params.get("level", "info")),
3678 params.get("logger"),
3679 )
3682@protocol_router.post("/completion/complete")
3683async def handle_completion(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
3684 """
3685 Handles the completion of tasks by processing a completion request.
3687 Args:
3688 request (Request): The incoming request with completion data.
3689 db (Session): The database session used to interact with the data store.
3690 user (str): The authenticated user making the request.
3692 Returns:
3693 The result of the completion process.
3694 """
3695 body = await _read_request_json(request)
3696 logger.debug(f"User {SecurityValidator.sanitize_log_message(user['email'])} sent a completion request")
3697 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
3698 if is_admin and token_teams is None:
3699 user_email = None
3700 elif token_teams is None:
3701 token_teams = []
3702 return await completion_service.handle_completion(db, body, user_email=user_email, token_teams=token_teams)
3705@protocol_router.post("/sampling/createMessage")
3706async def handle_sampling(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
3707 """
3708 Handles the creation of a new message for sampling.
3710 Args:
3711 request (Request): The incoming request with sampling data.
3712 db (Session): The database session used to interact with the data store.
3713 user (str): The authenticated user making the request.
3715 Returns:
3716 The result of the message creation process.
3717 """
3718 logger.debug(f"User {SecurityValidator.sanitize_log_message(user['email'])} sent a sampling request")
3719 body = await _read_request_json(request)
3720 return await sampling_handler.create_message(db, body)
3723###############
3724# Server APIs #
3725###############
3726@server_router.get("", response_model=Union[List[ServerRead], CursorPaginatedServersResponse])
3727@server_router.get("/", response_model=Union[List[ServerRead], CursorPaginatedServersResponse])
3728@require_permission("servers.read")
3729async def list_servers(
3730 request: Request,
3731 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
3732 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
3733 limit: Optional[int] = Query(None, ge=0, description="Maximum number of servers to return"),
3734 include_inactive: bool = False,
3735 include_metrics: bool = False,
3736 tags: Optional[str] = None,
3737 team_id: Optional[str] = None,
3738 visibility: Optional[str] = None,
3739 db: Session = Depends(get_db),
3740 user=Depends(get_current_user_with_permissions),
3741) -> Union[List[ServerRead], Dict[str, Any]]:
3742 """
3743 Lists servers accessible to the user, with team filtering and cursor pagination support.
3745 Args:
3746 request (Request): The incoming request object for team_id retrieval.
3747 cursor (Optional[str]): Cursor for pagination.
3748 include_pagination (bool): Include cursor pagination metadata in response.
3749 limit (Optional[int]): Maximum number of servers to return.
3750 include_inactive (bool): Whether to include inactive servers in the response.
3751 include_metrics (bool): Whether to include aggregated metrics in the response.
3752 tags (Optional[str]): Comma-separated list of tags to filter by.
3753 team_id (Optional[str]): Filter by specific team ID.
3754 visibility (Optional[str]): Filter by visibility (private, team, public).
3755 db (Session): The database session used to interact with the data store.
3756 user (str): The authenticated user making the request.
3758 Returns:
3759 Union[List[ServerRead], Dict[str, Any]]: A list of server objects or paginated response with nextCursor.
3760 """
3761 # Parse tags parameter if provided
3762 tags_list = None
3763 if tags:
3764 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
3765 # Get user email for team filtering
3766 user_email = get_user_email(user)
3768 # Check team ID from token
3769 token_team_id = getattr(request.state, "team_id", None)
3770 token_teams = getattr(request.state, "token_teams", None)
3772 # Check for team ID mismatch
3773 if team_id is not None and token_team_id is not None and team_id != token_team_id:
3774 return ORJSONResponse(
3775 content={"message": "Access issue: This API token does not have the required permissions for this team."},
3776 status_code=status.HTTP_403_FORBIDDEN,
3777 )
3779 # For listing, only narrow by team_id when explicitly requested via query param.
3780 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping
3781 # (public + team resources). Auto-narrowing would exclude public servers.
3783 # SECURITY: token_teams is normalized in auth.py:
3784 # - None: admin bypass (is_admin=true with explicit null teams) - sees ALL resources
3785 # - []: public-only (missing teams or explicit empty) - sees only public
3786 # - [...]: team-scoped - sees public + teams + user's private
3787 is_admin_bypass = token_teams is None
3788 is_public_only_token = token_teams is not None and len(token_teams) == 0
3790 # Use consolidated server listing with optional team filtering
3791 # For admin bypass: pass user_email=None and token_teams=None to skip all filtering
3792 logger.debug(
3793 f"User: {SecurityValidator.sanitize_log_message(user_email)} requested server list with include_inactive={include_inactive}, tags={tags_list}, team_id={team_id}, visibility={visibility}"
3794 )
3795 data, next_cursor = await server_service.list_servers(
3796 db=db,
3797 cursor=cursor,
3798 limit=limit,
3799 include_inactive=include_inactive,
3800 include_metrics=include_metrics,
3801 tags=tags_list,
3802 user_email=None if is_admin_bypass else user_email, # Admin bypass: no user filtering
3803 team_id=team_id,
3804 visibility="public" if is_public_only_token and not visibility else visibility,
3805 token_teams=token_teams, # None = admin bypass, [] = public-only, [...] = team-scoped
3806 )
3808 if include_pagination:
3809 return CursorPaginatedServersResponse.model_construct(servers=data, next_cursor=next_cursor)
3810 return data
3813@server_router.get("/{server_id}", response_model=ServerRead)
3814@require_permission("servers.read")
3815async def get_server(server_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> ServerRead:
3816 """
3817 Retrieves a server by its ID.
3819 Args:
3820 server_id (str): The ID of the server to retrieve.
3821 request (Request): The incoming request used for scoped access validation.
3822 db (Session): The database session used to interact with the data store.
3823 user (str): The authenticated user making the request.
3825 Returns:
3826 ServerRead: The server object with the specified ID.
3828 Raises:
3829 HTTPException: If the server is not found.
3830 """
3831 try:
3832 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested server with ID {server_id}")
3833 server = await server_service.get_server(db, server_id)
3834 _enforce_scoped_resource_access(request, db, user, f"/servers/{server_id}")
3835 return server
3836 except ServerNotFoundError as e:
3837 raise HTTPException(status_code=404, detail=str(e))
3840@server_router.post("", response_model=ServerRead, status_code=201)
3841@server_router.post("/", response_model=ServerRead, status_code=201)
3842@require_permission("servers.create")
3843async def create_server(
3844 server: ServerCreate,
3845 request: Request,
3846 team_id: Optional[str] = Body(None, description="Team ID to assign server to"),
3847 visibility: Optional[str] = Body(None, description="Server visibility: private, team, public"),
3848 db: Session = Depends(get_db),
3849 user=Depends(get_current_user_with_permissions),
3850) -> ServerRead:
3851 """
3852 Creates a new server.
3854 Args:
3855 server (ServerCreate): The data for the new server.
3856 request (Request): The incoming request object for extracting metadata.
3857 team_id (Optional[str]): Team ID to assign the server to.
3858 visibility (str): Server visibility level (private, team, public).
3859 db (Session): The database session used to interact with the data store.
3860 user (str): The authenticated user making the request.
3862 Returns:
3863 ServerRead: The created server object.
3865 Raises:
3866 HTTPException: If there is a conflict with the server name or other errors.
3867 """
3868 try:
3869 # Extract metadata from request
3870 metadata = MetadataCapture.extract_creation_metadata(request, user)
3872 # Get user email and handle team assignment
3873 user_email = get_user_email(user)
3875 token_team_id = getattr(request.state, "team_id", None)
3876 token_teams = getattr(request.state, "token_teams", None)
3878 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
3879 is_public_only_token = token_teams is not None and len(token_teams) == 0
3880 if is_public_only_token and visibility in ("team", "private"):
3881 return ORJSONResponse(
3882 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
3883 status_code=status.HTTP_403_FORBIDDEN,
3884 )
3886 # Check for team ID mismatch (only for non-public-only tokens)
3887 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
3888 return ORJSONResponse(
3889 content={"message": "Access issue: This API token does not have the required permissions for this team."},
3890 status_code=status.HTTP_403_FORBIDDEN,
3891 )
3893 # Determine final team ID (public-only tokens get no team)
3894 if is_public_only_token:
3895 team_id = None
3896 else:
3897 team_id = team_id or token_team_id
3899 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new server for team {team_id}")
3900 result = await server_service.register_server(
3901 db,
3902 server,
3903 created_by=metadata["created_by"],
3904 created_from_ip=metadata["created_from_ip"],
3905 created_via=metadata["created_via"],
3906 created_user_agent=metadata["created_user_agent"],
3907 team_id=team_id,
3908 owner_email=user_email,
3909 visibility=visibility,
3910 )
3911 db.commit()
3912 db.close()
3913 return result
3914 except ServerNameConflictError as e:
3915 raise HTTPException(status_code=409, detail=str(e))
3916 except ServerError as e:
3917 raise HTTPException(status_code=400, detail=str(e))
3918 except ValidationError as e:
3919 logger.error(f"Validation error while creating server: {e}")
3920 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
3921 except IntegrityError as e:
3922 logger.error(f"Integrity error while creating server: {e}")
3923 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
3926@server_router.put("/{server_id}", response_model=ServerRead)
3927@require_permission("servers.update")
3928async def update_server(
3929 server_id: str,
3930 server: ServerUpdate,
3931 request: Request,
3932 db: Session = Depends(get_db),
3933 user=Depends(get_current_user_with_permissions),
3934) -> ServerRead:
3935 """
3936 Updates the information of an existing server.
3938 Args:
3939 server_id (str): The ID of the server to update.
3940 server (ServerUpdate): The updated server data.
3941 request (Request): The incoming request object containing metadata.
3942 db (Session): The database session used to interact with the data store.
3943 user (str): The authenticated user making the request.
3945 Returns:
3946 ServerRead: The updated server object.
3948 Raises:
3949 HTTPException: If the server is not found, there is a name conflict, or other errors.
3950 """
3951 try:
3952 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating server with ID {server_id}")
3953 # Extract modification metadata
3954 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
3956 user_email: str = get_user_email(user)
3958 result = await server_service.update_server(
3959 db,
3960 server_id,
3961 server,
3962 user_email,
3963 modified_by=mod_metadata["modified_by"],
3964 modified_from_ip=mod_metadata["modified_from_ip"],
3965 modified_via=mod_metadata["modified_via"],
3966 modified_user_agent=mod_metadata["modified_user_agent"],
3967 )
3968 db.commit()
3969 db.close()
3970 return result
3971 except PermissionError as e:
3972 raise HTTPException(status_code=403, detail=str(e))
3973 except ServerNotFoundError as e:
3974 raise HTTPException(status_code=404, detail=str(e))
3975 except ServerNameConflictError as e:
3976 raise HTTPException(status_code=409, detail=str(e))
3977 except ServerError as e:
3978 raise HTTPException(status_code=400, detail=str(e))
3979 except ValidationError as e:
3980 logger.error(f"Validation error while updating server {server_id}: {e}")
3981 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
3982 except IntegrityError as e:
3983 logger.error(f"Integrity error while updating server {server_id}: {e}")
3984 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
3987@server_router.post("/{server_id}/state", response_model=ServerRead)
3988@require_permission("servers.update")
3989async def set_server_state(
3990 server_id: str,
3991 activate: bool = True,
3992 db: Session = Depends(get_db),
3993 user=Depends(get_current_user_with_permissions),
3994) -> ServerRead:
3995 """
3996 Sets the status of a server (activate or deactivate).
3998 Args:
3999 server_id (str): The ID of the server to set state for.
4000 activate (bool): Whether to activate or deactivate the server.
4001 db (Session): The database session used to interact with the data store.
4002 user (str): The authenticated user making the request.
4004 Returns:
4005 ServerRead: The server object after the status change.
4007 Raises:
4008 HTTPException: If the server is not found or there is an error.
4009 """
4010 try:
4011 user_email = user.get("email") if isinstance(user, dict) else str(user)
4012 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is setting server with ID {server_id} to {'active' if activate else 'inactive'}")
4013 return await server_service.set_server_state(db, server_id, activate, user_email=user_email)
4014 except PermissionError as e:
4015 raise HTTPException(status_code=403, detail=str(e))
4016 except ServerNotFoundError as e:
4017 raise HTTPException(status_code=404, detail=str(e))
4018 except ServerLockConflictError as e:
4019 raise HTTPException(status_code=409, detail=str(e))
4020 except ServerError as e:
4021 raise HTTPException(status_code=400, detail=str(e))
4024@server_router.post("/{server_id}/toggle", response_model=ServerRead, deprecated=True)
4025@require_permission("servers.update")
4026async def toggle_server_status(
4027 server_id: str,
4028 activate: bool = True,
4029 db: Session = Depends(get_db),
4030 user=Depends(get_current_user_with_permissions),
4031) -> ServerRead:
4032 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
4034 Sets the status of a server (activate or deactivate).
4036 Args:
4037 server_id: The server ID.
4038 activate: Whether to activate (True) or deactivate (False) the server.
4039 db: Database session.
4040 user: Authenticated user context.
4042 Returns:
4043 The updated server.
4044 """
4046 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
4047 return await set_server_state(server_id, activate, db, user)
4050@server_router.delete("/{server_id}", response_model=Dict[str, str])
4051@require_permission("servers.delete")
4052async def delete_server(
4053 server_id: str,
4054 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this server"),
4055 db: Session = Depends(get_db),
4056 user=Depends(get_current_user_with_permissions),
4057) -> Dict[str, str]:
4058 """
4059 Deletes a server by its ID.
4061 Args:
4062 server_id (str): The ID of the server to delete.
4063 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this server.
4064 db (Session): The database session used to interact with the data store.
4065 user (str): The authenticated user making the request.
4067 Returns:
4068 Dict[str, str]: A success message indicating the server was deleted.
4070 Raises:
4071 HTTPException: If the server is not found or there is an error.
4072 """
4073 try:
4074 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting server with ID {server_id}")
4075 user_email = user.get("email") if isinstance(user, dict) else str(user)
4076 await server_service.get_server(db, server_id)
4077 await server_service.delete_server(db, server_id, user_email=user_email, purge_metrics=purge_metrics)
4078 db.commit()
4079 db.close()
4080 return {
4081 "status": "success",
4082 "message": f"Server {server_id} deleted successfully",
4083 }
4084 except PermissionError as e:
4085 raise HTTPException(status_code=403, detail=str(e))
4086 except ServerNotFoundError as e:
4087 raise HTTPException(status_code=404, detail=str(e))
4088 except ServerError as e:
4089 raise HTTPException(status_code=400, detail=str(e))
4092@server_router.get("/{server_id}/sse")
4093@require_permission("servers.use")
4094async def sse_endpoint(request: Request, server_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
4095 """
4096 Establishes a Server-Sent Events (SSE) connection for real-time updates about a server.
4098 Args:
4099 request (Request): The incoming request.
4100 server_id (str): The ID of the server for which updates are received.
4101 db (Session): The database session used for server existence and scope checks.
4102 user (str): The authenticated user making the request.
4104 Returns:
4105 The SSE response object for the established connection.
4107 Raises:
4108 HTTPException: If there is an error in establishing the SSE connection.
4109 asyncio.CancelledError: If the request is cancelled during SSE setup.
4110 """
4111 try:
4112 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is establishing SSE connection for server {server_id}")
4113 await server_service.get_server(db, server_id)
4114 _enforce_scoped_resource_access(request, db, user, f"/servers/{server_id}/sse")
4116 base_url = update_url_protocol(request)
4117 server_sse_url = f"{base_url}/servers/{server_id}"
4119 # SSE transport generates its own session_id - server-initiated, not client-provided
4120 transport = SSETransport(base_url=server_sse_url)
4121 await transport.connect()
4122 await session_registry.add_session(transport.session_id, transport)
4123 await session_registry.set_session_owner(transport.session_id, get_user_email(user))
4125 # Extract auth token from request (header OR cookie, like get_current_user_with_permissions)
4126 # MUST be computed BEFORE create_sse_response to avoid race condition (Finding 1)
4127 auth_token = None
4128 auth_header = request.headers.get("authorization", "")
4129 if auth_header.lower().startswith("bearer "):
4130 auth_token = auth_header[7:]
4131 elif hasattr(request, "cookies") and request.cookies:
4132 # Cookie auth (admin UI sessions)
4133 auth_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
4135 # Extract and normalize token teams
4136 # Returns None if no JWT payload (non-JWT auth), or list if JWT exists
4137 # SECURITY: Preserve None vs [] distinction for admin bypass:
4138 # - None: unrestricted (admin keeps bypass, non-admin gets their accessible resources)
4139 # - []: public-only (admin bypass disabled)
4140 # - [...]: team-scoped access
4141 token_teams = _get_token_teams_from_request(request)
4143 # Preserve is_admin from user object (for cookie-authenticated admins)
4144 is_admin = False
4145 if hasattr(user, "is_admin"):
4146 is_admin = getattr(user, "is_admin", False)
4147 elif isinstance(user, dict):
4148 is_admin = user.get("is_admin", False) or user.get("user", {}).get("is_admin", False)
4150 # Create enriched user dict
4151 user_with_token = dict(user) if isinstance(user, dict) else {"email": getattr(user, "email", str(user))}
4152 user_with_token["auth_token"] = auth_token
4153 user_with_token["token_teams"] = token_teams # None for unrestricted, [] for public-only, [...] for team-scoped
4154 user_with_token["is_admin"] = is_admin # Preserve admin status for fallback token
4156 # Capture passthrough headers from the original SSE request for loopback /rpc calls.
4157 # Without this, headers like X-Upstream-Authorization are silently dropped. See #3640.
4158 # First-Party
4159 from mcpgateway.utils.passthrough_headers import safe_extract_headers_for_loopback # pylint: disable=import-outside-toplevel
4161 user_with_token["_passthrough_headers"] = safe_extract_headers_for_loopback(dict(request.headers), "SSE")
4163 # Defensive cleanup callback - runs immediately on client disconnect
4164 async def on_disconnect_cleanup() -> None:
4165 """Clean up session when SSE client disconnects."""
4166 try:
4167 await session_registry.remove_session(transport.session_id)
4168 logger.debug("Defensive session cleanup completed: %s", transport.session_id)
4169 except Exception as e:
4170 logger.warning("Defensive session cleanup failed for %s: %s", transport.session_id, e)
4172 # CRITICAL: Create and register respond task BEFORE create_sse_response (Finding 1 fix)
4173 # This ensures the task exists when disconnect callback runs, preventing orphaned tasks
4174 respond_task = asyncio.create_task(session_registry.respond(server_id, user_with_token, session_id=transport.session_id))
4175 session_registry.register_respond_task(transport.session_id, respond_task)
4177 try:
4178 response = await transport.create_sse_response(request, on_disconnect_callback=on_disconnect_cleanup)
4179 except asyncio.CancelledError:
4180 # Request cancelled - still need to clean up to prevent orphaned tasks
4181 logger.debug(f"SSE request cancelled for {transport.session_id}, cleaning up")
4182 try:
4183 await session_registry.remove_session(transport.session_id)
4184 except Exception as cleanup_error:
4185 logger.warning(f"Cleanup after SSE cancellation failed: {cleanup_error}")
4186 raise # Re-raise CancelledError
4187 except Exception as sse_error:
4188 # CRITICAL: Cleanup on failure - respond task and session would be orphaned otherwise
4189 logger.error(f"create_sse_response failed for {transport.session_id}: {sse_error}")
4190 try:
4191 await session_registry.remove_session(transport.session_id)
4192 except Exception as cleanup_error:
4193 logger.warning(f"Cleanup after SSE failure also failed: {cleanup_error}")
4194 raise
4196 tasks = BackgroundTasks()
4197 tasks.add_task(session_registry.remove_session, transport.session_id)
4198 response.background = tasks
4199 logger.info(f"SSE connection established: {transport.session_id}")
4200 return response
4201 except ServerNotFoundError as e:
4202 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
4203 except HTTPException:
4204 raise
4205 except Exception as e:
4206 logger.error(f"SSE connection error: {e}")
4207 raise HTTPException(status_code=500, detail="SSE connection failed")
4210@server_router.post("/{server_id}/message")
4211@require_permission("servers.use")
4212async def message_endpoint(request: Request, server_id: str = Depends(require_valid_server), user=Depends(get_current_user_with_permissions)):
4213 """
4214 Handles incoming messages for a specific server.
4216 Args:
4217 request (Request): The incoming message request.
4218 server_id (str): The ID of the server receiving the message.
4219 user (str): The authenticated user making the request.
4221 Returns:
4222 JSONResponse: A success status after processing the message.
4224 Raises:
4225 HTTPException: If there are errors processing the message.
4226 """
4227 try:
4228 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} sent a message to server {server_id}")
4229 session_id = request.query_params.get("session_id")
4230 if not session_id:
4231 logger.error("Missing session_id in message request")
4232 raise HTTPException(status_code=400, detail="Missing session_id")
4233 set_trace_session_id(session_id)
4235 await _assert_session_owner_or_admin(request, user, session_id)
4237 message = await _read_request_json(request)
4239 # Check if this is an elicitation response (JSON-RPC response with result containing action)
4240 is_elicitation_response = False
4241 if "result" in message and isinstance(message.get("result"), dict):
4242 result_data = message["result"]
4243 if "action" in result_data and result_data.get("action") in ["accept", "decline", "cancel"]:
4244 # This looks like an elicitation response
4245 request_id = message.get("id")
4246 if request_id:
4247 # Try to complete the elicitation
4248 # First-Party
4249 from mcpgateway.common.models import ElicitResult # pylint: disable=import-outside-toplevel
4250 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
4252 elicitation_service = get_elicitation_service()
4253 try:
4254 elicit_result = ElicitResult(**result_data)
4255 if elicitation_service.complete_elicitation(request_id, elicit_result):
4256 logger.info(f"Completed elicitation {request_id} from session {session_id}")
4257 is_elicitation_response = True
4258 except Exception as e:
4259 logger.warning(f"Failed to process elicitation response: {e}")
4261 # If not an elicitation response, broadcast normally
4262 if not is_elicitation_response:
4263 await session_registry.broadcast(
4264 session_id=session_id,
4265 message=message,
4266 )
4268 return ORJSONResponse(content={"status": "success"}, status_code=202)
4269 except ValueError as e:
4270 logger.error(f"Invalid message format: {e}")
4271 raise HTTPException(status_code=400, detail=str(e))
4272 except HTTPException:
4273 raise
4274 except Exception as e:
4275 logger.error(f"Message handling error: {e}")
4276 raise HTTPException(status_code=500, detail="Failed to process message")
4279@server_router.get("/{server_id}/tools", response_model=List[ToolRead])
4280@require_permission("servers.read")
4281async def server_get_tools(
4282 request: Request,
4283 server_id: str,
4284 include_inactive: bool = False,
4285 include_metrics: bool = False,
4286 db: Session = Depends(get_db),
4287 user=Depends(get_current_user_with_permissions),
4288) -> List[Dict[str, Any]]:
4289 """
4290 List tools for the server with an option to include inactive tools.
4292 This endpoint retrieves a list of tools from the database, optionally including
4293 those that are inactive. The inactive filter helps administrators manage tools
4294 that have been deactivated but not deleted from the system.
4296 Args:
4297 request (Request): FastAPI request object.
4298 server_id (str): ID of the server
4299 include_inactive (bool): Whether to include inactive tools in the results.
4300 include_metrics (bool): Whether to include metrics in the tools results.
4301 db (Session): Database session dependency.
4302 user (str): Authenticated user dependency.
4304 Returns:
4305 List[ToolRead]: A list of tool records formatted with by_alias=True.
4306 """
4307 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} has listed tools for the server_id: {server_id}")
4308 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4309 _req_email, _req_is_admin = user_email, is_admin
4310 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
4311 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4312 # If token has explicit team scope (even empty [] for public-only), respect it
4313 if is_admin and token_teams is None:
4314 user_email = None
4315 token_teams = None # Admin unrestricted
4316 elif token_teams is None:
4317 token_teams = [] # Non-admin without teams = public-only (secure default)
4318 tools = await tool_service.list_server_tools(
4319 db,
4320 server_id=server_id,
4321 include_inactive=include_inactive,
4322 include_metrics=include_metrics,
4323 user_email=user_email,
4324 token_teams=token_teams,
4325 requesting_user_email=_req_email,
4326 requesting_user_is_admin=_req_is_admin,
4327 requesting_user_team_roles=_req_team_roles,
4328 )
4329 return [tool.model_dump(by_alias=True) for tool in tools]
4332@server_router.get("/{server_id}/resources", response_model=List[ResourceRead])
4333@require_permission("servers.read")
4334async def server_get_resources(
4335 request: Request,
4336 server_id: str,
4337 include_inactive: bool = False,
4338 include_metrics: bool = False,
4339 db: Session = Depends(get_db),
4340 user=Depends(get_current_user_with_permissions),
4341) -> List[Dict[str, Any]]:
4342 """
4343 List resources for the server with an option to include inactive resources.
4345 This endpoint retrieves a list of resources from the database, optionally including
4346 those that are inactive. The inactive filter is useful for administrators who need
4347 to view or manage resources that have been deactivated but not deleted.
4349 Args:
4350 request (Request): FastAPI request object.
4351 server_id (str): ID of the server
4352 include_inactive (bool): Whether to include inactive resources in the results.
4353 include_metrics (bool): Whether to include aggregated metrics in the results.
4354 db (Session): Database session dependency.
4355 user (str): Authenticated user dependency.
4357 Returns:
4358 List[ResourceRead]: A list of resource records formatted with by_alias=True.
4359 """
4360 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} has listed resources for the server_id: {server_id}")
4361 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4362 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4363 # If token has explicit team scope (even empty [] for public-only), respect it
4364 if is_admin and token_teams is None:
4365 user_email = None
4366 token_teams = None # Admin unrestricted
4367 elif token_teams is None:
4368 token_teams = [] # Non-admin without teams = public-only (secure default)
4369 resources = await resource_service.list_server_resources(
4370 db, server_id=server_id, include_inactive=include_inactive, include_metrics=include_metrics, user_email=user_email, token_teams=token_teams
4371 )
4372 return [resource.model_dump(by_alias=True) for resource in resources]
4375@server_router.get("/{server_id}/prompts", response_model=List[PromptRead])
4376@require_permission("servers.read")
4377async def server_get_prompts(
4378 request: Request,
4379 server_id: str,
4380 include_inactive: bool = False,
4381 include_metrics: bool = False,
4382 db: Session = Depends(get_db),
4383 user=Depends(get_current_user_with_permissions),
4384) -> List[Dict[str, Any]]:
4385 """
4386 List prompts for the server with an option to include inactive prompts.
4388 This endpoint retrieves a list of prompts from the database, optionally including
4389 those that are inactive. The inactive filter helps administrators see and manage
4390 prompts that have been deactivated but not deleted from the system.
4392 Args:
4393 request (Request): FastAPI request object.
4394 server_id (str): ID of the server
4395 include_inactive (bool): Whether to include inactive prompts in the results.
4396 include_metrics (bool): Whether to include aggregated metrics in the results.
4397 db (Session): Database session dependency.
4398 user (str): Authenticated user dependency.
4400 Returns:
4401 List[PromptRead]: A list of prompt records formatted with by_alias=True.
4402 """
4403 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} has listed prompts for the server_id: {server_id}")
4404 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4405 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4406 # If token has explicit team scope (even empty [] for public-only), respect it
4407 if is_admin and token_teams is None:
4408 user_email = None
4409 token_teams = None # Admin unrestricted
4410 elif token_teams is None:
4411 token_teams = [] # Non-admin without teams = public-only (secure default)
4412 prompts = await prompt_service.list_server_prompts(db, server_id=server_id, include_inactive=include_inactive, include_metrics=include_metrics, user_email=user_email, token_teams=token_teams)
4413 return [prompt.model_dump(by_alias=True) for prompt in prompts]
4416##################
4417# A2A Agent APIs #
4418##################
4419@a2a_router.get("", response_model=Union[List[A2AAgentRead], CursorPaginatedA2AAgentsResponse])
4420@a2a_router.get("/", response_model=Union[List[A2AAgentRead], CursorPaginatedA2AAgentsResponse])
4421@require_permission("a2a.read")
4422async def list_a2a_agents(
4423 request: Request,
4424 include_inactive: bool = False,
4425 tags: Optional[str] = None,
4426 team_id: Optional[str] = Query(None, description="Filter by team ID"),
4427 visibility: Optional[str] = Query(None, description="Filter by visibility (private, team, public)"),
4428 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
4429 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
4430 limit: Optional[int] = Query(None, description="Maximum number of agents to return"),
4431 db: Session = Depends(get_db),
4432 user=Depends(get_current_user_with_permissions),
4433) -> Union[List[A2AAgentRead], Dict[str, Any]]:
4434 """
4435 Lists A2A agents user has access to with cursor pagination and team filtering.
4437 Args:
4438 request (Request): The FastAPI request object for team_id retrieval.
4439 include_inactive (bool): Whether to include inactive agents in the response.
4440 tags (Optional[str]): Comma-separated list of tags to filter by.
4441 team_id (Optional[str]): Team ID to filter by.
4442 visibility (Optional[str]): Visibility level to filter by.
4443 cursor (Optional[str]): Cursor for pagination.
4444 include_pagination (bool): Include cursor pagination metadata in response.
4445 limit (Optional[int]): Maximum number of agents to return.
4446 db (Session): The database session used to interact with the data store.
4447 user (str): The authenticated user making the request.
4449 Returns:
4450 Union[List[A2AAgentRead], Dict[str, Any]]: A list of A2A agent objects or paginated response with nextCursor.
4452 Raises:
4453 HTTPException: If A2A service is not available.
4454 """
4455 # Parse tags parameter if provided
4456 tags_list = None
4457 if tags:
4458 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
4460 if a2a_service is None:
4461 raise HTTPException(status_code=503, detail="A2A service not available")
4463 # Get filtering context from token (respects token scope)
4464 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4466 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4467 # If token has explicit team scope (even for admins), respect it for least-privilege
4468 if is_admin and token_teams is None:
4469 user_email = None
4470 token_teams = None # Admin unrestricted
4471 elif token_teams is None:
4472 token_teams = [] # Non-admin without teams = public-only (secure default)
4474 # Check team_id from request.state (set during auth)
4475 token_team_id = getattr(request.state, "team_id", None)
4477 # Check for team ID mismatch (only applies when both are specified and token has teams)
4478 if team_id is not None and token_team_id is not None and team_id != token_team_id:
4479 return ORJSONResponse(
4480 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4481 status_code=status.HTTP_403_FORBIDDEN,
4482 )
4484 # For listing, only narrow by team_id when explicitly requested via query param.
4485 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
4487 logger.debug(f"User: {SecurityValidator.sanitize_log_message(user_email)} requested A2A agent list with team_id={team_id}, visibility={visibility}, tags={tags_list}, cursor={cursor}")
4489 # Use consolidated agent listing with token-based team filtering
4490 data, next_cursor = await a2a_service.list_agents(
4491 db=db,
4492 cursor=cursor,
4493 include_inactive=include_inactive,
4494 tags=tags_list,
4495 limit=limit,
4496 user_email=user_email,
4497 token_teams=token_teams,
4498 team_id=team_id,
4499 visibility=visibility,
4500 )
4502 if include_pagination:
4503 return CursorPaginatedA2AAgentsResponse.model_construct(agents=data, next_cursor=next_cursor)
4504 return data
4507@a2a_router.get("/{agent_id}", response_model=A2AAgentRead)
4508@require_permission("a2a.read")
4509async def get_a2a_agent(
4510 agent_id: str,
4511 request: Request,
4512 db: Session = Depends(get_db),
4513 user=Depends(get_current_user_with_permissions),
4514) -> A2AAgentRead:
4515 """
4516 Retrieves an A2A agent by its ID.
4518 Args:
4519 agent_id (str): The ID of the agent to retrieve.
4520 request (Request): The FastAPI request object for team_id retrieval.
4521 db (Session): The database session used to interact with the data store.
4522 user (str): The authenticated user making the request.
4524 Returns:
4525 A2AAgentRead: The agent object with the specified ID.
4527 Raises:
4528 HTTPException: If the agent is not found or user lacks access.
4529 """
4530 try:
4531 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested A2A agent with ID {agent_id}")
4532 if a2a_service is None:
4533 raise HTTPException(status_code=503, detail="A2A service not available")
4535 # Get filtering context from token (respects token scope)
4536 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4538 # Admin bypass - only when token has NO team restrictions
4539 if is_admin and token_teams is None:
4540 token_teams = None # Admin unrestricted
4541 elif token_teams is None:
4542 token_teams = [] # Non-admin without teams = public-only
4544 return await a2a_service.get_agent(
4545 db,
4546 agent_id,
4547 user_email=user_email,
4548 token_teams=token_teams,
4549 )
4550 except A2AAgentNotFoundError as e:
4551 raise HTTPException(status_code=404, detail=str(e))
4554@a2a_router.post("", response_model=A2AAgentRead, status_code=201)
4555@a2a_router.post("/", response_model=A2AAgentRead, status_code=201)
4556@require_permission("a2a.create")
4557async def create_a2a_agent(
4558 agent: A2AAgentCreate,
4559 request: Request,
4560 team_id: Optional[str] = Body(None, description="Team ID to assign agent to"),
4561 visibility: Optional[str] = Body("public", description="Agent visibility: private, team, public"),
4562 db: Session = Depends(get_db),
4563 user=Depends(get_current_user_with_permissions),
4564) -> A2AAgentRead:
4565 """
4566 Creates a new A2A agent.
4568 Args:
4569 agent (A2AAgentCreate): The data for the new agent.
4570 request (Request): The FastAPI request object for metadata extraction.
4571 team_id (Optional[str]): Team ID to assign the agent to.
4572 visibility (str): Agent visibility level (private, team, public).
4573 db (Session): The database session used to interact with the data store.
4574 user (str): The authenticated user making the request.
4576 Returns:
4577 A2AAgentRead: The created agent object.
4579 Raises:
4580 HTTPException: If there is a conflict with the agent name or other errors.
4581 """
4582 try:
4583 # Extract metadata from request
4584 metadata = MetadataCapture.extract_creation_metadata(request, user)
4586 # Get user email and handle team assignment
4587 user_email = get_user_email(user)
4589 token_team_id = getattr(request.state, "team_id", None)
4590 token_teams = getattr(request.state, "token_teams", None)
4592 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
4593 is_public_only_token = token_teams is not None and len(token_teams) == 0
4594 if is_public_only_token and visibility in ("team", "private"):
4595 return ORJSONResponse(
4596 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
4597 status_code=status.HTTP_403_FORBIDDEN,
4598 )
4600 # Check for team ID mismatch (only for non-public-only tokens)
4601 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
4602 return ORJSONResponse(
4603 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4604 status_code=status.HTTP_403_FORBIDDEN,
4605 )
4607 # Determine final team ID (public-only tokens get no team)
4608 if is_public_only_token:
4609 team_id = None
4610 else:
4611 team_id = team_id or token_team_id
4613 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new A2A agent for team {team_id}")
4614 if a2a_service is None:
4615 raise HTTPException(status_code=503, detail="A2A service not available")
4616 return await a2a_service.register_agent(
4617 db,
4618 agent,
4619 created_by=metadata["created_by"],
4620 created_from_ip=metadata["created_from_ip"],
4621 created_via=metadata["created_via"],
4622 created_user_agent=metadata["created_user_agent"],
4623 import_batch_id=metadata["import_batch_id"],
4624 federation_source=metadata["federation_source"],
4625 team_id=team_id,
4626 owner_email=user_email,
4627 visibility=visibility,
4628 )
4629 except A2AAgentNameConflictError as e:
4630 raise HTTPException(status_code=409, detail=str(e))
4631 except A2AAgentError as e:
4632 raise HTTPException(status_code=400, detail=str(e))
4633 except ValidationError as e:
4634 logger.error(f"Validation error while creating A2A agent: {e}")
4635 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
4636 except IntegrityError as e:
4637 logger.error(f"Integrity error while creating A2A agent: {e}")
4638 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
4641@a2a_router.put("/{agent_id}", response_model=A2AAgentRead)
4642@require_permission("a2a.update")
4643async def update_a2a_agent(
4644 agent_id: str,
4645 agent: A2AAgentUpdate,
4646 request: Request,
4647 db: Session = Depends(get_db),
4648 user=Depends(get_current_user_with_permissions),
4649) -> A2AAgentRead:
4650 """
4651 Updates the information of an existing A2A agent.
4653 Args:
4654 agent_id (str): The ID of the agent to update.
4655 agent (A2AAgentUpdate): The updated agent data.
4656 request (Request): The FastAPI request object for metadata extraction.
4657 db (Session): The database session used to interact with the data store.
4658 user (str): The authenticated user making the request.
4660 Returns:
4661 A2AAgentRead: The updated agent object.
4663 Raises:
4664 HTTPException: If the agent is not found, there is a name conflict, or other errors.
4665 """
4666 try:
4667 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating A2A agent with ID {agent_id}")
4668 # Extract modification metadata
4669 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
4671 if a2a_service is None:
4672 raise HTTPException(status_code=503, detail="A2A service not available")
4673 user_email = user.get("email") if isinstance(user, dict) else str(user)
4674 return await a2a_service.update_agent(
4675 db,
4676 agent_id,
4677 agent,
4678 modified_by=mod_metadata["modified_by"],
4679 modified_from_ip=mod_metadata["modified_from_ip"],
4680 modified_via=mod_metadata["modified_via"],
4681 modified_user_agent=mod_metadata["modified_user_agent"],
4682 user_email=user_email,
4683 )
4684 except PermissionError as e:
4685 raise HTTPException(status_code=403, detail=str(e))
4686 except A2AAgentNotFoundError as e:
4687 raise HTTPException(status_code=404, detail=str(e))
4688 except A2AAgentNameConflictError as e:
4689 raise HTTPException(status_code=409, detail=str(e))
4690 except A2AAgentError as e:
4691 raise HTTPException(status_code=400, detail=str(e))
4692 except ValidationError as e:
4693 logger.error(f"Validation error while updating A2A agent {agent_id}: {e}")
4694 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
4695 except IntegrityError as e:
4696 logger.error(f"Integrity error while updating A2A agent {agent_id}: {e}")
4697 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
4700@a2a_router.post("/{agent_id}/state", response_model=A2AAgentRead)
4701@require_permission("a2a.update")
4702async def set_a2a_agent_state(
4703 agent_id: str,
4704 activate: bool = True,
4705 db: Session = Depends(get_db),
4706 user=Depends(get_current_user_with_permissions),
4707) -> A2AAgentRead:
4708 """
4709 Sets the status of an A2A agent (activate or deactivate).
4711 Args:
4712 agent_id (str): The ID of the agent to update.
4713 activate (bool): Whether to activate or deactivate the agent.
4714 db (Session): The database session used to interact with the data store.
4715 user (str): The authenticated user making the request.
4717 Returns:
4718 A2AAgentRead: The agent object after the status change.
4720 Raises:
4721 HTTPException: If the agent is not found or there is an error.
4722 """
4723 try:
4724 user_email = user.get("email") if isinstance(user, dict) else str(user)
4725 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is toggling A2A agent with ID {agent_id} to {'active' if activate else 'inactive'}")
4726 if a2a_service is None:
4727 raise HTTPException(status_code=503, detail="A2A service not available")
4728 return await a2a_service.set_agent_state(db, agent_id, activate, user_email=user_email)
4729 except PermissionError as e:
4730 raise HTTPException(status_code=403, detail=str(e))
4731 except A2AAgentNotFoundError as e:
4732 raise HTTPException(status_code=404, detail=str(e))
4733 except A2AAgentError as e:
4734 raise HTTPException(status_code=400, detail=str(e))
4737@a2a_router.post("/{agent_id}/toggle", response_model=A2AAgentRead, deprecated=True)
4738@require_permission("a2a.update")
4739async def toggle_a2a_agent_status(
4740 agent_id: str,
4741 activate: bool = True,
4742 db: Session = Depends(get_db),
4743 user=Depends(get_current_user_with_permissions),
4744) -> A2AAgentRead:
4745 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
4747 Sets the status of an A2A agent (activate or deactivate).
4749 Args:
4750 agent_id: The A2A agent ID.
4751 activate: Whether to activate (True) or deactivate (False) the agent.
4752 db: Database session.
4753 user: Authenticated user context.
4755 Returns:
4756 The updated A2A agent.
4757 """
4759 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
4760 return await set_a2a_agent_state(agent_id, activate, db, user)
4763@a2a_router.delete("/{agent_id}", response_model=Dict[str, str])
4764@require_permission("a2a.delete")
4765async def delete_a2a_agent(
4766 agent_id: str,
4767 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this agent"),
4768 db: Session = Depends(get_db),
4769 user=Depends(get_current_user_with_permissions),
4770) -> Dict[str, str]:
4771 """
4772 Deletes an A2A agent by its ID.
4774 Args:
4775 agent_id (str): The ID of the agent to delete.
4776 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this agent.
4777 db (Session): The database session used to interact with the data store.
4778 user (str): The authenticated user making the request.
4780 Returns:
4781 Dict[str, str]: A success message indicating the agent was deleted.
4783 Raises:
4784 HTTPException: If the agent is not found or there is an error.
4785 """
4786 try:
4787 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting A2A agent with ID {agent_id}")
4788 if a2a_service is None:
4789 raise HTTPException(status_code=503, detail="A2A service not available")
4790 user_email = user.get("email") if isinstance(user, dict) else str(user)
4791 await a2a_service.delete_agent(db, agent_id, user_email=user_email, purge_metrics=purge_metrics)
4792 return {
4793 "status": "success",
4794 "message": f"A2A Agent {agent_id} deleted successfully",
4795 }
4796 except PermissionError as e:
4797 raise HTTPException(status_code=403, detail=str(e))
4798 except A2AAgentNotFoundError as e:
4799 raise HTTPException(status_code=404, detail=str(e))
4800 except A2AAgentError as e:
4801 raise HTTPException(status_code=400, detail=str(e))
4804@a2a_router.post("/{agent_name}/invoke", response_model=Dict[str, Any])
4805@require_permission("a2a.invoke")
4806async def invoke_a2a_agent(
4807 agent_name: str,
4808 request: Request,
4809 parameters: Dict[str, Any] = Body(default_factory=dict),
4810 interaction_type: str = Body(default="query"),
4811 db: Session = Depends(get_db),
4812 user=Depends(get_current_user_with_permissions),
4813) -> Dict[str, Any]:
4814 """
4815 Invokes an A2A agent with the specified parameters.
4817 Args:
4818 agent_name (str): The name of the agent to invoke.
4819 request (Request): The FastAPI request object for team_id retrieval.
4820 parameters (Dict[str, Any]): Parameters for the agent interaction.
4821 interaction_type (str): Type of interaction (query, execute, etc.).
4822 db (Session): The database session used to interact with the data store.
4823 user (str): The authenticated user making the request.
4825 Returns:
4826 Dict[str, Any]: The response from the A2A agent.
4828 Raises:
4829 HTTPException: If the agent is not found, user lacks access, or there is an error during invocation.
4830 """
4831 try:
4832 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is invoking A2A agent '{agent_name}' with type '{interaction_type}'")
4833 if a2a_service is None:
4834 raise HTTPException(status_code=503, detail="A2A service not available")
4836 # Get filtering context from token (respects token scope)
4837 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4839 # Admin bypass - only when token has NO team restrictions
4840 if is_admin and token_teams is None:
4841 token_teams = None # Admin unrestricted
4842 elif token_teams is None:
4843 token_teams = [] # Non-admin without teams = public-only
4845 user_id = None
4846 if isinstance(user, dict):
4847 user_id = str(user.get("id") or user.get("sub") or user_email)
4848 else:
4849 user_id = str(user)
4851 return await a2a_service.invoke_agent(
4852 db,
4853 agent_name,
4854 parameters,
4855 interaction_type,
4856 user_id=user_id,
4857 user_email=user_email,
4858 token_teams=token_teams,
4859 )
4860 except A2AAgentNotFoundError as e:
4861 raise HTTPException(status_code=404, detail=str(e))
4862 except A2AAgentError as e:
4863 raise HTTPException(status_code=400, detail=str(e))
4866#############
4867# Tool APIs #
4868#############
4869@tool_router.get("", response_model=Union[List[ToolRead], CursorPaginatedToolsResponse])
4870@tool_router.get("/", response_model=Union[List[ToolRead], CursorPaginatedToolsResponse])
4871@require_permission("tools.read")
4872async def list_tools(
4873 request: Request,
4874 cursor: Optional[str] = None,
4875 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
4876 limit: Optional[int] = Query(None, ge=0, description="Maximum number of tools to return. 0 means all (no limit). Default uses pagination_default_page_size."),
4877 include_inactive: bool = False,
4878 tags: Optional[str] = None,
4879 team_id: Optional[str] = Query(None, description="Filter by team ID"),
4880 visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, public"),
4881 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"),
4882 db: Session = Depends(get_db),
4883 apijsonpath: Optional[str] = Query(None, description="Optional JSONPath modifier as JSON string"),
4884 user=Depends(get_current_user_with_permissions),
4885) -> ToolsResponse:
4886 """List all registered tools with team-based filtering and pagination support.
4888 Args:
4889 request (Request): The FastAPI request object for team_id retrieval
4890 cursor: Pagination cursor for fetching the next set of results
4891 include_pagination: Whether to include cursor pagination metadata in the response
4892 limit: Maximum number of tools to return. Use 0 for all tools (no limit).
4893 If not specified, uses pagination_default_page_size (default: 50).
4894 include_inactive: Whether to include inactive tools in the results
4895 tags: Comma-separated list of tags to filter by (e.g., "api,data")
4896 team_id: Optional team ID to filter tools by specific team
4897 visibility: Optional visibility filter (private, team, public)
4898 gateway_id: Optional gateway ID to filter tools by specific gateway
4899 db: Database session
4900 apijsonpath: Optional JSON-Path modifier supplied as URL-encoded query parameter.
4901 Example: ?apijsonpath=%7B%22jsonpath%22%3A%22%24.name%22%7D
4902 (decoded: {"jsonpath":"$.name"})
4903 Use to filter or transform the response via JSONPath expressions.
4904 user: Authenticated user with permissions
4906 Returns:
4907 List of tools or modified result based on jsonpath
4909 Raises:
4910 HTTPException: If JSONPath modifier fails to process the tools list
4911 """
4913 # Validate apijsonpath early — fail fast before the database query
4914 parsed_apijsonpath = _parse_apijsonpath(apijsonpath)
4916 # Parse tags parameter if provided
4917 tags_list = None
4918 if tags:
4919 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
4921 # Get filtering context from token (respects token scope)
4922 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
4923 # Capture original identity for header masking (before admin bypass modifies user_email)
4924 _req_email, _req_is_admin = user_email, is_admin
4926 # Admin bypass - only when token has NO team restrictions (token_teams is None)
4927 # If token has explicit team scope (even for admins), respect it for least-privilege
4928 if is_admin and token_teams is None:
4929 user_email = None
4930 token_teams = None # Admin unrestricted
4931 elif token_teams is None:
4932 token_teams = [] # Non-admin without teams = public-only (secure default)
4934 # Check team_id from request.state (set during auth)
4935 token_team_id = getattr(request.state, "team_id", None)
4937 # Check for team ID mismatch (only applies when both are specified and token has teams)
4938 if team_id is not None and token_team_id is not None and team_id != token_team_id:
4939 return ORJSONResponse(
4940 content={"message": "Access issue: This API token does not have the required permissions for this team."},
4941 status_code=status.HTTP_403_FORBIDDEN,
4942 )
4944 # For listing, only narrow by team_id when explicitly requested via query param.
4945 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
4947 # Use unified list_tools() with token-based team filtering
4948 # Always apply visibility filtering based on token scope
4949 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
4950 data, next_cursor = await tool_service.list_tools(
4951 db=db,
4952 cursor=cursor,
4953 include_inactive=include_inactive,
4954 tags=tags_list,
4955 gateway_id=gateway_id,
4956 limit=limit,
4957 user_email=user_email,
4958 team_id=team_id,
4959 visibility=visibility,
4960 token_teams=token_teams,
4961 requesting_user_email=_req_email,
4962 requesting_user_is_admin=_req_is_admin,
4963 requesting_user_team_roles=_req_team_roles,
4964 )
4965 # Release transaction before response serialization
4966 db.commit()
4967 db.close()
4969 if parsed_apijsonpath is None:
4970 if include_pagination:
4971 return CursorPaginatedToolsResponse.model_construct(tools=data, next_cursor=next_cursor)
4972 return data
4974 tools_dict_list = [tool.to_dict(use_alias=True) for tool in data]
4975 try:
4976 result = jsonpath_modifier(tools_dict_list, parsed_apijsonpath.jsonpath, parsed_apijsonpath.mapping)
4978 # If pagination is requested, wrap the result with cursor metadata.
4979 # Use "nextCursor" to match the CursorPaginatedToolsResponse alias contract.
4980 if include_pagination:
4981 paginated_result = {"tools": result, "nextCursor": next_cursor}
4982 return ORJSONResponse(content=paginated_result)
4984 # Return ORJSONResponse to bypass FastAPI's response_model validation
4985 return ORJSONResponse(content=result)
4986 except HTTPException:
4987 # Re-raise HTTPException as-is (preserves 400 from apijsonpath parsing)
4988 raise
4989 except Exception:
4990 logger.exception("JSONPath modifier failed while processing tools list")
4991 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="JSONPath modifier error")
4994@tool_router.post("", response_model=ToolRead)
4995@tool_router.post("/", response_model=ToolRead)
4996@require_permission("tools.create")
4997async def create_tool(
4998 tool: ToolCreate,
4999 request: Request,
5000 team_id: Optional[str] = Body(None, description="Team ID to assign tool to"),
5001 db: Session = Depends(get_db),
5002 user=Depends(get_current_user_with_permissions),
5003) -> ToolRead:
5004 """
5005 Creates a new tool in the system with team assignment support.
5007 Args:
5008 tool (ToolCreate): The data needed to create the tool.
5009 request (Request): The FastAPI request object for metadata extraction.
5010 team_id (Optional[str]): Team ID to assign the tool to.
5011 db (Session): The database session dependency.
5012 user: The authenticated user making the request.
5014 Returns:
5015 ToolRead: The created tool data.
5017 Raises:
5018 HTTPException: If the tool name already exists or other validation errors occur.
5019 """
5020 try:
5021 # Extract metadata from request
5022 metadata = MetadataCapture.extract_creation_metadata(request, user)
5024 # Get user email and handle team assignment
5025 user_email = get_user_email(user)
5027 token_team_id = getattr(request.state, "team_id", None)
5028 token_teams = getattr(request.state, "token_teams", None)
5030 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
5031 is_public_only_token = token_teams is not None and len(token_teams) == 0
5032 if is_public_only_token and tool.visibility in ("team", "private"):
5033 return ORJSONResponse(
5034 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
5035 status_code=status.HTTP_403_FORBIDDEN,
5036 )
5038 # Check for team ID mismatch (only for non-public-only tokens)
5039 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
5040 return ORJSONResponse(
5041 content={"message": "Access issue: This API token does not have the required permissions for this team."},
5042 status_code=status.HTTP_403_FORBIDDEN,
5043 )
5045 # Determine final team ID (public-only tokens get no team)
5046 if is_public_only_token:
5047 team_id = None
5048 else:
5049 team_id = team_id or token_team_id
5051 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new tool for team {team_id}")
5052 result = await tool_service.register_tool(
5053 db,
5054 tool,
5055 created_by=metadata["created_by"],
5056 created_from_ip=metadata["created_from_ip"],
5057 created_via=metadata["created_via"],
5058 created_user_agent=metadata["created_user_agent"],
5059 import_batch_id=metadata["import_batch_id"],
5060 federation_source=metadata["federation_source"],
5061 team_id=team_id,
5062 owner_email=user_email,
5063 visibility=tool.visibility,
5064 )
5065 db.commit()
5066 db.close()
5067 return result
5068 except Exception as ex:
5069 logger.error(f"Error while creating tool: {ex}")
5070 if isinstance(ex, ToolNameConflictError):
5071 if not ex.enabled and ex.tool_id:
5072 raise HTTPException(
5073 status_code=status.HTTP_409_CONFLICT,
5074 detail=f"Tool name already exists but is inactive. Consider activating it with ID: {ex.tool_id}",
5075 )
5076 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(ex))
5077 if isinstance(ex, (ValidationError, ValueError)):
5078 logger.error(f"Validation error while creating tool: {ex}")
5079 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(ex))
5080 if isinstance(ex, IntegrityError):
5081 logger.error(f"Integrity error while creating tool: {ex}")
5082 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(ex))
5083 if isinstance(ex, ToolError):
5084 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex))
5085 logger.error(f"Unexpected error while creating tool: {ex}")
5086 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while creating the tool")
5089@tool_router.get("/{tool_id}", response_model=Union[ToolRead, Dict])
5090@require_permission("tools.read")
5091async def get_tool(
5092 tool_id: str,
5093 request: Request,
5094 db: Session = Depends(get_db),
5095 user=Depends(get_current_user_with_permissions),
5096 apijsonpath: Optional[str] = Query(None, description="Optional JSONPath modifier as JSON string"),
5097) -> ToolResponse:
5098 """
5099 Retrieve a tool by ID, optionally applying a JSONPath post-filter.
5101 Args:
5102 tool_id: The numeric ID of the tool.
5103 request: The incoming HTTP request.
5104 db: Active SQLAlchemy session (dependency).
5105 user: Authenticated username (dependency).
5106 apijsonpath: Optional JSON-Path modifier supplied as URL-encoded query parameter.
5107 Example: ?apijsonpath=%7B%22jsonpath%22%3A%22%24.name%22%7D
5108 (decoded: {"jsonpath":"$.name","mapping":null})
5109 Use to filter or transform the response via JSONPath expressions.
5111 Returns:
5112 The raw ``ToolRead`` model **or** a JSON-transformed ``dict`` if
5113 a JSONPath filter/mapping was supplied, **or** an ``ORJSONResponse``
5114 when JSONPath modifiers are applied.
5116 Raises:
5117 HTTPException: If the tool does not exist or the transformation fails.
5118 """
5119 try:
5120 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is retrieving tool with ID {tool_id}")
5121 _req_email, _, _req_is_admin = _get_rpc_filter_context(request, user)
5122 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
5123 data = await tool_service.get_tool(db, tool_id, requesting_user_email=_req_email, requesting_user_is_admin=_req_is_admin, requesting_user_team_roles=_req_team_roles)
5124 _enforce_scoped_resource_access(request, db, user, f"/tools/{tool_id}")
5126 # Parse apijsonpath parameter (handles both string and JsonPathModifier inputs)
5127 parsed_apijsonpath = _parse_apijsonpath(apijsonpath)
5128 if parsed_apijsonpath is None:
5129 return data
5131 data_dict = data.to_dict(use_alias=True)
5132 try:
5133 result = jsonpath_modifier(data_dict, parsed_apijsonpath.jsonpath, parsed_apijsonpath.mapping)
5134 # Return ORJSONResponse to bypass FastAPI's response_model validation
5135 return ORJSONResponse(content=result)
5136 except HTTPException:
5137 # Re-raise HTTPException as-is (preserves 400 from apijsonpath parsing)
5138 raise
5139 except Exception:
5140 logger.exception("JSONPath modifier failed while processing single tool")
5141 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="JSONPath modifier error")
5142 except HTTPException:
5143 raise
5144 except Exception as e:
5145 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5148@tool_router.put("/{tool_id}", response_model=ToolRead)
5149@require_permission("tools.update")
5150async def update_tool(
5151 tool_id: str,
5152 tool: ToolUpdate,
5153 request: Request,
5154 db: Session = Depends(get_db),
5155 user=Depends(get_current_user_with_permissions),
5156) -> ToolRead:
5157 """
5158 Updates an existing tool with new data.
5160 Args:
5161 tool_id (str): The ID of the tool to update.
5162 tool (ToolUpdate): The updated tool information.
5163 request (Request): The FastAPI request object for metadata extraction.
5164 db (Session): The database session dependency.
5165 user (str): The authenticated user making the request.
5167 Returns:
5168 ToolRead: The updated tool data.
5170 Raises:
5171 HTTPException: If an error occurs during the update.
5172 """
5173 try:
5174 # Get current tool to extract current version
5175 current_tool = db.get(DbTool, tool_id)
5176 current_version = getattr(current_tool, "version", 0) if current_tool else 0
5178 # Extract modification metadata
5179 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, current_version)
5181 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating tool with ID {tool_id}")
5182 user_email = user.get("email") if isinstance(user, dict) else str(user)
5183 result = await tool_service.update_tool(
5184 db,
5185 tool_id,
5186 tool,
5187 modified_by=mod_metadata["modified_by"],
5188 modified_from_ip=mod_metadata["modified_from_ip"],
5189 modified_via=mod_metadata["modified_via"],
5190 modified_user_agent=mod_metadata["modified_user_agent"],
5191 user_email=user_email,
5192 )
5193 db.commit()
5194 db.close()
5195 return result
5196 except Exception as ex:
5197 if isinstance(ex, PermissionError):
5198 raise HTTPException(status_code=403, detail=str(ex))
5199 if isinstance(ex, ToolNotFoundError):
5200 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(ex))
5201 if isinstance(ex, ValidationError):
5202 logger.error(f"Validation error while updating tool: {ex}")
5203 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(ex))
5204 if isinstance(ex, IntegrityError):
5205 logger.error(f"Integrity error while updating tool: {ex}")
5206 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(ex))
5207 if isinstance(ex, ToolError):
5208 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(ex))
5209 logger.error(f"Unexpected error while updating tool: {ex}")
5210 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the tool")
5213@tool_router.delete("/{tool_id}")
5214@require_permission("tools.delete")
5215async def delete_tool(
5216 tool_id: str,
5217 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this tool"),
5218 db: Session = Depends(get_db),
5219 user=Depends(get_current_user_with_permissions),
5220) -> Dict[str, str]:
5221 """
5222 Permanently deletes a tool by ID.
5224 Args:
5225 tool_id (str): The ID of the tool to delete.
5226 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this tool.
5227 db (Session): The database session dependency.
5228 user (str): The authenticated user making the request.
5230 Returns:
5231 Dict[str, str]: A confirmation message upon successful deletion.
5233 Raises:
5234 HTTPException: If an error occurs during deletion.
5235 """
5236 try:
5237 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting tool with ID {tool_id}")
5238 user_email = user.get("email") if isinstance(user, dict) else str(user)
5239 await tool_service.delete_tool(db, tool_id, user_email=user_email, purge_metrics=purge_metrics)
5240 db.commit()
5241 db.close()
5242 return {"status": "success", "message": f"Tool {tool_id} permanently deleted"}
5243 except PermissionError as e:
5244 raise HTTPException(status_code=403, detail=str(e))
5245 except ToolNotFoundError as e:
5246 raise HTTPException(status_code=404, detail=str(e))
5247 except Exception as e:
5248 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5251@tool_router.post("/{tool_id}/state")
5252@require_permission("tools.update")
5253async def set_tool_state(
5254 tool_id: str,
5255 activate: bool = True,
5256 db: Session = Depends(get_db),
5257 user=Depends(get_current_user_with_permissions),
5258) -> Dict[str, Any]:
5259 """
5260 Activates or deactivates a tool.
5262 Args:
5263 tool_id (str): The ID of the tool to update.
5264 activate (bool): Whether to activate (`True`) or deactivate (`False`) the tool.
5265 db (Session): The database session dependency.
5266 user (str): The authenticated user making the request.
5268 Returns:
5269 Dict[str, Any]: The status, message, and updated tool data.
5271 Raises:
5272 HTTPException: If an error occurs during state change.
5273 """
5274 try:
5275 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is setting tool state for ID {tool_id} to {'active' if activate else 'inactive'}")
5276 user_email = user.get("email") if isinstance(user, dict) else str(user)
5277 tool = await tool_service.set_tool_state(db, tool_id, activate, reachable=activate, user_email=user_email)
5278 return {
5279 "status": "success",
5280 "message": f"Tool {tool_id} {'activated' if activate else 'deactivated'}",
5281 "tool": tool.model_dump(),
5282 }
5283 except PermissionError as e:
5284 raise HTTPException(status_code=403, detail=str(e))
5285 except ToolNotFoundError as e:
5286 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5287 except ToolLockConflictError as e:
5288 raise HTTPException(status_code=409, detail=str(e))
5289 except Exception as e:
5290 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5293@tool_router.post("/{tool_id}/toggle", deprecated=True)
5294@require_permission("tools.update")
5295async def toggle_tool_status(
5296 tool_id: str,
5297 activate: bool = True,
5298 db: Session = Depends(get_db),
5299 user=Depends(get_current_user_with_permissions),
5300) -> Dict[str, Any]:
5301 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
5303 Activates or deactivates a tool.
5305 Args:
5306 tool_id: The tool ID.
5307 activate: Whether to activate (True) or deactivate (False) the tool.
5308 db: Database session.
5309 user: Authenticated user context.
5311 Returns:
5312 Status message with tool state.
5313 """
5315 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
5316 return await set_tool_state(tool_id, activate, db, user)
5319#################
5320# Resource APIs #
5321#################
5322# --- Resource templates endpoint - MUST come before variable paths ---
5323@resource_router.get("/templates/list", response_model=ListResourceTemplatesResult)
5324@require_permission("resources.read")
5325async def list_resource_templates(
5326 request: Request,
5327 db: Session = Depends(get_db),
5328 include_inactive: bool = False,
5329 tags: Optional[str] = None,
5330 visibility: Optional[str] = None,
5331 user=Depends(get_current_user_with_permissions),
5332) -> ListResourceTemplatesResult:
5333 """
5334 List all available resource templates.
5336 Args:
5337 request (Request): The FastAPI request object for team_id retrieval.
5338 db (Session): Database session.
5339 user (str): Authenticated user.
5340 include_inactive (bool): Whether to include inactive resources.
5341 tags (Optional[str]): Comma-separated list of tags to filter by.
5342 visibility (Optional[str]): Filter by visibility (private, team, public).
5344 Returns:
5345 ListResourceTemplatesResult: A paginated list of resource templates.
5346 """
5347 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested resource templates")
5349 # Parse tags parameter if provided
5350 tags_list = None
5351 if tags:
5352 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
5354 # Get filtering context from token (respects token scope)
5355 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
5357 # Admin bypass - only when token has NO team restrictions
5358 if is_admin and token_teams is None:
5359 token_teams = None # Admin unrestricted
5360 elif token_teams is None:
5361 token_teams = [] # Non-admin without teams = public-only
5363 resource_templates = await resource_service.list_resource_templates(
5364 db,
5365 user_email=user_email,
5366 token_teams=token_teams,
5367 include_inactive=include_inactive,
5368 tags=tags_list,
5369 visibility=visibility,
5370 )
5371 # For simplicity, we're not implementing real pagination here
5372 return ListResourceTemplatesResult(_meta={}, resource_templates=resource_templates, next_cursor=None) # No pagination for now
5375@resource_router.post("/{resource_id}/state")
5376@require_permission("resources.update")
5377async def set_resource_state(
5378 resource_id: str,
5379 activate: bool = True,
5380 db: Session = Depends(get_db),
5381 user=Depends(get_current_user_with_permissions),
5382) -> Dict[str, Any]:
5383 """
5384 Activate or deactivate a resource by its ID.
5386 Args:
5387 resource_id (str): The ID of the resource.
5388 activate (bool): True to activate, False to deactivate.
5389 db (Session): Database session.
5390 user (str): Authenticated user.
5392 Returns:
5393 Dict[str, Any]: Status message and updated resource data.
5395 Raises:
5396 HTTPException: If toggling fails.
5397 """
5398 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is toggling resource with ID {resource_id} to {'active' if activate else 'inactive'}")
5399 try:
5400 user_email = user.get("email") if isinstance(user, dict) else str(user)
5401 resource = await resource_service.set_resource_state(db, resource_id, activate, user_email=user_email)
5402 return {
5403 "status": "success",
5404 "message": f"Resource {resource_id} {'activated' if activate else 'deactivated'}",
5405 "resource": resource.model_dump(),
5406 }
5407 except PermissionError as e:
5408 raise HTTPException(status_code=403, detail=str(e))
5409 except ResourceNotFoundError as e:
5410 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5411 except ResourceLockConflictError as e:
5412 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
5413 except Exception as e:
5414 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5417@resource_router.post("/{resource_id}/toggle", deprecated=True)
5418@require_permission("resources.update")
5419async def toggle_resource_status(
5420 resource_id: str,
5421 activate: bool = True,
5422 db: Session = Depends(get_db),
5423 user=Depends(get_current_user_with_permissions),
5424) -> Dict[str, Any]:
5425 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
5427 Activate or deactivate a resource by its ID.
5429 Args:
5430 resource_id: The resource ID.
5431 activate: Whether to activate (True) or deactivate (False) the resource.
5432 db: Database session.
5433 user: Authenticated user context.
5435 Returns:
5436 Status message with resource state.
5437 """
5439 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
5440 return await set_resource_state(resource_id, activate, db, user)
5443@resource_router.get("", response_model=Union[List[ResourceRead], CursorPaginatedResourcesResponse])
5444@resource_router.get("/", response_model=Union[List[ResourceRead], CursorPaginatedResourcesResponse])
5445@require_permission("resources.read")
5446async def list_resources(
5447 request: Request,
5448 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
5449 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
5450 limit: Optional[int] = Query(None, ge=0, description="Maximum number of resources to return"),
5451 include_inactive: bool = False,
5452 tags: Optional[str] = None,
5453 team_id: Optional[str] = None,
5454 visibility: Optional[str] = None,
5455 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"),
5456 db: Session = Depends(get_db),
5457 user=Depends(get_current_user_with_permissions),
5458) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
5459 """
5460 Retrieve a list of resources accessible to the user, with team filtering and cursor pagination support.
5462 Args:
5463 request (Request): The FastAPI request object for team_id retrieval
5464 cursor (Optional[str]): Cursor for pagination.
5465 include_pagination (bool): Include cursor pagination metadata in response.
5466 limit (Optional[int]): Maximum number of resources to return.
5467 include_inactive (bool): Whether to include inactive resources.
5468 tags (Optional[str]): Comma-separated list of tags to filter by.
5469 team_id (Optional[str]): Filter by specific team ID.
5470 visibility (Optional[str]): Filter by visibility (private, team, public).
5471 gateway_id (Optional[str]): Filter by gateway ID. Use 'null' for resources without a gateway.
5472 db (Session): Database session.
5473 user (str): Authenticated user.
5475 Returns:
5476 Union[List[ResourceRead], Dict[str, Any]]: List of resources or paginated response with nextCursor.
5477 """
5478 # Parse tags parameter if provided
5479 tags_list = None
5480 if tags:
5481 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
5483 # Get filtering context from token (respects token scope)
5484 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
5486 # Admin bypass - only when token has NO team restrictions (token_teams is None)
5487 # If token has explicit team scope (even for admins), respect it for least-privilege
5488 if is_admin and token_teams is None:
5489 user_email = None
5490 token_teams = None # Admin unrestricted
5491 elif token_teams is None:
5492 token_teams = [] # Non-admin without teams = public-only (secure default)
5494 # Check team_id from request.state (set during auth)
5495 token_team_id = getattr(request.state, "team_id", None)
5497 # Check for team ID mismatch (only applies when both are specified and token has teams)
5498 if team_id is not None and token_team_id is not None and team_id != token_team_id:
5499 return ORJSONResponse(
5500 content={"message": "Access issue: This API token does not have the required permissions for this team."},
5501 status_code=status.HTTP_403_FORBIDDEN,
5502 )
5504 # For listing, only narrow by team_id when explicitly requested via query param.
5505 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
5507 # Use unified list_resources() with token-based team filtering
5508 # Always apply visibility filtering based on token scope
5509 logger.debug(
5510 f"User {SecurityValidator.sanitize_log_message(user_email)} requested resource list with cursor {cursor}, include_inactive={include_inactive}, tags={tags_list}, team_id={team_id}, visibility={visibility}, gateway_id={gateway_id}"
5511 )
5512 data, next_cursor = await resource_service.list_resources(
5513 db=db,
5514 cursor=cursor,
5515 limit=limit,
5516 include_inactive=include_inactive,
5517 tags=tags_list,
5518 gateway_id=gateway_id,
5519 user_email=user_email,
5520 team_id=team_id,
5521 visibility=visibility,
5522 token_teams=token_teams,
5523 )
5524 # Release transaction before response serialization
5525 db.commit()
5526 db.close()
5528 if include_pagination:
5529 return CursorPaginatedResourcesResponse.model_construct(resources=data, next_cursor=next_cursor)
5530 return data
5533@resource_router.post("", response_model=ResourceRead)
5534@resource_router.post("/", response_model=ResourceRead)
5535@require_permission("resources.create")
5536async def create_resource(
5537 resource: ResourceCreate,
5538 request: Request,
5539 team_id: Optional[str] = Body(None, description="Team ID to assign resource to"),
5540 visibility: Optional[str] = Body("public", description="Resource visibility: private, team, public"),
5541 db: Session = Depends(get_db),
5542 user=Depends(get_current_user_with_permissions),
5543) -> ResourceRead:
5544 """
5545 Create a new resource.
5547 Args:
5548 resource (ResourceCreate): Data for the new resource.
5549 request (Request): FastAPI request object for metadata extraction.
5550 team_id (Optional[str]): Team ID to assign the resource to.
5551 visibility (str): Resource visibility level (private, team, public).
5552 db (Session): Database session.
5553 user (str): Authenticated user.
5555 Returns:
5556 ResourceRead: The created resource.
5558 Raises:
5559 HTTPException: On conflict or validation errors or IntegrityError.
5560 """
5561 try:
5562 # Extract metadata from request
5563 metadata = MetadataCapture.extract_creation_metadata(request, user)
5565 # Get user email and handle team assignment
5566 user_email = get_user_email(user)
5568 token_team_id = getattr(request.state, "team_id", None)
5569 token_teams = getattr(request.state, "token_teams", None)
5571 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
5572 is_public_only_token = token_teams is not None and len(token_teams) == 0
5573 if is_public_only_token and visibility in ("team", "private"):
5574 return ORJSONResponse(
5575 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
5576 status_code=status.HTTP_403_FORBIDDEN,
5577 )
5579 # Check for team ID mismatch (only for non-public-only tokens)
5580 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
5581 return ORJSONResponse(
5582 content={"message": "Access issue: This API token does not have the required permissions for this team."},
5583 status_code=status.HTTP_403_FORBIDDEN,
5584 )
5586 # Determine final team ID (public-only tokens get no team)
5587 if is_public_only_token:
5588 team_id = None
5589 else:
5590 team_id = team_id or token_team_id
5592 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new resource for team {team_id}")
5593 result = await resource_service.register_resource(
5594 db,
5595 resource,
5596 created_by=metadata["created_by"],
5597 created_from_ip=metadata["created_from_ip"],
5598 created_via=metadata["created_via"],
5599 created_user_agent=metadata["created_user_agent"],
5600 import_batch_id=metadata["import_batch_id"],
5601 federation_source=metadata["federation_source"],
5602 team_id=team_id,
5603 owner_email=user_email,
5604 visibility=visibility,
5605 )
5606 db.commit()
5607 db.close()
5608 return result
5609 except ResourceURIConflictError as e:
5610 raise HTTPException(status_code=409, detail=str(e))
5611 except ResourceError as e:
5612 raise HTTPException(status_code=400, detail=str(e))
5613 except ValidationError as e:
5614 # Handle validation errors from Pydantic
5615 logger.error(f"Validation error while creating resource: {e}")
5616 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
5617 except IntegrityError as e:
5618 logger.error(f"Integrity error while creating resource: {e}")
5619 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
5620 except ContentSizeError as e:
5621 logger.error(f"Content size exceeded in creating resource: {e}")
5622 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size})
5623 except ContentTypeError as e:
5624 logger.error(f"MIME type not allowed in creating resource: {e}")
5625 raise HTTPException(status_code=415, detail={"error": "Unsupported Media Type", "message": str(e), "mime_type": e.mime_type, "allowed_types": e.allowed_types})
5628@resource_router.get("/{resource_id}")
5629@require_permission("resources.read")
5630async def read_resource(resource_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Any:
5631 """
5632 Read a resource by its ID with plugin support.
5634 Args:
5635 resource_id (str): ID of the resource.
5636 request (Request): FastAPI request object for context.
5637 db (Session): Database session.
5638 user (str): Authenticated user.
5640 Returns:
5641 Any: The content of the resource.
5643 Raises:
5644 HTTPException: If the resource cannot be found or read.
5645 """
5646 # Get request ID from headers or generate one
5647 request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
5648 server_id = request.headers.get("X-Server-ID")
5650 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested resource with ID {resource_id} (request_id: {request_id})")
5652 # NOTE: Removed endpoint-level cache to prevent authorization bypass
5653 # The cache was checked before access control, allowing unauthorized users
5654 # to access cached private resources. Service layer handles caching safely.
5656 # Get plugin contexts from request.state for cross-hook sharing
5657 plugin_context_table = getattr(request.state, "plugin_context_table", None)
5658 plugin_global_context = getattr(request.state, "plugin_global_context", None)
5660 try:
5661 # Extract user email and admin status for authorization
5662 user_email = get_user_email(user)
5663 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
5665 # Admin bypass: pass user=None to trigger unrestricted access
5666 # Non-admin: pass user_email and let service look up teams
5667 auth_user_email = None if is_admin else user_email
5669 # Call service with context for plugin support
5670 content = await resource_service.read_resource(
5671 db,
5672 resource_id=resource_id,
5673 request_id=request_id,
5674 user=auth_user_email,
5675 server_id=server_id,
5676 token_teams=None, # Admin: bypass; Non-admin: lookup teams
5677 plugin_context_table=plugin_context_table,
5678 plugin_global_context=plugin_global_context,
5679 )
5680 _enforce_scoped_resource_access(request, db, user, f"/resources/{resource_id}")
5681 # Release transaction before response serialization
5682 db.commit()
5683 db.close()
5684 except (ResourceNotFoundError, ResourceError) as exc:
5685 # Translate to FastAPI HTTP error
5686 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
5688 # NOTE: Removed cache.set() - see cache removal comment above
5689 # Ensure a plain JSON-serializable structure
5690 try:
5691 # First-Party
5692 from mcpgateway.common.models import ResourceContent, TextContent # pylint: disable=import-outside-toplevel
5694 # If already a ResourceContent, serialize directly
5695 if isinstance(content, ResourceContent):
5696 return content.model_dump()
5698 # If TextContent, wrap into resource envelope with text
5699 if isinstance(content, TextContent):
5700 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": content.text}
5701 except Exception:
5702 pass # nosec B110 - Intentionally continue with fallback resource content handling
5704 if isinstance(content, bytes):
5705 return {"type": "resource", "id": resource_id, "uri": content.uri, "blob": content.decode("utf-8", errors="ignore")}
5706 if isinstance(content, str):
5707 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": content}
5709 # Objects with a 'text' attribute (e.g., mocks) – best-effort mapping
5710 if hasattr(content, "text"):
5711 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": getattr(content, "text")}
5713 return {"type": "resource", "id": resource_id, "uri": content.uri, "text": str(content)}
5716@resource_router.get("/{resource_id}/info", response_model=ResourceRead)
5717@require_permission("resources.read")
5718async def get_resource_info(
5719 resource_id: str,
5720 request: Request,
5721 include_inactive: bool = Query(False, description="Include inactive resources"),
5722 db: Session = Depends(get_db),
5723 user=Depends(get_current_user_with_permissions),
5724) -> ResourceRead:
5725 """
5726 Get resource metadata by ID.
5728 Returns the resource metadata including the enabled status. This endpoint
5729 is different from GET /resources/{resource_id} which returns the resource content.
5731 Args:
5732 resource_id (str): ID of the resource.
5733 request (Request): Incoming request context used for scope enforcement.
5734 include_inactive (bool): Whether to include inactive resources.
5735 db (Session): Database session.
5736 user (str): Authenticated user.
5738 Returns:
5739 ResourceRead: The resource metadata including enabled status.
5741 Raises:
5742 HTTPException: If the resource is not found.
5743 """
5744 try:
5745 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested resource info for ID {resource_id}")
5746 result = await resource_service.get_resource_by_id(db, resource_id, include_inactive=include_inactive)
5747 _enforce_scoped_resource_access(request, db, user, f"/resources/{resource_id}")
5748 return result
5749 except ResourceNotFoundError as e:
5750 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5753@resource_router.put("/{resource_id}", response_model=ResourceRead)
5754@require_permission("resources.update")
5755async def update_resource(
5756 resource_id: str,
5757 resource: ResourceUpdate,
5758 request: Request,
5759 db: Session = Depends(get_db),
5760 user=Depends(get_current_user_with_permissions),
5761) -> ResourceRead:
5762 """
5763 Update a resource identified by its ID.
5765 Args:
5766 resource_id (str): ID of the resource.
5767 resource (ResourceUpdate): New resource data.
5768 request (Request): The FastAPI request object for metadata extraction.
5769 db (Session): Database session.
5770 user (str): Authenticated user.
5772 Returns:
5773 ResourceRead: The updated resource.
5775 Raises:
5776 HTTPException: If the resource is not found or update fails.
5777 """
5778 try:
5779 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is updating resource with ID {resource_id}")
5780 # Extract modification metadata
5781 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
5783 user_email = user.get("email") if isinstance(user, dict) else str(user)
5784 result = await resource_service.update_resource(
5785 db,
5786 resource_id,
5787 resource,
5788 modified_by=mod_metadata["modified_by"],
5789 modified_from_ip=mod_metadata["modified_from_ip"],
5790 modified_via=mod_metadata["modified_via"],
5791 modified_user_agent=mod_metadata["modified_user_agent"],
5792 user_email=user_email,
5793 )
5794 except PermissionError as e:
5795 raise HTTPException(status_code=403, detail=str(e))
5796 except ResourceNotFoundError as e:
5797 raise HTTPException(status_code=404, detail=str(e))
5798 except ValidationError as e:
5799 logger.error(f"Validation error while updating resource {resource_id}: {e}")
5800 raise HTTPException(status_code=422, detail=ErrorFormatter.format_validation_error(e))
5801 except IntegrityError as e:
5802 logger.error(f"Integrity error while updating resource {resource_id}: {e}")
5803 raise HTTPException(status_code=409, detail=ErrorFormatter.format_database_error(e))
5804 except ResourceURIConflictError as e:
5805 raise HTTPException(status_code=409, detail=str(e))
5806 except ContentSizeError as e:
5807 logger.error(f"Content size exceeded in updating resource: {e}")
5808 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size})
5809 except ContentTypeError as e:
5810 logger.error(f"MIME type not allowed in updating resource: {e}")
5811 raise HTTPException(status_code=415, detail={"error": "Unsupported Media Type", "message": str(e), "mime_type": e.mime_type, "allowed_types": e.allowed_types})
5812 db.commit()
5813 db.close()
5814 await invalidate_resource_cache(resource_id)
5815 return result
5818@resource_router.delete("/{resource_id}")
5819@require_permission("resources.delete")
5820async def delete_resource(
5821 resource_id: str,
5822 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this resource"),
5823 db: Session = Depends(get_db),
5824 user=Depends(get_current_user_with_permissions),
5825) -> Dict[str, str]:
5826 """
5827 Delete a resource by its ID.
5829 Args:
5830 resource_id (str): ID of the resource to delete.
5831 purge_metrics (bool): Whether to delete raw + hourly rollup metrics for this resource.
5832 db (Session): Database session.
5833 user (str): Authenticated user.
5835 Returns:
5836 Dict[str, str]: Status message indicating deletion success.
5838 Raises:
5839 HTTPException: If the resource is not found or deletion fails.
5840 """
5841 try:
5842 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is deleting resource with id {resource_id}")
5843 user_email = user.get("email") if isinstance(user, dict) else str(user)
5844 await resource_service.delete_resource(db, resource_id, user_email=user_email, purge_metrics=purge_metrics)
5845 db.commit()
5846 db.close()
5847 await invalidate_resource_cache(resource_id)
5848 return {"status": "success", "message": f"Resource {resource_id} deleted"}
5849 except PermissionError as e:
5850 raise HTTPException(status_code=403, detail=str(e))
5851 except ResourceNotFoundError as e:
5852 raise HTTPException(status_code=404, detail=str(e))
5853 except ResourceError as e:
5854 raise HTTPException(status_code=400, detail=str(e))
5857@resource_router.post("/subscribe")
5858@require_permission("resources.read")
5859async def subscribe_resource(request: Request, user=Depends(get_current_user_with_permissions)) -> StreamingResponse:
5860 """
5861 Subscribe to server-sent events (SSE) for a specific resource.
5863 Args:
5864 request (Request): Incoming HTTP request.
5865 user (str): Authenticated user.
5867 Returns:
5868 StreamingResponse: A streaming response with event updates.
5869 """
5870 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is subscribing to resource")
5871 user_email, token_teams = _get_scoped_resource_access_context(request, user)
5873 async def sse_generator():
5874 """Generate SSE-formatted events from resource subscription changes.
5876 Yields:
5877 str: SSE-formatted event data.
5878 """
5879 async for event in resource_service.subscribe_events(user_email=user_email, token_teams=token_teams):
5880 yield f"data: {orjson.dumps(event).decode()}\n\n"
5882 return StreamingResponse(sse_generator(), media_type="text/event-stream")
5885###############
5886# Prompt APIs #
5887###############
5888@prompt_router.post("/{prompt_id}/state")
5889@require_permission("prompts.update")
5890async def set_prompt_state(
5891 prompt_id: str,
5892 activate: bool = True,
5893 db: Session = Depends(get_db),
5894 user=Depends(get_current_user_with_permissions),
5895) -> Dict[str, Any]:
5896 """
5897 Set the activation status of a prompt.
5899 Args:
5900 prompt_id: ID of the prompt to update.
5901 activate: True to activate, False to deactivate.
5902 db: Database session.
5903 user: Authenticated user.
5905 Returns:
5906 Status message and updated prompt details.
5908 Raises:
5909 HTTPException: If the state change fails (e.g., prompt not found or database error); emitted with *400 Bad Request* status and an error message.
5910 """
5911 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested state change for prompt {prompt_id}, activate={activate}")
5912 try:
5913 user_email = user.get("email") if isinstance(user, dict) else str(user)
5914 prompt = await prompt_service.set_prompt_state(db, prompt_id, activate, user_email=user_email)
5915 return {
5916 "status": "success",
5917 "message": f"Prompt {prompt_id} {'activated' if activate else 'deactivated'}",
5918 "prompt": prompt.model_dump(),
5919 }
5920 except PermissionError as e:
5921 raise HTTPException(status_code=403, detail=str(e))
5922 except PromptNotFoundError as e:
5923 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
5924 except PromptLockConflictError as e:
5925 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
5926 except Exception as e:
5927 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
5930@prompt_router.post("/{prompt_id}/toggle", deprecated=True)
5931@require_permission("prompts.update")
5932async def toggle_prompt_status(
5933 prompt_id: str,
5934 activate: bool = True,
5935 db: Session = Depends(get_db),
5936 user=Depends(get_current_user_with_permissions),
5937) -> Dict[str, Any]:
5938 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
5940 Set the activation status of a prompt.
5942 Args:
5943 prompt_id: The prompt ID.
5944 activate: Whether to activate (True) or deactivate (False) the prompt.
5945 db: Database session.
5946 user: Authenticated user context.
5948 Returns:
5949 Status message with prompt state.
5950 """
5952 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
5953 return await set_prompt_state(prompt_id, activate, db, user)
5956@prompt_router.get("", response_model=Union[List[PromptRead], CursorPaginatedPromptsResponse])
5957@prompt_router.get("/", response_model=Union[List[PromptRead], CursorPaginatedPromptsResponse])
5958@require_permission("prompts.read")
5959async def list_prompts(
5960 request: Request,
5961 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
5962 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
5963 limit: Optional[int] = Query(None, ge=0, description="Maximum number of prompts to return"),
5964 include_inactive: bool = False,
5965 tags: Optional[str] = None,
5966 team_id: Optional[str] = None,
5967 visibility: Optional[str] = None,
5968 gateway_id: Optional[str] = Query(None, description="Filter by gateway ID"),
5969 db: Session = Depends(get_db),
5970 user=Depends(get_current_user_with_permissions),
5971) -> Union[List[Dict[str, Any]], Dict[str, Any]]:
5972 """
5973 List prompts accessible to the user, with team filtering and cursor pagination support.
5975 Args:
5976 request (Request): The FastAPI request object for team_id retrieval
5977 cursor (Optional[str]): Cursor for pagination.
5978 include_pagination (bool): Include cursor pagination metadata in response.
5979 limit (Optional[int]): Maximum number of prompts to return.
5980 include_inactive: Include inactive prompts.
5981 tags: Comma-separated list of tags to filter by.
5982 team_id: Filter by specific team ID.
5983 visibility: Filter by visibility (private, team, public).
5984 gateway_id: Filter by gateway ID. Use 'null' for prompts without a gateway.
5985 db: Database session.
5986 user: Authenticated user.
5988 Returns:
5989 Union[List[Dict[str, Any]], Dict[str, Any]]: List of prompt records or paginated response with nextCursor.
5990 """
5991 # Parse tags parameter if provided
5992 tags_list = None
5993 if tags:
5994 tags_list = [tag.strip() for tag in tags.split(",") if tag.strip()]
5996 # Get filtering context from token (respects token scope)
5997 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
5999 # Admin bypass - only when token has NO team restrictions (token_teams is None)
6000 # If token has explicit team scope (even for admins), respect it for least-privilege
6001 if is_admin and token_teams is None:
6002 user_email = None
6003 token_teams = None # Admin unrestricted
6004 elif token_teams is None:
6005 token_teams = [] # Non-admin without teams = public-only (secure default)
6007 # Check team_id from request.state (set during auth)
6008 token_team_id = getattr(request.state, "team_id", None)
6010 # Check for team ID mismatch (only applies when both are specified and token has teams)
6011 if team_id is not None and token_team_id is not None and team_id != token_team_id:
6012 return ORJSONResponse(
6013 content={"message": "Access issue: This API token does not have the required permissions for this team."},
6014 status_code=status.HTTP_403_FORBIDDEN,
6015 )
6017 # For listing, only narrow by team_id when explicitly requested via query param.
6018 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
6020 # Use consolidated prompt listing with token-based team filtering
6021 # Always apply visibility filtering based on token scope
6022 logger.debug(
6023 f"User: {SecurityValidator.sanitize_log_message(user_email)} requested prompt list with include_inactive={include_inactive}, cursor={cursor}, tags={tags_list}, team_id={team_id}, visibility={visibility}, gateway_id={gateway_id}"
6024 )
6025 data, next_cursor = await prompt_service.list_prompts(
6026 db=db,
6027 cursor=cursor,
6028 limit=limit,
6029 include_inactive=include_inactive,
6030 tags=tags_list,
6031 gateway_id=gateway_id,
6032 user_email=user_email,
6033 team_id=team_id,
6034 visibility=visibility,
6035 token_teams=token_teams,
6036 )
6037 # Release transaction before response serialization
6038 db.commit()
6039 db.close()
6041 if include_pagination:
6042 return CursorPaginatedPromptsResponse.model_construct(prompts=data, next_cursor=next_cursor)
6043 return data
6046@prompt_router.post("", response_model=PromptRead)
6047@prompt_router.post("/", response_model=PromptRead)
6048@require_permission("prompts.create")
6049async def create_prompt(
6050 prompt: PromptCreate,
6051 request: Request,
6052 team_id: Optional[str] = Body(None, description="Team ID to assign prompt to"),
6053 visibility: Optional[str] = Body("public", description="Prompt visibility: private, team, public"),
6054 db: Session = Depends(get_db),
6055 user=Depends(get_current_user_with_permissions),
6056) -> PromptRead:
6057 """
6058 Create a new prompt.
6060 Args:
6061 prompt (PromptCreate): Payload describing the prompt to create.
6062 request (Request): The FastAPI request object for metadata extraction.
6063 team_id (Optional[str]): Team ID to assign the prompt to.
6064 visibility (str): Prompt visibility level (private, team, public).
6065 db (Session): Active SQLAlchemy session.
6066 user (str): Authenticated username.
6068 Returns:
6069 PromptRead: The newly-created prompt.
6071 Raises:
6072 HTTPException: * **409 Conflict** - another prompt with the same name already exists.
6073 * **400 Bad Request** - validation or persistence error raised
6074 by :pyclass:`~mcpgateway.services.prompt_service.PromptService`.
6075 """
6076 try:
6077 # Extract metadata from request
6078 metadata = MetadataCapture.extract_creation_metadata(request, user)
6080 # Get user email and handle team assignment
6081 user_email = get_user_email(user)
6083 token_team_id = getattr(request.state, "team_id", None)
6084 token_teams = getattr(request.state, "token_teams", None)
6086 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
6087 is_public_only_token = token_teams is not None and len(token_teams) == 0
6088 if is_public_only_token and visibility in ("team", "private"):
6089 return ORJSONResponse(
6090 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
6091 status_code=status.HTTP_403_FORBIDDEN,
6092 )
6094 # Check for team ID mismatch (only for non-public-only tokens)
6095 if not is_public_only_token and team_id is not None and token_team_id is not None and team_id != token_team_id:
6096 return ORJSONResponse(
6097 content={"message": "Access issue: This API token does not have the required permissions for this team."},
6098 status_code=status.HTTP_403_FORBIDDEN,
6099 )
6101 # Determine final team ID (public-only tokens get no team)
6102 if is_public_only_token:
6103 team_id = None
6104 else:
6105 team_id = team_id or token_team_id
6107 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new prompt for team {team_id}")
6108 result = await prompt_service.register_prompt(
6109 db,
6110 prompt,
6111 created_by=metadata["created_by"],
6112 created_from_ip=metadata["created_from_ip"],
6113 created_via=metadata["created_via"],
6114 created_user_agent=metadata["created_user_agent"],
6115 import_batch_id=metadata["import_batch_id"],
6116 federation_source=metadata["federation_source"],
6117 team_id=team_id,
6118 owner_email=user_email,
6119 visibility=visibility,
6120 )
6121 db.commit()
6122 db.close()
6123 return result
6124 except Exception as e:
6125 if isinstance(e, PromptNameConflictError):
6126 # If the prompt name already exists, return a 409 Conflict error
6127 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
6128 if isinstance(e, PromptError):
6129 # If there is a general prompt error, return a 400 Bad Request error
6130 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
6131 if isinstance(e, ValidationError):
6132 # If there is a validation error, return a 422 Unprocessable Entity error
6133 logger.error(f"Validation error while creating prompt: {e}")
6134 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(e))
6135 if isinstance(e, IntegrityError):
6136 # If there is an integrity error, return a 409 Conflict error
6137 logger.error(f"Integrity error while creating prompt: {e}")
6138 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(e))
6139 if isinstance(e, ContentSizeError):
6140 logger.error(f"Content size exceeded in creating prompt: {e}")
6141 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size})
6142 # For any other unexpected errors, return a 500 Internal Server Error
6143 logger.error(f"Unexpected error while creating prompt: {e}")
6144 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while creating the prompt")
6147@prompt_router.post("/{prompt_id}")
6148@require_permission("prompts.read")
6149async def get_prompt(
6150 request: Request,
6151 prompt_id: str,
6152 args: Dict[str, str] = Body({}),
6153 db: Session = Depends(get_db),
6154 user=Depends(get_current_user_with_permissions),
6155) -> Any:
6156 """Get a prompt by prompt_id with arguments.
6158 This implements the prompts/get functionality from the MCP spec,
6159 which requires a POST request with arguments in the body.
6162 Args:
6163 request: FastAPI request object.
6164 prompt_id: ID of the prompt.
6165 args: Template arguments.
6166 db: Database session.
6167 user: Authenticated user.
6169 Returns:
6170 Rendered prompt or metadata.
6172 Raises:
6173 Exception: Re-raised if not a handled exception type.
6174 """
6175 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested prompt: {prompt_id} with args={args}")
6177 # Get plugin contexts from request.state for cross-hook sharing
6178 plugin_context_table = getattr(request.state, "plugin_context_table", None)
6179 plugin_global_context = getattr(request.state, "plugin_global_context", None)
6181 # Extract user email, admin status, and server_id for authorization
6182 user_email = get_user_email(user)
6183 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
6184 server_id = request.headers.get("X-Server-ID")
6186 # Admin bypass: pass user=None to trigger unrestricted access
6187 # Non-admin: pass user_email and let service look up teams
6188 auth_user_email = None if is_admin else user_email
6190 try:
6191 PromptExecuteArgs(args=args)
6192 result = await prompt_service.get_prompt(
6193 db,
6194 prompt_id,
6195 args,
6196 user=auth_user_email,
6197 server_id=server_id,
6198 token_teams=None, # Admin: bypass; Non-admin: lookup teams
6199 plugin_context_table=plugin_context_table,
6200 plugin_global_context=plugin_global_context,
6201 )
6202 logger.debug(f"Prompt execution successful for '{prompt_id}'")
6203 except Exception as ex:
6204 logger.error(f"Could not retrieve prompt {prompt_id}: {ex}")
6205 if isinstance(ex, PluginViolationError):
6206 # Return the actual plugin violation message
6207 return ORJSONResponse(content={"message": ex.message, "details": str(ex.violation) if hasattr(ex, "violation") else None}, status_code=422)
6208 if isinstance(ex, (ValueError, PromptError)):
6209 # Return the actual error message
6210 return ORJSONResponse(content={"message": str(ex)}, status_code=422)
6211 raise
6213 return result
6216@prompt_router.get("/{prompt_id}")
6217@require_permission("prompts.read")
6218async def get_prompt_no_args(
6219 request: Request,
6220 prompt_id: str,
6221 db: Session = Depends(get_db),
6222 user=Depends(get_current_user_with_permissions),
6223) -> Any:
6224 """Get a prompt by ID without arguments.
6226 This endpoint is for convenience when no arguments are needed.
6228 Args:
6229 request: FastAPI request object.
6230 prompt_id: The ID of the prompt to retrieve
6231 db: Database session
6232 user: Authenticated user
6234 Returns:
6235 The prompt template information
6237 Raises:
6238 HTTPException: 404 if prompt not found, 403 if permission denied.
6239 """
6240 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested prompt: {prompt_id} with no arguments")
6242 # Get plugin contexts from request.state for cross-hook sharing
6243 plugin_context_table = getattr(request.state, "plugin_context_table", None)
6244 plugin_global_context = getattr(request.state, "plugin_global_context", None)
6246 # Extract user email, admin status, and server_id for authorization
6247 user_email = get_user_email(user)
6248 is_admin = user.get("is_admin", False) if isinstance(user, dict) else False
6249 server_id = request.headers.get("X-Server-ID")
6251 # Admin bypass: pass user=None to trigger unrestricted access
6252 # Non-admin: pass user_email and let service look up teams
6253 auth_user_email = None if is_admin else user_email
6255 try:
6256 return await prompt_service.get_prompt(
6257 db,
6258 prompt_id,
6259 {},
6260 user=auth_user_email,
6261 server_id=server_id,
6262 token_teams=None, # Admin: bypass; Non-admin: lookup teams
6263 plugin_context_table=plugin_context_table,
6264 plugin_global_context=plugin_global_context,
6265 )
6266 except PromptNotFoundError as e:
6267 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6268 except PromptError as e:
6269 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e))
6270 except PermissionError as e:
6271 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
6274@prompt_router.put("/{prompt_id}", response_model=PromptRead)
6275@require_permission("prompts.update")
6276async def update_prompt(
6277 prompt_id: str,
6278 prompt: PromptUpdate,
6279 request: Request,
6280 db: Session = Depends(get_db),
6281 user=Depends(get_current_user_with_permissions),
6282) -> PromptRead:
6283 """
6284 Update (overwrite) an existing prompt definition.
6286 Args:
6287 prompt_id (str): Identifier of the prompt to update.
6288 prompt (PromptUpdate): New prompt content and metadata.
6289 request (Request): The FastAPI request object for metadata extraction.
6290 db (Session): Active SQLAlchemy session.
6291 user (str): Authenticated username.
6293 Returns:
6294 PromptRead: The updated prompt object.
6296 Raises:
6297 HTTPException: * **409 Conflict** - a different prompt with the same *name* already exists and is still active.
6298 * **400 Bad Request** - validation or persistence error raised by :pyclass:`~mcpgateway.services.prompt_service.PromptService`.
6299 """
6300 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested to update prompt: {prompt_id} with data={prompt}")
6301 try:
6302 # Extract modification metadata
6303 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
6305 user_email = user.get("email") if isinstance(user, dict) else str(user)
6306 result = await prompt_service.update_prompt(
6307 db,
6308 prompt_id,
6309 prompt,
6310 modified_by=mod_metadata["modified_by"],
6311 modified_from_ip=mod_metadata["modified_from_ip"],
6312 modified_via=mod_metadata["modified_via"],
6313 modified_user_agent=mod_metadata["modified_user_agent"],
6314 user_email=user_email,
6315 )
6316 db.commit()
6317 db.close()
6318 return result
6319 except Exception as e:
6320 if isinstance(e, PermissionError):
6321 raise HTTPException(status_code=403, detail=str(e))
6322 if isinstance(e, PromptNotFoundError):
6323 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6324 if isinstance(e, ValidationError):
6325 logger.error(f"Validation error while updating prompt: {e}")
6326 raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=ErrorFormatter.format_validation_error(e))
6327 if isinstance(e, IntegrityError):
6328 logger.error(f"Integrity error while updating prompt: {e}")
6329 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=ErrorFormatter.format_database_error(e))
6330 if isinstance(e, PromptNameConflictError):
6331 # If the prompt name already exists, return a 409 Conflict error
6332 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
6333 if isinstance(e, PromptError):
6334 # If there is a general prompt error, return a 400 Bad Request error
6335 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
6336 if isinstance(e, ContentSizeError):
6337 logger.error(f"Content size exceeded in updating prompt: {e}")
6338 raise HTTPException(status_code=413, detail={"error": f"{e.content_type} size limit exceeded", "message": str(e), "actual_size": e.actual_size, "max_size": e.max_size})
6339 # For any other unexpected errors, return a 500 Internal Server Error
6340 logger.error(f"Unexpected error while updating prompt: {e}")
6341 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while updating the prompt")
6344@prompt_router.delete("/{prompt_id}")
6345@require_permission("prompts.delete")
6346async def delete_prompt(
6347 prompt_id: str,
6348 purge_metrics: bool = Query(False, description="Purge raw + rollup metrics for this prompt"),
6349 db: Session = Depends(get_db),
6350 user=Depends(get_current_user_with_permissions),
6351) -> Dict[str, str]:
6352 """
6353 Delete a prompt by ID.
6355 Args:
6356 prompt_id: ID of the prompt.
6357 purge_metrics: Whether to delete raw + hourly rollup metrics for this prompt.
6358 db: Database session.
6359 user: Authenticated user.
6361 Returns:
6362 Status message.
6364 Raises:
6365 HTTPException: If the prompt is not found, a prompt error occurs, or an unexpected error occurs during deletion.
6366 """
6367 logger.debug(f"User: {SecurityValidator.sanitize_log_message(str(user))} requested deletion of prompt {prompt_id}")
6368 try:
6369 user_email = user.get("email") if isinstance(user, dict) else str(user)
6370 await prompt_service.delete_prompt(db, prompt_id, user_email=user_email, purge_metrics=purge_metrics)
6371 db.commit()
6372 db.close()
6373 return {"status": "success", "message": f"Prompt {prompt_id} deleted"}
6374 except Exception as e:
6375 if isinstance(e, PermissionError):
6376 raise HTTPException(status_code=403, detail=str(e))
6377 if isinstance(e, PromptNotFoundError):
6378 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6379 if isinstance(e, PromptError):
6380 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
6381 logger.error(f"Unexpected error while deleting prompt {prompt_id}: {e}")
6382 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="An unexpected error occurred while deleting the prompt")
6384 # except PromptNotFoundError as e:
6385 # return {"status": "error", "message": str(e)}
6386 # except PromptError as e:
6387 # return {"status": "error", "message": str(e)}
6390################
6391# Gateway APIs #
6392################
6393@gateway_router.post("/{gateway_id}/state")
6394@require_permission("gateways.update")
6395async def set_gateway_state(
6396 gateway_id: str,
6397 activate: bool = True,
6398 db: Session = Depends(get_db),
6399 user=Depends(get_current_user_with_permissions),
6400) -> Dict[str, Any]:
6401 """
6402 Set the activation status of a gateway.
6404 Args:
6405 gateway_id (str): String ID of the gateway to update.
6406 activate (bool): ``True`` to activate, ``False`` to deactivate.
6407 db (Session): Active SQLAlchemy session.
6408 user (str): Authenticated username.
6410 Returns:
6411 Dict[str, Any]: A dict containing the operation status, a message, and the updated gateway object.
6413 Raises:
6414 HTTPException: Returned with **400 Bad Request** if the state change fails (e.g., the gateway does not exist or the database raises an unexpected error).
6415 """
6416 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested state change for gateway {gateway_id}, activate={activate}")
6417 try:
6418 user_email = user.get("email") if isinstance(user, dict) else str(user)
6419 gateway = await gateway_service.set_gateway_state(
6420 db,
6421 gateway_id,
6422 activate,
6423 user_email=user_email,
6424 )
6425 return {
6426 "status": "success",
6427 "message": f"Gateway {gateway_id} {'activated' if activate else 'deactivated'}",
6428 "gateway": gateway.model_dump(),
6429 }
6430 except PermissionError as e:
6431 raise HTTPException(status_code=403, detail=str(e))
6432 except GatewayNotFoundError as e:
6433 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6434 except Exception as e:
6435 raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
6438@gateway_router.post("/{gateway_id}/toggle", deprecated=True)
6439@require_permission("gateways.update")
6440async def toggle_gateway_status(
6441 gateway_id: str,
6442 activate: bool = True,
6443 db: Session = Depends(get_db),
6444 user=Depends(get_current_user_with_permissions),
6445) -> Dict[str, Any]:
6446 """DEPRECATED: Use /state endpoint instead. This endpoint will be removed in a future release.
6448 Set the activation status of a gateway.
6450 Args:
6451 gateway_id: The gateway ID.
6452 activate: Whether to activate (True) or deactivate (False) the gateway.
6453 db: Database session.
6454 user: Authenticated user context.
6456 Returns:
6457 Status message with gateway state.
6458 """
6460 warnings.warn("The /toggle endpoint is deprecated. Use /state instead.", DeprecationWarning, stacklevel=2)
6461 return await set_gateway_state(gateway_id, activate, db, user)
6464@gateway_router.get("", response_model=Union[List[GatewayRead], CursorPaginatedGatewaysResponse])
6465@gateway_router.get("/", response_model=Union[List[GatewayRead], CursorPaginatedGatewaysResponse])
6466@require_permission("gateways.read")
6467async def list_gateways(
6468 request: Request,
6469 cursor: Optional[str] = Query(None, description="Cursor for pagination"),
6470 include_pagination: bool = Query(False, description="Include cursor pagination metadata in response"),
6471 limit: Optional[int] = Query(None, ge=0, description="Maximum number of gateways to return"),
6472 include_inactive: bool = False,
6473 team_id: Optional[str] = Query(None, description="Filter by team ID"),
6474 visibility: Optional[str] = Query(None, description="Filter by visibility: private, team, public"),
6475 db: Session = Depends(get_db),
6476 user=Depends(get_current_user_with_permissions),
6477) -> Union[List[GatewayRead], Dict[str, Any]]:
6478 """
6479 List all gateways with cursor pagination support.
6481 Args:
6482 request (Request): The FastAPI request object for team_id retrieval
6483 cursor (Optional[str]): Cursor for pagination.
6484 include_pagination (bool): Include cursor pagination metadata in response.
6485 limit (Optional[int]): Maximum number of gateways to return.
6486 include_inactive: Include inactive gateways.
6487 team_id (Optional): Filter by specific team ID.
6488 visibility (Optional): Filter by visibility (private, team, public).
6489 db: Database session.
6490 user: Authenticated user.
6492 Returns:
6493 Union[List[GatewayRead], Dict[str, Any]]: List of gateway records or paginated response with nextCursor.
6494 """
6495 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested list of gateways with include_inactive={include_inactive}")
6497 user_email = get_user_email(user)
6499 # Check team_id from token
6500 token_team_id = getattr(request.state, "team_id", None)
6501 token_teams = getattr(request.state, "token_teams", None)
6503 # Check for team ID mismatch
6504 if team_id is not None and token_team_id is not None and team_id != token_team_id:
6505 return ORJSONResponse(
6506 content={"message": "Access issue: This API token does not have the required permissions for this team."},
6507 status_code=status.HTTP_403_FORBIDDEN,
6508 )
6510 # For listing, only narrow by team_id when explicitly requested via query param.
6511 # Do NOT auto-narrow to token's single team; token_teams handles visibility scoping.
6513 # SECURITY: token_teams is normalized in auth.py:
6514 # - None: admin bypass (is_admin=true with explicit null teams) - sees ALL resources
6515 # - []: public-only (missing teams or explicit empty) - sees only public
6516 # - [...]: team-scoped - sees public + teams + user's private
6517 is_admin_bypass = token_teams is None
6518 is_public_only_token = token_teams is not None and len(token_teams) == 0
6520 # Use consolidated gateway listing with optional team filtering
6521 # For admin bypass: pass user_email=None and token_teams=None to skip all filtering
6522 logger.debug(f"User: {SecurityValidator.sanitize_log_message(user_email)} requested gateway list with include_inactive={include_inactive}, team_id={team_id}, visibility={visibility}")
6523 data, next_cursor = await gateway_service.list_gateways(
6524 db=db,
6525 cursor=cursor,
6526 limit=limit,
6527 include_inactive=include_inactive,
6528 user_email=None if is_admin_bypass else user_email, # Admin bypass: no user filtering
6529 team_id=team_id,
6530 visibility="public" if is_public_only_token and not visibility else visibility,
6531 token_teams=token_teams, # None = admin bypass, [] = public-only, [...] = team-scoped
6532 )
6533 # Release transaction before response serialization
6534 db.commit()
6535 db.close()
6537 if include_pagination:
6538 return CursorPaginatedGatewaysResponse.model_construct(gateways=data, next_cursor=next_cursor)
6539 return data
6542@gateway_router.post("", response_model=GatewayRead)
6543@gateway_router.post("/", response_model=GatewayRead)
6544@require_permission("gateways.create")
6545async def register_gateway(
6546 gateway: GatewayCreate,
6547 request: Request,
6548 db: Session = Depends(get_db),
6549 user=Depends(get_current_user_with_permissions),
6550) -> Union[GatewayRead, JSONResponse]:
6551 """
6552 Register a new gateway.
6554 Args:
6555 gateway: Gateway creation data.
6556 request: The FastAPI request object for metadata extraction.
6557 db: Database session.
6558 user: Authenticated user.
6560 Returns:
6561 Created gateway.
6562 """
6563 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to register gateway: {gateway}")
6564 try:
6565 # Extract metadata from request
6566 metadata = MetadataCapture.extract_creation_metadata(request, user)
6568 # Get user email and handle team assignment
6569 user_email = get_user_email(user)
6571 token_team_id = getattr(request.state, "team_id", None)
6572 token_teams = getattr(request.state, "token_teams", None)
6573 gateway_team_id = gateway.team_id
6574 visibility = gateway.visibility
6576 # SECURITY: Public-only tokens (teams == []) cannot create team/private resources
6577 is_public_only_token = token_teams is not None and len(token_teams) == 0
6578 if is_public_only_token and visibility in ("team", "private"):
6579 return ORJSONResponse(
6580 content={"message": "Public-only tokens cannot create team or private resources. Use visibility='public' or obtain a team-scoped token."},
6581 status_code=status.HTTP_403_FORBIDDEN,
6582 )
6584 # Check for team ID mismatch (only for non-public-only tokens)
6585 if not is_public_only_token and gateway_team_id is not None and token_team_id is not None and gateway_team_id != token_team_id:
6586 return ORJSONResponse(
6587 content={"message": "Access issue: This API token does not have the required permissions for this team."},
6588 status_code=status.HTTP_403_FORBIDDEN,
6589 )
6591 # Determine final team ID (public-only tokens get no team)
6592 if is_public_only_token:
6593 team_id = None
6594 else:
6595 team_id = gateway_team_id or token_team_id
6597 logger.debug(f"User {SecurityValidator.sanitize_log_message(user_email)} is creating a new gateway for team {team_id}")
6599 return await gateway_service.register_gateway(
6600 db,
6601 gateway,
6602 created_by=metadata["created_by"],
6603 created_from_ip=metadata["created_from_ip"],
6604 created_via=metadata["created_via"],
6605 created_user_agent=metadata["created_user_agent"],
6606 team_id=team_id,
6607 owner_email=user_email,
6608 visibility=visibility,
6609 )
6610 except Exception as ex:
6611 if isinstance(ex, GatewayConnectionError):
6612 return ORJSONResponse(content={"message": str(ex)}, status_code=status.HTTP_502_BAD_GATEWAY)
6613 if isinstance(ex, ValueError):
6614 return ORJSONResponse(content={"message": "Unable to process input"}, status_code=status.HTTP_400_BAD_REQUEST)
6615 if isinstance(ex, GatewayNameConflictError):
6616 return ORJSONResponse(content={"message": "Gateway name already exists"}, status_code=status.HTTP_409_CONFLICT)
6617 if isinstance(ex, GatewayDuplicateConflictError):
6618 return ORJSONResponse(content={"message": "Gateway already exists"}, status_code=status.HTTP_409_CONFLICT)
6619 if isinstance(ex, RuntimeError):
6620 return ORJSONResponse(content={"message": "Error during execution"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
6621 if isinstance(ex, ValidationError):
6622 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
6623 if isinstance(ex, IntegrityError):
6624 return ORJSONResponse(status_code=status.HTTP_409_CONFLICT, content=ErrorFormatter.format_database_error(ex))
6625 return ORJSONResponse(content={"message": "Unexpected error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
6628@gateway_router.get("/{gateway_id}", response_model=GatewayRead)
6629@require_permission("gateways.read")
6630async def get_gateway(gateway_id: str, request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Union[GatewayRead, JSONResponse]:
6631 """
6632 Retrieve a gateway by ID.
6634 Args:
6635 gateway_id: ID of the gateway.
6636 request: Incoming request used for scoped access validation.
6637 db: Database session.
6638 user: Authenticated user.
6640 Returns:
6641 Gateway data.
6643 Raises:
6644 HTTPException: 404 if gateway not found.
6645 """
6646 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested gateway {gateway_id}")
6647 try:
6648 gateway = await gateway_service.get_gateway(db, gateway_id)
6649 _enforce_scoped_resource_access(request, db, user, f"/gateways/{gateway_id}")
6650 return gateway
6651 except GatewayNotFoundError as e:
6652 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6655@gateway_router.put("/{gateway_id}", response_model=GatewayRead)
6656@require_permission("gateways.update")
6657async def update_gateway(
6658 gateway_id: str,
6659 gateway: GatewayUpdate,
6660 request: Request,
6661 db: Session = Depends(get_db),
6662 user=Depends(get_current_user_with_permissions),
6663) -> Union[GatewayRead, JSONResponse]:
6664 """
6665 Update a gateway.
6667 Args:
6668 gateway_id: Gateway ID.
6669 gateway: Gateway update data.
6670 request (Request): The FastAPI request object for metadata extraction.
6671 db: Database session.
6672 user: Authenticated user.
6674 Returns:
6675 Updated gateway.
6676 """
6677 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested update on gateway {gateway_id} with data={gateway}")
6678 try:
6679 # Extract modification metadata
6680 mod_metadata = MetadataCapture.extract_modification_metadata(request, user, 0) # Version will be incremented in service
6682 user_email = user.get("email") if isinstance(user, dict) else str(user)
6683 result = await gateway_service.update_gateway(
6684 db,
6685 gateway_id,
6686 gateway,
6687 modified_by=mod_metadata["modified_by"],
6688 modified_from_ip=mod_metadata["modified_from_ip"],
6689 modified_via=mod_metadata["modified_via"],
6690 modified_user_agent=mod_metadata["modified_user_agent"],
6691 user_email=user_email,
6692 )
6693 db.commit()
6694 db.close()
6695 return result
6696 except Exception as ex:
6697 if isinstance(ex, PermissionError):
6698 return ORJSONResponse(content={"message": str(ex)}, status_code=403)
6699 if isinstance(ex, GatewayNotFoundError):
6700 return ORJSONResponse(content={"message": "Gateway not found"}, status_code=status.HTTP_404_NOT_FOUND)
6701 if isinstance(ex, GatewayConnectionError):
6702 return ORJSONResponse(content={"message": str(ex)}, status_code=status.HTTP_502_BAD_GATEWAY)
6703 if isinstance(ex, ValueError):
6704 return ORJSONResponse(content={"message": "Unable to process input"}, status_code=status.HTTP_400_BAD_REQUEST)
6705 if isinstance(ex, GatewayNameConflictError):
6706 return ORJSONResponse(content={"message": "Gateway name already exists"}, status_code=status.HTTP_409_CONFLICT)
6707 if isinstance(ex, GatewayDuplicateConflictError):
6708 return ORJSONResponse(content={"message": "Gateway already exists"}, status_code=status.HTTP_409_CONFLICT)
6709 if isinstance(ex, RuntimeError):
6710 return ORJSONResponse(content={"message": "Error during execution"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
6711 if isinstance(ex, ValidationError):
6712 return ORJSONResponse(content=ErrorFormatter.format_validation_error(ex), status_code=status.HTTP_422_UNPROCESSABLE_CONTENT)
6713 if isinstance(ex, IntegrityError):
6714 return ORJSONResponse(status_code=status.HTTP_409_CONFLICT, content=ErrorFormatter.format_database_error(ex))
6715 return ORJSONResponse(content={"message": "Unexpected error"}, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
6718@gateway_router.delete("/{gateway_id}")
6719@require_permission("gateways.delete")
6720async def delete_gateway(gateway_id: str, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> Dict[str, str]:
6721 """
6722 Delete a gateway by ID.
6724 Args:
6725 gateway_id: ID of the gateway.
6726 db: Database session.
6727 user: Authenticated user.
6729 Returns:
6730 Status message.
6732 Raises:
6733 HTTPException: If permission denied (403), gateway not found (404), or other gateway error (400).
6734 """
6735 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested deletion of gateway {gateway_id}")
6736 try:
6737 user_email = user.get("email") if isinstance(user, dict) else str(user)
6738 current = await gateway_service.get_gateway(db, gateway_id)
6739 has_resources = bool(current.capabilities.get("resources"))
6740 await gateway_service.delete_gateway(db, gateway_id, user_email=user_email)
6742 # If the gateway had resources and was successfully deleted, invalidate
6743 # the whole resource cache. This is needed since the cache holds both
6744 # individual resources and the full listing which will also need to be
6745 # invalidated.
6746 if has_resources:
6747 await invalidate_resource_cache()
6749 db.commit()
6750 db.close()
6751 return {"status": "success", "message": f"Gateway {gateway_id} deleted"}
6752 except PermissionError as e:
6753 raise HTTPException(status_code=403, detail=str(e))
6754 except GatewayNotFoundError as e:
6755 raise HTTPException(status_code=404, detail=str(e))
6756 except GatewayError as e:
6757 raise HTTPException(status_code=400, detail=str(e))
6760@gateway_router.post("/{gateway_id}/tools/refresh", response_model=GatewayRefreshResponse)
6761@require_permission("gateways.update")
6762async def refresh_gateway_tools(
6763 gateway_id: str,
6764 request: Request,
6765 include_resources: bool = Query(False, description="Include resources in refresh"),
6766 include_prompts: bool = Query(False, description="Include prompts in refresh"),
6767 db: Session = Depends(get_db),
6768 user=Depends(get_current_user_with_permissions),
6769) -> GatewayRefreshResponse:
6770 """
6771 Manually trigger a refresh of tools/resources/prompts from a gateway's MCP server.
6773 This endpoint forces an immediate re-discovery of tools, resources, and prompts
6774 from the specified gateway. It returns counts of added, updated, and removed items,
6775 along with any validation errors encountered.
6777 Args:
6778 gateway_id: ID of the gateway to refresh.
6779 request: The FastAPI request object.
6780 include_resources: Whether to include resources in the refresh.
6781 include_prompts: Whether to include prompts in the refresh.
6782 db: Database session used to validate gateway access.
6783 user: Authenticated user.
6785 Returns:
6786 GatewayRefreshResponse with counts of changes and any validation errors.
6788 Raises:
6789 HTTPException: 404 if gateway not found, 409 if refresh already in progress.
6790 """
6791 logger.info(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested manual refresh for gateway {gateway_id}")
6792 try:
6793 await gateway_service.get_gateway(db, gateway_id)
6794 _enforce_scoped_resource_access(request, db, user, f"/gateways/{gateway_id}")
6796 user_email = user.get("email") if isinstance(user, dict) else str(user)
6797 result = await gateway_service.refresh_gateway_manually(
6798 gateway_id=gateway_id,
6799 include_resources=include_resources,
6800 include_prompts=include_prompts,
6801 user_email=user_email,
6802 request_headers=dict(request.headers),
6803 )
6804 return GatewayRefreshResponse(gateway_id=gateway_id, **result)
6805 except GatewayNotFoundError as e:
6806 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6807 except GatewayError as e:
6808 # 409 Conflict for concurrent refresh attempts
6809 raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
6812##############
6813# Root APIs #
6814##############
6815@root_router.get("", response_model=List[Root])
6816@root_router.get("/", response_model=List[Root])
6817@require_permission("admin.system_config")
6818async def list_roots(
6819 user=Depends(get_current_user_with_permissions),
6820) -> List[Root]:
6821 """
6822 Retrieve a list of all registered roots.
6824 Args:
6825 user: Authenticated user.
6827 Returns:
6828 List of Root objects.
6829 """
6830 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested list of roots")
6831 return await root_service.list_roots()
6834@root_router.get("/export", response_model=Dict[str, Any])
6835@require_permission("admin.system_config")
6836async def export_root(
6837 uri: str,
6838 user=Depends(get_current_user_with_permissions),
6839) -> Dict[str, Any]:
6840 """
6841 Export a single root configuration to JSON format.
6843 Args:
6844 uri: Root URI to export (query parameter)
6845 user: Authenticated user
6847 Returns:
6848 Export data containing root information
6850 Raises:
6851 HTTPException: If root not found or export fails
6852 """
6853 try:
6854 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested root export for URI: {uri}")
6856 # Extract username from user
6857 username: Optional[str] = None
6858 if hasattr(user, "email"):
6859 username = getattr(user, "email", None)
6860 elif isinstance(user, dict):
6861 username = user.get("email", None)
6862 else:
6863 username = None
6865 # Get the root by URI
6866 root = await root_service.get_root_by_uri(uri)
6868 # Create export data
6869 export_data = {
6870 "exported_at": datetime.now().isoformat(),
6871 "exported_by": username or "unknown",
6872 "export_type": "root",
6873 "version": "1.0",
6874 "root": {
6875 "uri": str(root.uri),
6876 "name": root.name,
6877 },
6878 }
6880 return export_data
6882 except RootServiceNotFoundError as e:
6883 logger.error(f"Root not found for export by user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
6884 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6885 except Exception as e:
6886 logger.error(f"Unexpected root export error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
6887 raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Root export failed: {str(e)}")
6890@root_router.get("/changes")
6891@require_permission("admin.system_config")
6892async def subscribe_roots_changes(
6893 user=Depends(get_current_user_with_permissions),
6894) -> StreamingResponse:
6895 """
6896 Subscribe to real-time changes in root list via Server-Sent Events (SSE).
6898 Args:
6899 user: Authenticated user.
6901 Returns:
6902 StreamingResponse with event-stream media type.
6903 """
6904 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' subscribed to root changes stream")
6906 async def generate_events():
6907 """Generate SSE-formatted events from root service changes.
6909 Yields:
6910 str: SSE-formatted event data.
6911 """
6912 async for event in root_service.subscribe_changes():
6913 yield f"data: {orjson.dumps(event).decode()}\n\n"
6915 return StreamingResponse(generate_events(), media_type="text/event-stream")
6918@root_router.get("/{root_uri:path}", response_model=Root)
6919@require_permission("admin.system_config")
6920async def get_root_by_uri(
6921 root_uri: str,
6922 user=Depends(get_current_user_with_permissions),
6923) -> Root:
6924 """
6925 Retrieve a specific root by its URI.
6927 Args:
6928 root_uri: URI of the root to retrieve.
6929 user: Authenticated user.
6931 Returns:
6932 Root object.
6934 Raises:
6935 HTTPException: If the root is not found.
6936 Exception: For any other unexpected errors.
6937 """
6938 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested root with URI: {root_uri}")
6939 try:
6940 root = await root_service.get_root_by_uri(root_uri)
6941 return root
6942 except RootServiceNotFoundError as e:
6943 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6944 except Exception as e:
6945 logger.error(f"Error getting root {root_uri}: {e}")
6946 raise e
6949@root_router.post("", response_model=Root)
6950@root_router.post("/", response_model=Root)
6951@require_permission("admin.system_config")
6952async def add_root(
6953 root: Root, # Accept JSON body using the Root model from models.py
6954 user=Depends(get_current_user_with_permissions),
6955) -> Root:
6956 """
6957 Add a new root.
6959 Args:
6960 root: Root object containing URI and name.
6961 user: Authenticated user.
6963 Returns:
6964 The added Root object.
6965 """
6966 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to add root: {root}")
6967 return await root_service.add_root(str(root.uri), root.name)
6970@root_router.put("/{root_uri:path}", response_model=Root)
6971@require_permission("admin.system_config")
6972async def update_root(
6973 root_uri: str,
6974 root: Root,
6975 user=Depends(get_current_user_with_permissions),
6976) -> Root:
6977 """
6978 Update a root by URI.
6980 Args:
6981 root_uri: URI of the root to update.
6982 root: Root object with updated information.
6983 user: Authenticated user.
6985 Returns:
6986 Updated Root object.
6988 Raises:
6989 HTTPException: If the root is not found.
6990 Exception: For any other unexpected errors.
6991 """
6992 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to update root with URI: {root_uri}")
6993 try:
6994 root = await root_service.update_root(root_uri, root.name)
6995 return root
6996 except RootServiceNotFoundError as e:
6997 raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e))
6998 except Exception as e:
6999 logger.error(f"Error updating root {root_uri}: {e}")
7000 raise e
7003@root_router.delete("/{uri:path}")
7004@require_permission("admin.system_config")
7005async def remove_root(
7006 uri: str,
7007 user=Depends(get_current_user_with_permissions),
7008) -> Dict[str, str]:
7009 """
7010 Remove a registered root by URI.
7012 Args:
7013 uri: URI of the root to remove.
7014 user: Authenticated user.
7016 Returns:
7017 Status message indicating result.
7018 """
7019 logger.debug(f"User '{SecurityValidator.sanitize_log_message(str(user))}' requested to remove root with URI: {uri}")
7020 await root_service.remove_root(uri)
7021 return {"status": "success", "message": f"Root {uri} removed"}
7024##################
7025# Utility Routes #
7026##################
7027@utility_router.post("/rpc/")
7028@utility_router.post("/rpc")
7029async def handle_rpc(request: Request, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)):
7030 """Handle authenticated public RPC requests.
7032 Args:
7033 request: Incoming public RPC request.
7034 db: Database session provided by dependency injection.
7035 user: Authenticated user payload with permissions.
7037 Returns:
7038 JSON-RPC response generated by the shared authenticated RPC dispatcher.
7039 """
7040 return await _handle_rpc_authenticated(request, db=db, user=user)
7043@utility_router.post("/_internal/mcp/authenticate/")
7044@utility_router.post("/_internal/mcp/authenticate")
7045async def handle_internal_mcp_authenticate(request: Request):
7046 """Authenticate a public MCP request for direct Rust ingress.
7048 Args:
7049 request: Trusted internal request sent by the local Rust runtime.
7051 Returns:
7052 Auth context payload that Rust can forward on subsequent internal MCP calls.
7054 Raises:
7055 HTTPException: If the request is not trusted or the forwarded payload is invalid.
7056 """
7057 if not _is_trusted_internal_mcp_runtime_request(request):
7058 raise HTTPException(status_code=403, detail="Internal MCP authenticate is only available to the local Rust runtime")
7060 payload = await request.json()
7061 if not isinstance(payload, dict):
7062 raise HTTPException(status_code=400, detail="Invalid internal MCP authenticate payload")
7064 method = str(payload.get("method") or "GET").upper()
7065 path = payload.get("path")
7066 query_string = payload.get("queryString", "")
7067 forwarded_headers = payload.get("headers", {})
7068 client_ip = payload.get("clientIp")
7070 if not isinstance(path, str) or not path:
7071 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload requires path")
7072 if not isinstance(query_string, str):
7073 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload queryString must be a string")
7074 if not isinstance(forwarded_headers, dict) or not all(isinstance(name, str) and isinstance(value, str) for name, value in forwarded_headers.items()):
7075 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload headers must be a string map")
7076 if client_ip is not None and not isinstance(client_ip, str):
7077 raise HTTPException(status_code=400, detail="Internal MCP authenticate payload clientIp must be a string")
7079 error_response, auth_context = await _run_internal_mcp_authentication(
7080 method=method,
7081 path=path,
7082 query_string=query_string,
7083 headers=forwarded_headers,
7084 client_ip=client_ip,
7085 )
7086 if error_response is not None:
7087 return error_response
7089 return ORJSONResponse(status_code=200, content={"authContext": auth_context})
7092@utility_router.post("/_internal/mcp/rpc/")
7093@utility_router.post("/_internal/mcp/rpc")
7094async def handle_internal_mcp_rpc(request: Request):
7095 """Handle trusted MCP dispatch forwarded from the local Rust runtime.
7097 Args:
7098 request: Trusted internal MCP request from the Rust runtime.
7100 Returns:
7101 JSON-RPC response from the shared authenticated RPC dispatcher.
7103 Raises:
7104 Exception: Propagated after rolling back the local database session.
7105 """
7106 user = _build_internal_mcp_forwarded_user(request)
7107 db = SessionLocal()
7108 try:
7109 response = await _handle_rpc_authenticated(request, db=db, user=user)
7110 if db.is_active and db.in_transaction() is not None:
7111 db.commit()
7112 return response
7113 except Exception:
7114 try:
7115 db.rollback()
7116 except Exception:
7117 try:
7118 db.invalidate()
7119 except Exception:
7120 pass # nosec B110 - Best effort cleanup on connection failure
7121 raise
7122 finally:
7123 db.close()
7126@utility_router.post("/_internal/mcp/initialize/")
7127@utility_router.post("/_internal/mcp/initialize")
7128async def handle_internal_mcp_initialize(request: Request):
7129 """Handle trusted MCP initialize requests forwarded from the local Rust runtime.
7131 Args:
7132 request: Trusted internal MCP initialize request.
7134 Returns:
7135 JSON-RPC initialize response payload.
7136 """
7137 user = _build_internal_mcp_forwarded_user(request)
7138 req_id = None
7139 try:
7140 try:
7141 body = orjson.loads(await request.body())
7142 except orjson.JSONDecodeError:
7143 return ORJSONResponse(
7144 status_code=400,
7145 content={
7146 "jsonrpc": "2.0",
7147 "error": {"code": -32700, "message": "Parse error"},
7148 "id": None,
7149 },
7150 )
7152 req_id = body.get("id")
7153 if req_id is None:
7154 req_id = str(uuid.uuid4())
7156 if body.get("method") != "initialize":
7157 return ORJSONResponse(
7158 status_code=400,
7159 content={
7160 "jsonrpc": "2.0",
7161 "error": {"code": -32600, "message": "Invalid Request"},
7162 "id": req_id,
7163 },
7164 )
7166 params = body.get("params", {})
7167 if not isinstance(params, dict):
7168 params = {}
7170 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7171 if server_id:
7172 _enforce_internal_mcp_server_scope(request, server_id)
7173 else:
7174 server_id = params.get("server_id")
7176 result = await _execute_rpc_initialize(
7177 request,
7178 user,
7179 params=params,
7180 server_id=server_id,
7181 mcp_session_id=request.headers.get("mcp-session-id") or request.headers.get("x-mcp-session-id"),
7182 )
7183 return ORJSONResponse(content={"jsonrpc": "2.0", "result": result, "id": req_id})
7184 except JSONRPCError as exc:
7185 error = exc.to_dict()
7186 return ORJSONResponse(content={"jsonrpc": "2.0", "error": error["error"], "id": req_id})
7187 except Exception as exc:
7188 logger.error("Internal MCP initialize error: %s", exc)
7189 return ORJSONResponse(
7190 content={
7191 "jsonrpc": "2.0",
7192 "error": {"code": -32000, "message": "Internal error", "data": str(exc)},
7193 "id": req_id,
7194 }
7195 )
7198@utility_router.delete("/_internal/mcp/session/")
7199@utility_router.delete("/_internal/mcp/session")
7200async def handle_internal_mcp_session_delete(request: Request):
7201 """Handle trusted MCP session teardown forwarded from the local Rust runtime.
7203 Args:
7204 request: Trusted internal MCP session-delete request.
7206 Returns:
7207 Empty HTTP response indicating the session was removed.
7208 """
7209 _build_internal_mcp_forwarded_user(request)
7210 auth_context = _get_internal_mcp_auth_context(request) or {}
7211 mcp_session_id = request.headers.get("mcp-session-id") or request.headers.get("x-mcp-session-id")
7212 if not mcp_session_id:
7213 return ORJSONResponse(status_code=400, content={"detail": "mcp-session-id header is required"})
7215 if auth_context.get("_rust_session_validated") is not True:
7216 session_allowed, deny_status, deny_detail = await _validate_streamable_session_access(
7217 mcp_session_id=mcp_session_id,
7218 user_context=auth_context,
7219 )
7220 if not session_allowed:
7221 return ORJSONResponse(status_code=deny_status, content={"detail": deny_detail})
7223 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7224 if server_id:
7225 _enforce_internal_mcp_server_scope(request, server_id)
7227 await session_registry.remove_session(mcp_session_id)
7229 if settings.mcpgateway_session_affinity_enabled:
7230 try:
7231 # First-Party
7232 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel
7234 pool = get_mcp_session_pool()
7235 await pool.cleanup_streamable_http_session_owner(mcp_session_id)
7236 except RuntimeError:
7237 pass
7239 return Response(status_code=204)
7242@utility_router.post("/_internal/mcp/notifications/initialized/")
7243@utility_router.post("/_internal/mcp/notifications/initialized")
7244async def handle_internal_mcp_notifications_initialized(request: Request):
7245 """Handle trusted MCP notifications/initialized requests from the local Rust runtime.
7247 Args:
7248 request: Trusted internal MCP notification request.
7250 Returns:
7251 Empty HTTP response acknowledging the notification.
7253 Raises:
7254 HTTPException: If trusted server-scope validation fails.
7255 """
7256 _build_internal_mcp_forwarded_user(request)
7257 req_id = None
7258 try:
7259 try:
7260 body = orjson.loads(await request.body())
7261 except orjson.JSONDecodeError:
7262 return ORJSONResponse(
7263 status_code=400,
7264 content={
7265 "jsonrpc": "2.0",
7266 "error": {"code": -32700, "message": "Parse error"},
7267 "id": None,
7268 },
7269 )
7271 req_id = body.get("id")
7272 if body.get("method") != "notifications/initialized":
7273 return ORJSONResponse(
7274 status_code=400,
7275 content={
7276 "jsonrpc": "2.0",
7277 "error": {"code": -32600, "message": "Invalid Request"},
7278 "id": req_id,
7279 },
7280 )
7282 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7283 if server_id:
7284 _enforce_internal_mcp_server_scope(request, server_id)
7286 logger.info("Client initialized")
7287 await logging_service.notify("Client initialized", LogLevel.INFO)
7288 return Response(status_code=status.HTTP_204_NO_CONTENT)
7289 except HTTPException:
7290 raise
7291 except Exception as exc:
7292 logger.error("Internal MCP notifications/initialized error: %s", exc)
7293 return ORJSONResponse(
7294 content={
7295 "jsonrpc": "2.0",
7296 "error": {"code": -32000, "message": "Internal error", "data": str(exc)},
7297 "id": req_id,
7298 }
7299 )
7302@utility_router.post("/_internal/mcp/notifications/message/")
7303@utility_router.post("/_internal/mcp/notifications/message")
7304async def handle_internal_mcp_notifications_message(request: Request):
7305 """Handle trusted MCP notifications/message requests from the local Rust runtime.
7307 Args:
7308 request: Trusted internal MCP notification request.
7310 Returns:
7311 Empty HTTP response acknowledging the notification.
7313 Raises:
7314 HTTPException: If trusted server-scope validation fails.
7315 """
7316 _build_internal_mcp_forwarded_user(request)
7317 req_id = None
7318 try:
7319 try:
7320 body = orjson.loads(await request.body())
7321 except orjson.JSONDecodeError:
7322 return ORJSONResponse(
7323 status_code=400,
7324 content={
7325 "jsonrpc": "2.0",
7326 "error": {"code": -32700, "message": "Parse error"},
7327 "id": None,
7328 },
7329 )
7331 req_id = body.get("id")
7332 if body.get("method") != "notifications/message":
7333 return ORJSONResponse(
7334 status_code=400,
7335 content={
7336 "jsonrpc": "2.0",
7337 "error": {"code": -32600, "message": "Invalid Request"},
7338 "id": req_id,
7339 },
7340 )
7342 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7343 if server_id:
7344 _enforce_internal_mcp_server_scope(request, server_id)
7346 params = body.get("params", {})
7347 if not isinstance(params, dict):
7348 params = {}
7350 await logging_service.notify(
7351 params.get("data"),
7352 LogLevel(params.get("level", "info")),
7353 params.get("logger"),
7354 )
7355 return Response(status_code=status.HTTP_204_NO_CONTENT)
7356 except HTTPException:
7357 raise
7358 except Exception as exc:
7359 logger.error("Internal MCP notifications/message error: %s", exc)
7360 return ORJSONResponse(
7361 content={
7362 "jsonrpc": "2.0",
7363 "error": {"code": -32000, "message": "Internal error", "data": str(exc)},
7364 "id": req_id,
7365 }
7366 )
7369@utility_router.post("/_internal/mcp/notifications/cancelled/")
7370@utility_router.post("/_internal/mcp/notifications/cancelled")
7371async def handle_internal_mcp_notifications_cancelled(request: Request):
7372 """Handle trusted MCP notifications/cancelled requests from the local Rust runtime.
7374 Args:
7375 request: Trusted internal MCP cancellation notification.
7377 Returns:
7378 Empty HTTP response acknowledging the cancellation.
7380 Raises:
7381 HTTPException: If cancellation authorization or trusted scope validation fails.
7382 """
7383 user = _build_internal_mcp_forwarded_user(request)
7384 req_id = None
7385 try:
7386 try:
7387 body = orjson.loads(await request.body())
7388 except orjson.JSONDecodeError:
7389 return ORJSONResponse(
7390 status_code=400,
7391 content={
7392 "jsonrpc": "2.0",
7393 "error": {"code": -32700, "message": "Parse error"},
7394 "id": None,
7395 },
7396 )
7398 req_id = body.get("id")
7399 if body.get("method") != "notifications/cancelled":
7400 return ORJSONResponse(
7401 status_code=400,
7402 content={
7403 "jsonrpc": "2.0",
7404 "error": {"code": -32600, "message": "Invalid Request"},
7405 "id": req_id,
7406 },
7407 )
7409 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7410 if server_id:
7411 _enforce_internal_mcp_server_scope(request, server_id)
7413 params = body.get("params", {})
7414 if not isinstance(params, dict):
7415 params = {}
7417 raw_request_id = params.get("requestId")
7418 request_id = str(raw_request_id) if raw_request_id is not None else None
7419 reason = params.get("reason")
7420 logger.info("Request cancelled: %s, reason: %s", request_id, reason)
7421 if request_id is not None:
7422 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=False)
7423 await cancellation_service.cancel_run(request_id, reason=reason)
7424 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO)
7425 return Response(status_code=status.HTTP_204_NO_CONTENT)
7426 except HTTPException:
7427 raise
7428 except Exception as exc:
7429 logger.error("Internal MCP notifications/cancelled error: %s", exc)
7430 return ORJSONResponse(
7431 content={
7432 "jsonrpc": "2.0",
7433 "error": {"code": -32000, "message": "Internal error", "data": str(exc)},
7434 "id": req_id,
7435 }
7436 )
7439@utility_router.post("/_internal/mcp/tools/list/")
7440@utility_router.post("/_internal/mcp/tools/list")
7441async def handle_internal_mcp_tools_list(request: Request):
7442 """Handle trusted server-scoped tools/list requests forwarded from the Rust runtime.
7444 Args:
7445 request: Trusted internal MCP tools/list request.
7447 Returns:
7448 MCP tools/list response payload for the requested virtual server.
7450 Raises:
7451 HTTPException: If the trusted server scope is missing or invalid.
7452 """
7453 server_id = request.headers.get("x-contextforge-server-id")
7454 if not server_id:
7455 raise HTTPException(status_code=400, detail="Missing trusted MCP server scope")
7457 db = SessionLocal()
7458 try:
7459 user = await _authorize_internal_mcp_request(
7460 request,
7461 db,
7462 permission="tools.read",
7463 method="tools/list",
7464 server_id=server_id,
7465 )
7466 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
7467 if is_admin and token_teams is None:
7468 user_email = None
7469 token_teams = None
7470 elif token_teams is None:
7471 token_teams = []
7473 tools = await tool_service.list_server_mcp_tool_definitions(
7474 db,
7475 server_id,
7476 user_email=user_email,
7477 token_teams=token_teams,
7478 )
7479 return ORJSONResponse(content={"tools": tools})
7480 except HTTPException:
7481 try:
7482 db.rollback()
7483 except Exception:
7484 try:
7485 db.invalidate()
7486 except Exception:
7487 pass # nosec B110 - Best effort cleanup on connection failure
7488 raise
7489 except JSONRPCError as exc:
7490 return ORJSONResponse(status_code=403, content={"code": exc.code, "message": exc.message, "data": exc.data})
7491 except Exception as exc:
7492 try:
7493 db.rollback()
7494 except Exception:
7495 try:
7496 db.invalidate()
7497 except Exception:
7498 pass # nosec B110 - Best effort cleanup on connection failure
7499 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
7500 finally:
7501 db.close()
7504@utility_router.post("/_internal/mcp/resources/list/")
7505@utility_router.post("/_internal/mcp/resources/list")
7506async def handle_internal_mcp_resources_list(request: Request):
7507 """Handle trusted resources/list requests forwarded from the Rust runtime.
7509 Args:
7510 request: Trusted internal MCP resources/list request.
7512 Returns:
7513 MCP resources/list response payload.
7514 """
7515 db = SessionLocal()
7516 req_id = None
7517 try:
7518 user = _build_internal_mcp_forwarded_user(request)
7519 try:
7520 body = orjson.loads(await request.body())
7521 except orjson.JSONDecodeError:
7522 return ORJSONResponse(
7523 status_code=400,
7524 content={
7525 "jsonrpc": "2.0",
7526 "error": {"code": -32700, "message": "Parse error"},
7527 "id": None,
7528 },
7529 )
7531 req_id = body.get("id") if isinstance(body, dict) else None
7532 if not isinstance(body, dict) or body.get("method") != "resources/list":
7533 return ORJSONResponse(
7534 status_code=400,
7535 content={
7536 "jsonrpc": "2.0",
7537 "error": {"code": -32600, "message": "Invalid Request"},
7538 "id": req_id,
7539 },
7540 )
7542 params = body.get("params", {})
7543 if not isinstance(params, dict):
7544 params = {}
7546 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7547 if server_id:
7548 _enforce_internal_mcp_server_scope(request, server_id)
7549 else:
7550 server_id = params.get("server_id")
7551 cursor = params.get("cursor")
7553 await _authorize_internal_mcp_request(
7554 request,
7555 db,
7556 permission="resources.read",
7557 method="resources/list",
7558 server_id=server_id,
7559 )
7561 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
7562 if is_admin and token_teams is None:
7563 user_email = None
7564 token_teams = None
7565 elif token_teams is None:
7566 token_teams = []
7568 if server_id:
7569 resources = await resource_service.list_server_resources(
7570 db,
7571 server_id,
7572 user_email=user_email,
7573 token_teams=token_teams,
7574 )
7575 payload = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]}
7576 else:
7577 resources, next_cursor = await resource_service.list_resources(
7578 db,
7579 cursor=cursor,
7580 limit=0,
7581 user_email=user_email,
7582 token_teams=token_teams,
7583 )
7584 payload = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]}
7585 if next_cursor:
7586 payload["nextCursor"] = next_cursor
7588 if db.is_active and db.in_transaction() is not None:
7589 db.commit()
7590 return ORJSONResponse(content=payload)
7591 except JSONRPCError as exc:
7592 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
7593 except Exception as exc:
7594 try:
7595 db.rollback()
7596 except Exception:
7597 try:
7598 db.invalidate()
7599 except Exception:
7600 pass # nosec B110 - Best effort cleanup on connection failure
7601 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
7602 finally:
7603 db.close()
7606@utility_router.post("/_internal/mcp/resources/read/")
7607@utility_router.post("/_internal/mcp/resources/read")
7608async def handle_internal_mcp_resources_read(request: Request):
7609 """Handle trusted resources/read requests forwarded from the Rust runtime.
7611 Args:
7612 request: Trusted internal MCP resources/read request.
7614 Returns:
7615 MCP resources/read response payload.
7616 """
7617 db = SessionLocal()
7618 req_id = None
7619 uri = None
7620 try:
7621 user = _build_internal_mcp_forwarded_user(request)
7622 try:
7623 body = orjson.loads(await request.body())
7624 except orjson.JSONDecodeError:
7625 return ORJSONResponse(
7626 status_code=400,
7627 content={
7628 "jsonrpc": "2.0",
7629 "error": {"code": -32700, "message": "Parse error"},
7630 "id": None,
7631 },
7632 )
7634 req_id = body.get("id") if isinstance(body, dict) else None
7635 if not isinstance(body, dict) or body.get("method") != "resources/read":
7636 return ORJSONResponse(
7637 status_code=400,
7638 content={
7639 "jsonrpc": "2.0",
7640 "error": {"code": -32600, "message": "Invalid Request"},
7641 "id": req_id,
7642 },
7643 )
7645 params = body.get("params", {})
7646 if not isinstance(params, dict):
7647 params = {}
7649 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7650 if server_id:
7651 _enforce_internal_mcp_server_scope(request, server_id)
7652 else:
7653 server_id = params.get("server_id")
7655 await _authorize_internal_mcp_request(
7656 request,
7657 db,
7658 permission="resources.read",
7659 method="resources/read",
7660 server_id=server_id,
7661 )
7663 uri = params.get("uri")
7664 request_id = params.get("requestId")
7665 meta_data = params.get("_meta")
7666 if not uri:
7667 return ORJSONResponse(
7668 status_code=400,
7669 content={
7670 "code": -32602,
7671 "message": "Missing resource URI in parameters",
7672 "data": params,
7673 },
7674 )
7676 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
7677 if auth_is_admin and auth_token_teams is None:
7678 auth_user_email = None
7679 elif auth_token_teams is None:
7680 auth_token_teams = []
7682 plugin_context_table = getattr(request.state, "plugin_context_table", None)
7683 plugin_global_context = getattr(request.state, "plugin_global_context", None)
7684 result = await resource_service.read_resource(
7685 db,
7686 resource_uri=uri,
7687 request_id=request_id,
7688 user=auth_user_email,
7689 server_id=server_id,
7690 token_teams=auth_token_teams,
7691 plugin_context_table=plugin_context_table,
7692 plugin_global_context=plugin_global_context,
7693 meta_data=meta_data,
7694 )
7695 # First-Party
7696 from mcpgateway.common.models import ResourceContent # pylint: disable=import-outside-toplevel
7698 if isinstance(result, ResourceContent):
7699 normalized_content = {"uri": result.uri}
7700 if result.mime_type:
7701 normalized_content["mimeType"] = result.mime_type
7702 if result.text is not None:
7703 normalized_content["text"] = result.text
7704 elif result.blob is not None:
7705 normalized_content["blob"] = base64.b64encode(result.blob).decode("ascii")
7706 payload = {"contents": [normalized_content]}
7707 elif hasattr(result, "model_dump"):
7708 payload = {"contents": [result.model_dump(by_alias=True, exclude_none=True)]}
7709 else:
7710 payload = {"contents": [result]}
7712 if db.is_active and db.in_transaction() is not None:
7713 db.commit()
7714 return ORJSONResponse(content=payload)
7715 except ResourceNotFoundError as exc:
7716 return ORJSONResponse(
7717 status_code=404,
7718 content={
7719 "code": -32002,
7720 "message": str(exc),
7721 "data": {"uri": uri} if uri else None,
7722 },
7723 )
7724 except ResourceError as exc:
7725 return ORJSONResponse(
7726 status_code=400,
7727 content={
7728 "code": -32602,
7729 "message": str(exc),
7730 "data": {"uri": uri} if uri else None,
7731 },
7732 )
7733 except JSONRPCError as exc:
7734 status_code = 403 if exc.code == -32003 else 400
7735 return ORJSONResponse(status_code=status_code, content=exc.to_dict()["error"])
7736 except Exception as exc:
7737 try:
7738 db.rollback()
7739 except Exception:
7740 try:
7741 db.invalidate()
7742 except Exception:
7743 pass # nosec B110 - Best effort cleanup on connection failure
7744 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
7745 finally:
7746 db.close()
7749@utility_router.post("/_internal/mcp/resources/subscribe/")
7750@utility_router.post("/_internal/mcp/resources/subscribe")
7751async def handle_internal_mcp_resources_subscribe(request: Request):
7752 """Handle trusted resources/subscribe requests forwarded from the Rust runtime.
7754 Args:
7755 request: Trusted internal MCP resources/subscribe request.
7757 Returns:
7758 Empty JSON response confirming the subscription.
7759 """
7760 db = SessionLocal()
7761 req_id = None
7762 try:
7763 user = _build_internal_mcp_forwarded_user(request)
7764 try:
7765 body = orjson.loads(await request.body())
7766 except orjson.JSONDecodeError:
7767 return ORJSONResponse(
7768 status_code=400,
7769 content={
7770 "jsonrpc": "2.0",
7771 "error": {"code": -32700, "message": "Parse error"},
7772 "id": None,
7773 },
7774 )
7776 req_id = body.get("id") if isinstance(body, dict) else None
7777 if not isinstance(body, dict) or body.get("method") != "resources/subscribe":
7778 return ORJSONResponse(
7779 status_code=400,
7780 content={
7781 "jsonrpc": "2.0",
7782 "error": {"code": -32600, "message": "Invalid Request"},
7783 "id": req_id,
7784 },
7785 )
7787 params = body.get("params", {})
7788 if not isinstance(params, dict):
7789 params = {}
7791 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7792 if server_id:
7793 _enforce_internal_mcp_server_scope(request, server_id)
7795 await _authorize_internal_mcp_request(
7796 request,
7797 db,
7798 permission="resources.read",
7799 method="resources/subscribe",
7800 server_id=server_id,
7801 )
7803 uri = params.get("uri")
7804 if not uri:
7805 return ORJSONResponse(
7806 status_code=400,
7807 content={
7808 "code": -32602,
7809 "message": "Missing resource URI in parameters",
7810 "data": params,
7811 },
7812 )
7814 access_user_email, access_token_teams = _get_scoped_resource_access_context(request, user)
7815 user_email = get_user_email(user)
7816 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email)
7817 await resource_service.subscribe_resource(
7818 db,
7819 subscription,
7820 user_email=access_user_email,
7821 token_teams=access_token_teams,
7822 )
7823 if db.is_active and db.in_transaction() is not None:
7824 db.commit()
7825 return ORJSONResponse(content={})
7826 except ResourceNotFoundError as exc:
7827 return ORJSONResponse(
7828 status_code=404,
7829 content={"code": -32002, "message": str(exc), "data": None},
7830 )
7831 except PermissionError:
7832 return ORJSONResponse(
7833 status_code=403,
7834 content={"code": -32003, "message": _ACCESS_DENIED_MSG, "data": {"method": "resources/subscribe"}},
7835 )
7836 except JSONRPCError as exc:
7837 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
7838 except Exception as exc:
7839 try:
7840 db.rollback()
7841 except Exception:
7842 try:
7843 db.invalidate()
7844 except Exception:
7845 pass # nosec B110 - Best effort cleanup on connection failure
7846 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
7847 finally:
7848 db.close()
7851@utility_router.post("/_internal/mcp/resources/unsubscribe/")
7852@utility_router.post("/_internal/mcp/resources/unsubscribe")
7853async def handle_internal_mcp_resources_unsubscribe(request: Request):
7854 """Handle trusted resources/unsubscribe requests forwarded from the Rust runtime.
7856 Args:
7857 request: Trusted internal MCP resources/unsubscribe request.
7859 Returns:
7860 Empty JSON response confirming the unsubscription.
7861 """
7862 db = SessionLocal()
7863 req_id = None
7864 try:
7865 user = _build_internal_mcp_forwarded_user(request)
7866 try:
7867 body = orjson.loads(await request.body())
7868 except orjson.JSONDecodeError:
7869 return ORJSONResponse(
7870 status_code=400,
7871 content={
7872 "jsonrpc": "2.0",
7873 "error": {"code": -32700, "message": "Parse error"},
7874 "id": None,
7875 },
7876 )
7878 req_id = body.get("id") if isinstance(body, dict) else None
7879 if not isinstance(body, dict) or body.get("method") != "resources/unsubscribe":
7880 return ORJSONResponse(
7881 status_code=400,
7882 content={
7883 "jsonrpc": "2.0",
7884 "error": {"code": -32600, "message": "Invalid Request"},
7885 "id": req_id,
7886 },
7887 )
7889 params = body.get("params", {})
7890 if not isinstance(params, dict):
7891 params = {}
7893 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7894 if server_id:
7895 _enforce_internal_mcp_server_scope(request, server_id)
7897 await _authorize_internal_mcp_request(
7898 request,
7899 db,
7900 permission="resources.read",
7901 method="resources/unsubscribe",
7902 server_id=server_id,
7903 )
7905 uri = params.get("uri")
7906 if not uri:
7907 return ORJSONResponse(
7908 status_code=400,
7909 content={
7910 "code": -32602,
7911 "message": "Missing resource URI in parameters",
7912 "data": params,
7913 },
7914 )
7916 user_email = get_user_email(user)
7917 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email)
7918 await resource_service.unsubscribe_resource(db, subscription)
7919 if db.is_active and db.in_transaction() is not None:
7920 db.commit()
7921 return ORJSONResponse(content={})
7922 except JSONRPCError as exc:
7923 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
7924 except Exception as exc:
7925 try:
7926 db.rollback()
7927 except Exception:
7928 try:
7929 db.invalidate()
7930 except Exception:
7931 pass # nosec B110 - Best effort cleanup on connection failure
7932 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
7933 finally:
7934 db.close()
7937@utility_router.post("/_internal/mcp/resources/templates/list/")
7938@utility_router.post("/_internal/mcp/resources/templates/list")
7939async def handle_internal_mcp_resource_templates_list(request: Request):
7940 """Handle trusted resources/templates/list requests forwarded from the Rust runtime.
7942 Args:
7943 request: Trusted internal MCP resources/templates/list request.
7945 Returns:
7946 MCP resources/templates/list response payload.
7948 Raises:
7949 Exception: Propagated after best-effort rollback when unexpected failures occur.
7950 """
7951 db = SessionLocal()
7952 req_id = None
7953 try:
7954 user = _build_internal_mcp_forwarded_user(request)
7955 try:
7956 body = orjson.loads(await request.body())
7957 except orjson.JSONDecodeError:
7958 return ORJSONResponse(
7959 status_code=400,
7960 content={
7961 "jsonrpc": "2.0",
7962 "error": {"code": -32700, "message": "Parse error"},
7963 "id": None,
7964 },
7965 )
7967 req_id = body.get("id") if isinstance(body, dict) else None
7968 if not isinstance(body, dict) or body.get("method") != "resources/templates/list":
7969 return ORJSONResponse(
7970 status_code=400,
7971 content={
7972 "jsonrpc": "2.0",
7973 "error": {"code": -32600, "message": "Invalid Request"},
7974 "id": req_id,
7975 },
7976 )
7978 params = body.get("params", {})
7979 if not isinstance(params, dict):
7980 params = {}
7982 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
7983 if server_id:
7984 _enforce_internal_mcp_server_scope(request, server_id)
7985 else:
7986 server_id = params.get("server_id")
7988 await _authorize_internal_mcp_request(
7989 request,
7990 db,
7991 permission="resources.read",
7992 method="resources/templates/list",
7993 server_id=server_id,
7994 )
7996 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
7997 if is_admin and token_teams is None:
7998 token_teams = None
7999 elif token_teams is None:
8000 token_teams = []
8002 resource_templates = await resource_service.list_resource_templates(
8003 db,
8004 user_email=user_email,
8005 token_teams=token_teams,
8006 server_id=server_id,
8007 )
8008 payload = {"resourceTemplates": [rt.model_dump(by_alias=True, exclude_none=True) for rt in resource_templates]}
8010 if db.is_active and db.in_transaction() is not None:
8011 db.commit()
8012 return ORJSONResponse(content=payload)
8013 except JSONRPCError as exc:
8014 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
8015 except Exception:
8016 try:
8017 db.rollback()
8018 except Exception:
8019 try:
8020 db.invalidate()
8021 except Exception:
8022 pass # nosec B110 - Best effort cleanup on connection failure
8023 raise
8024 finally:
8025 db.close()
8028@utility_router.post("/_internal/mcp/roots/list/")
8029@utility_router.post("/_internal/mcp/roots/list")
8030async def handle_internal_mcp_roots_list(request: Request):
8031 """Handle trusted roots/list requests forwarded from the Rust runtime.
8033 Args:
8034 request: Trusted internal MCP roots/list request.
8036 Returns:
8037 MCP roots/list response payload.
8039 Raises:
8040 Exception: Propagated after best-effort rollback when unexpected failures occur.
8041 """
8042 db = SessionLocal()
8043 req_id = None
8044 try:
8045 _build_internal_mcp_forwarded_user(request)
8046 try:
8047 body = orjson.loads(await request.body())
8048 except orjson.JSONDecodeError:
8049 return ORJSONResponse(
8050 status_code=400,
8051 content={
8052 "jsonrpc": "2.0",
8053 "error": {"code": -32700, "message": "Parse error"},
8054 "id": None,
8055 },
8056 )
8058 req_id = body.get("id") if isinstance(body, dict) else None
8059 if not isinstance(body, dict) or body.get("method") != "roots/list":
8060 return ORJSONResponse(
8061 status_code=400,
8062 content={
8063 "jsonrpc": "2.0",
8064 "error": {"code": -32600, "message": "Invalid Request"},
8065 "id": req_id,
8066 },
8067 )
8069 await _authorize_internal_mcp_request(
8070 request,
8071 db,
8072 permission="admin.system_config",
8073 method="roots/list",
8074 server_id=None,
8075 )
8076 roots = await root_service.list_roots()
8077 payload = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]}
8078 if db.is_active and db.in_transaction() is not None:
8079 db.commit()
8080 return ORJSONResponse(content=payload)
8081 except JSONRPCError as exc:
8082 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
8083 except Exception:
8084 try:
8085 db.rollback()
8086 except Exception:
8087 try:
8088 db.invalidate()
8089 except Exception:
8090 pass # nosec B110 - Best effort cleanup on connection failure
8091 raise
8092 finally:
8093 db.close()
8096@utility_router.post("/_internal/mcp/completion/complete/")
8097@utility_router.post("/_internal/mcp/completion/complete")
8098async def handle_internal_mcp_completion_complete(request: Request):
8099 """Handle trusted completion/complete requests forwarded from the Rust runtime.
8101 Args:
8102 request: Trusted internal MCP completion/complete request.
8104 Returns:
8105 MCP completion response payload.
8106 """
8107 db = SessionLocal()
8108 req_id = None
8109 try:
8110 user = _build_internal_mcp_forwarded_user(request)
8111 try:
8112 body = orjson.loads(await request.body())
8113 except orjson.JSONDecodeError:
8114 return ORJSONResponse(
8115 status_code=400,
8116 content={
8117 "jsonrpc": "2.0",
8118 "error": {"code": -32700, "message": "Parse error"},
8119 "id": None,
8120 },
8121 )
8123 req_id = body.get("id") if isinstance(body, dict) else None
8124 if not isinstance(body, dict) or body.get("method") != "completion/complete":
8125 return ORJSONResponse(
8126 status_code=400,
8127 content={
8128 "jsonrpc": "2.0",
8129 "error": {"code": -32600, "message": "Invalid Request"},
8130 "id": req_id,
8131 },
8132 )
8134 params = body.get("params", {})
8135 if not isinstance(params, dict):
8136 params = {}
8138 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
8139 if server_id:
8140 _enforce_internal_mcp_server_scope(request, server_id)
8141 else:
8142 server_id = params.get("server_id")
8144 await _authorize_internal_mcp_request(
8145 request,
8146 db,
8147 permission="tools.read",
8148 method="completion/complete",
8149 server_id=server_id,
8150 )
8152 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
8153 if is_admin and token_teams is None:
8154 user_email = None
8155 token_teams = None
8156 elif token_teams is None:
8157 token_teams = []
8159 payload = await completion_service.handle_completion(
8160 db,
8161 params,
8162 user_email=user_email,
8163 token_teams=token_teams,
8164 )
8165 if db.is_active and db.in_transaction() is not None:
8166 db.commit()
8167 return ORJSONResponse(content=payload)
8168 except JSONRPCError as exc:
8169 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
8170 except Exception as exc:
8171 try:
8172 db.rollback()
8173 except Exception:
8174 try:
8175 db.invalidate()
8176 except Exception:
8177 pass # nosec B110 - Best effort cleanup on connection failure
8178 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
8179 finally:
8180 db.close()
8183@utility_router.post("/_internal/mcp/sampling/createMessage/")
8184@utility_router.post("/_internal/mcp/sampling/createMessage")
8185async def handle_internal_mcp_sampling_create_message(request: Request):
8186 """Handle trusted sampling/createMessage requests forwarded from the Rust runtime.
8188 Args:
8189 request: Trusted internal MCP sampling/createMessage request.
8191 Returns:
8192 MCP sampling/createMessage response payload.
8193 """
8194 db = SessionLocal()
8195 req_id = None
8196 try:
8197 _build_internal_mcp_forwarded_user(request)
8198 try:
8199 body = orjson.loads(await request.body())
8200 except orjson.JSONDecodeError:
8201 return ORJSONResponse(
8202 status_code=400,
8203 content={
8204 "jsonrpc": "2.0",
8205 "error": {"code": -32700, "message": "Parse error"},
8206 "id": None,
8207 },
8208 )
8210 req_id = body.get("id") if isinstance(body, dict) else None
8211 if not isinstance(body, dict) or body.get("method") != "sampling/createMessage":
8212 return ORJSONResponse(
8213 status_code=400,
8214 content={
8215 "jsonrpc": "2.0",
8216 "error": {"code": -32600, "message": "Invalid Request"},
8217 "id": req_id,
8218 },
8219 )
8221 if request.headers.get("x-contextforge-mcp-runtime") == "rust":
8222 server_id = request.headers.get("x-contextforge-server-id")
8223 if server_id:
8224 _enforce_internal_mcp_server_scope(request, server_id)
8226 params = body.get("params", {})
8227 if not isinstance(params, dict):
8228 params = {}
8230 payload = await sampling_handler.create_message(db, params)
8231 if db.is_active and db.in_transaction() is not None:
8232 db.commit()
8233 return ORJSONResponse(content=payload)
8234 except JSONRPCError as exc:
8235 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
8236 except Exception as exc:
8237 try:
8238 db.rollback()
8239 except Exception:
8240 try:
8241 db.invalidate()
8242 except Exception:
8243 pass # nosec B110 - Best effort cleanup on connection failure
8244 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
8245 finally:
8246 db.close()
8249@utility_router.post("/_internal/mcp/logging/setLevel/")
8250@utility_router.post("/_internal/mcp/logging/setLevel")
8251async def handle_internal_mcp_logging_set_level(request: Request):
8252 """Handle trusted logging/setLevel requests forwarded from the Rust runtime.
8254 Args:
8255 request: Trusted internal MCP logging/setLevel request.
8257 Returns:
8258 Empty JSON response confirming the new log level.
8259 """
8260 db = SessionLocal()
8261 req_id = None
8262 try:
8263 _build_internal_mcp_forwarded_user(request)
8264 try:
8265 body = orjson.loads(await request.body())
8266 except orjson.JSONDecodeError:
8267 return ORJSONResponse(
8268 status_code=400,
8269 content={
8270 "jsonrpc": "2.0",
8271 "error": {"code": -32700, "message": "Parse error"},
8272 "id": None,
8273 },
8274 )
8276 req_id = body.get("id") if isinstance(body, dict) else None
8277 if not isinstance(body, dict) or body.get("method") != "logging/setLevel":
8278 return ORJSONResponse(
8279 status_code=400,
8280 content={
8281 "jsonrpc": "2.0",
8282 "error": {"code": -32600, "message": "Invalid Request"},
8283 "id": req_id,
8284 },
8285 )
8287 await _authorize_internal_mcp_request(
8288 request,
8289 db,
8290 permission="admin.system_config",
8291 method="logging/setLevel",
8292 server_id=None,
8293 )
8295 params = body.get("params", {})
8296 if not isinstance(params, dict):
8297 params = {}
8299 level = LogLevel(params.get("level"))
8300 await logging_service.set_level(level)
8301 if db.is_active and db.in_transaction() is not None:
8302 db.commit()
8303 return ORJSONResponse(content={})
8304 except JSONRPCError as exc:
8305 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
8306 except Exception as exc:
8307 try:
8308 db.rollback()
8309 except Exception:
8310 try:
8311 db.invalidate()
8312 except Exception:
8313 pass # nosec B110 - Best effort cleanup on connection failure
8314 return ORJSONResponse(status_code=500, content={"code": -32000, "message": "Internal error", "data": str(exc)})
8315 finally:
8316 db.close()
8319@utility_router.post("/_internal/mcp/prompts/list/")
8320@utility_router.post("/_internal/mcp/prompts/list")
8321async def handle_internal_mcp_prompts_list(request: Request):
8322 """Handle trusted prompts/list requests forwarded from the Rust runtime.
8324 Args:
8325 request: Trusted internal MCP prompts/list request.
8327 Returns:
8328 MCP prompts/list response payload.
8330 Raises:
8331 Exception: Propagated after best-effort rollback when unexpected failures occur.
8332 """
8333 db = SessionLocal()
8334 req_id = None
8335 try:
8336 user = _build_internal_mcp_forwarded_user(request)
8337 try:
8338 body = orjson.loads(await request.body())
8339 except orjson.JSONDecodeError:
8340 return ORJSONResponse(
8341 status_code=400,
8342 content={
8343 "jsonrpc": "2.0",
8344 "error": {"code": -32700, "message": "Parse error"},
8345 "id": None,
8346 },
8347 )
8349 req_id = body.get("id") if isinstance(body, dict) else None
8350 if not isinstance(body, dict) or body.get("method") != "prompts/list":
8351 return ORJSONResponse(
8352 status_code=400,
8353 content={
8354 "jsonrpc": "2.0",
8355 "error": {"code": -32600, "message": "Invalid Request"},
8356 "id": req_id,
8357 },
8358 )
8360 params = body.get("params", {})
8361 if not isinstance(params, dict):
8362 params = {}
8364 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
8365 if server_id:
8366 _enforce_internal_mcp_server_scope(request, server_id)
8367 else:
8368 server_id = params.get("server_id")
8369 cursor = params.get("cursor")
8371 await _authorize_internal_mcp_request(
8372 request,
8373 db,
8374 permission="prompts.read",
8375 method="prompts/list",
8376 server_id=server_id,
8377 )
8379 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
8380 if is_admin and token_teams is None:
8381 user_email = None
8382 token_teams = None
8383 elif token_teams is None:
8384 token_teams = []
8386 if server_id:
8387 prompts = await prompt_service.list_server_prompts(
8388 db,
8389 server_id,
8390 cursor=cursor,
8391 user_email=user_email,
8392 token_teams=token_teams,
8393 )
8394 payload = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]}
8395 else:
8396 prompts, next_cursor = await prompt_service.list_prompts(
8397 db,
8398 cursor=cursor,
8399 limit=0,
8400 user_email=user_email,
8401 token_teams=token_teams,
8402 )
8403 payload = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]}
8404 if next_cursor:
8405 payload["nextCursor"] = next_cursor
8407 if db.is_active and db.in_transaction() is not None:
8408 db.commit()
8409 return ORJSONResponse(content=payload)
8410 except JSONRPCError as exc:
8411 return ORJSONResponse(status_code=403, content=exc.to_dict()["error"])
8412 except Exception:
8413 try:
8414 db.rollback()
8415 except Exception:
8416 try:
8417 db.invalidate()
8418 except Exception:
8419 pass # nosec B110 - Best effort cleanup on connection failure
8420 raise
8421 finally:
8422 db.close()
8425@utility_router.post("/_internal/mcp/prompts/get/")
8426@utility_router.post("/_internal/mcp/prompts/get")
8427async def handle_internal_mcp_prompts_get(request: Request):
8428 """Handle trusted prompts/get requests forwarded from the Rust runtime.
8430 Args:
8431 request: Trusted internal MCP prompts/get request.
8433 Returns:
8434 MCP prompts/get response payload.
8436 Raises:
8437 Exception: Propagated after best-effort rollback when unexpected failures occur.
8438 """
8439 db = SessionLocal()
8440 req_id = None
8441 name = None
8442 try:
8443 user = _build_internal_mcp_forwarded_user(request)
8444 try:
8445 body = orjson.loads(await request.body())
8446 except orjson.JSONDecodeError:
8447 return ORJSONResponse(
8448 status_code=400,
8449 content={
8450 "jsonrpc": "2.0",
8451 "error": {"code": -32700, "message": "Parse error"},
8452 "id": None,
8453 },
8454 )
8456 req_id = body.get("id") if isinstance(body, dict) else None
8457 if not isinstance(body, dict) or body.get("method") != "prompts/get":
8458 return ORJSONResponse(
8459 status_code=400,
8460 content={
8461 "jsonrpc": "2.0",
8462 "error": {"code": -32600, "message": "Invalid Request"},
8463 "id": req_id,
8464 },
8465 )
8467 params = body.get("params", {})
8468 if not isinstance(params, dict):
8469 params = {}
8471 server_id = request.headers.get("x-contextforge-server-id") if request.headers.get("x-contextforge-mcp-runtime") == "rust" else None
8472 if server_id:
8473 _enforce_internal_mcp_server_scope(request, server_id)
8474 else:
8475 server_id = params.get("server_id")
8477 await _authorize_internal_mcp_request(
8478 request,
8479 db,
8480 permission="prompts.read",
8481 method="prompts/get",
8482 server_id=server_id,
8483 )
8485 name = params.get("name")
8486 arguments = params.get("arguments", {})
8487 meta_data = params.get("_meta")
8488 if not name:
8489 return ORJSONResponse(
8490 status_code=400,
8491 content={
8492 "code": -32602,
8493 "message": "Missing prompt name in parameters",
8494 "data": params,
8495 },
8496 )
8498 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
8499 if auth_is_admin and auth_token_teams is None:
8500 auth_user_email = None
8501 elif auth_token_teams is None:
8502 auth_token_teams = []
8504 plugin_context_table = getattr(request.state, "plugin_context_table", None)
8505 plugin_global_context = getattr(request.state, "plugin_global_context", None)
8506 result = await prompt_service.get_prompt(
8507 db,
8508 name,
8509 arguments,
8510 user=auth_user_email,
8511 server_id=server_id,
8512 token_teams=auth_token_teams,
8513 plugin_context_table=plugin_context_table,
8514 plugin_global_context=plugin_global_context,
8515 _meta_data=meta_data,
8516 )
8517 payload = result.model_dump(by_alias=True, exclude_none=True) if hasattr(result, "model_dump") else result
8519 if db.is_active and db.in_transaction() is not None:
8520 db.commit()
8521 return ORJSONResponse(content=payload)
8522 except PromptNotFoundError as exc:
8523 return ORJSONResponse(
8524 status_code=404,
8525 content={
8526 "code": -32002,
8527 "message": str(exc),
8528 "data": {"name": name} if name else None,
8529 },
8530 )
8531 except PromptError as exc:
8532 try:
8533 if db.is_active and db.in_transaction() is not None:
8534 db.rollback()
8535 except Exception:
8536 try:
8537 db.invalidate()
8538 except Exception:
8539 pass # nosec B110 - Best effort cleanup on connection failure
8540 return ORJSONResponse(
8541 status_code=422,
8542 content={
8543 "code": -32000,
8544 "message": str(exc),
8545 "data": {"name": name} if name else None,
8546 },
8547 )
8548 except JSONRPCError as exc:
8549 status_code = 403 if exc.code == -32003 else 400
8550 return ORJSONResponse(status_code=status_code, content=exc.to_dict()["error"])
8551 except Exception:
8552 try:
8553 db.rollback()
8554 except Exception:
8555 try:
8556 db.invalidate()
8557 except Exception:
8558 pass # nosec B110 - Best effort cleanup on connection failure
8559 raise
8560 finally:
8561 db.close()
8564@utility_router.post("/_internal/mcp/tools/list/authz/")
8565@utility_router.post("/_internal/mcp/tools/list/authz")
8566async def handle_internal_mcp_tools_list_authz(request: Request):
8567 """Authorize trusted server-scoped tools/list requests for the Rust direct-DB path.
8569 Args:
8570 request: Trusted internal MCP authz request.
8572 Returns:
8573 Empty success response when the request is authorized.
8574 """
8575 return await _authorize_internal_mcp_server_scoped_method(
8576 request,
8577 permission="tools.read",
8578 method="tools/list",
8579 )
8582async def _authorize_internal_mcp_server_scoped_method(
8583 request: Request,
8584 *,
8585 permission: str,
8586 method: str,
8587) -> Response:
8588 """Authorize a trusted server-scoped MCP method for Rust direct-path execution.
8590 Args:
8591 request: Trusted internal MCP authz request.
8592 permission: Permission required for the target method.
8593 method: MCP method name being authorized.
8595 Returns:
8596 Empty success response when the method is authorized and remains eligible
8597 for Rust direct execution, or a JSON success payload instructing Rust to
8598 forward the request to Python when plugin hooks require Python
8599 execution. Returns a JSON error response when authorization fails.
8601 Raises:
8602 HTTPException: If the trusted server scope header is missing.
8603 Exception: Propagated after best-effort rollback when unexpected failures occur.
8604 """
8605 server_id = request.headers.get("x-contextforge-server-id")
8606 if not server_id:
8607 raise HTTPException(status_code=400, detail="Missing trusted MCP server scope")
8609 db = SessionLocal()
8610 try:
8611 await _authorize_internal_mcp_request(
8612 request,
8613 db,
8614 permission=permission,
8615 method=method,
8616 server_id=server_id,
8617 )
8618 if db.is_active and db.in_transaction() is not None:
8619 db.commit()
8620 fallback_reason = _server_scoped_direct_execution_fallback_reason(method)
8621 if fallback_reason:
8622 return ORJSONResponse(
8623 status_code=status.HTTP_200_OK,
8624 content={
8625 "directExecutionEligible": False,
8626 "fallbackReason": fallback_reason,
8627 },
8628 )
8629 return Response(status_code=status.HTTP_204_NO_CONTENT)
8630 except JSONRPCError as exc:
8631 return ORJSONResponse(status_code=403, content={"code": exc.code, "message": exc.message, "data": exc.data})
8632 except Exception:
8633 try:
8634 db.rollback()
8635 except Exception:
8636 try:
8637 db.invalidate()
8638 except Exception:
8639 pass # nosec B110 - Best effort cleanup on connection failure
8640 raise
8641 finally:
8642 db.close()
8645def _server_scoped_direct_execution_fallback_reason(method: str) -> Optional[str]:
8646 """Return a direct-execution fallback reason for server-scoped Rust MCP calls.
8648 This fail-closed helper lets Python remain the source of truth for plugin
8649 semantics. Rust can safely execute DB-direct reads only when no relevant
8650 prompt/resource hooks are configured.
8652 Args:
8653 method: MCP method name being considered for Rust direct execution.
8655 Returns:
8656 A stable fallback reason when Python must handle the request to preserve
8657 plugin semantics, otherwise ``None``.
8658 """
8659 if not plugin_manager:
8660 return None
8662 if method == "resources/read":
8663 if plugin_manager.has_hooks_for(ResourceHookType.RESOURCE_PRE_FETCH) or plugin_manager.has_hooks_for(ResourceHookType.RESOURCE_POST_FETCH):
8664 return "resource-hooks-configured"
8665 if method == "prompts/get":
8666 if plugin_manager.has_hooks_for(PromptHookType.PROMPT_PRE_FETCH) or plugin_manager.has_hooks_for(PromptHookType.PROMPT_POST_FETCH):
8667 return "prompt-hooks-configured"
8668 return None
8671@utility_router.post("/_internal/mcp/resources/list/authz/")
8672@utility_router.post("/_internal/mcp/resources/list/authz")
8673async def handle_internal_mcp_resources_list_authz(request: Request):
8674 """Authorize trusted server-scoped resources/list requests for Rust direct-path execution.
8676 Args:
8677 request: Trusted internal MCP authz request.
8679 Returns:
8680 Empty success response when the request is authorized.
8681 """
8682 return await _authorize_internal_mcp_server_scoped_method(
8683 request,
8684 permission="resources.read",
8685 method="resources/list",
8686 )
8689@utility_router.post("/_internal/mcp/resources/read/authz/")
8690@utility_router.post("/_internal/mcp/resources/read/authz")
8691async def handle_internal_mcp_resources_read_authz(request: Request):
8692 """Authorize trusted server-scoped resources/read requests for Rust direct-path execution.
8694 Args:
8695 request: Trusted internal MCP authz request.
8697 Returns:
8698 Empty success response when the request is authorized.
8699 """
8700 return await _authorize_internal_mcp_server_scoped_method(
8701 request,
8702 permission="resources.read",
8703 method="resources/read",
8704 )
8707@utility_router.post("/_internal/mcp/resources/templates/list/authz/")
8708@utility_router.post("/_internal/mcp/resources/templates/list/authz")
8709async def handle_internal_mcp_resource_templates_list_authz(request: Request):
8710 """Authorize trusted server-scoped resources/templates/list requests for Rust direct-path execution.
8712 Args:
8713 request: Trusted internal MCP authz request.
8715 Returns:
8716 Empty success response when the request is authorized.
8717 """
8718 return await _authorize_internal_mcp_server_scoped_method(
8719 request,
8720 permission="resources.read",
8721 method="resources/templates/list",
8722 )
8725@utility_router.post("/_internal/mcp/prompts/list/authz/")
8726@utility_router.post("/_internal/mcp/prompts/list/authz")
8727async def handle_internal_mcp_prompts_list_authz(request: Request):
8728 """Authorize trusted server-scoped prompts/list requests for Rust direct-path execution.
8730 Args:
8731 request: Trusted internal MCP authz request.
8733 Returns:
8734 Empty success response when the request is authorized.
8735 """
8736 return await _authorize_internal_mcp_server_scoped_method(
8737 request,
8738 permission="prompts.read",
8739 method="prompts/list",
8740 )
8743@utility_router.post("/_internal/mcp/prompts/get/authz/")
8744@utility_router.post("/_internal/mcp/prompts/get/authz")
8745async def handle_internal_mcp_prompts_get_authz(request: Request):
8746 """Authorize trusted server-scoped prompts/get requests for Rust direct-path execution.
8748 Args:
8749 request: Trusted internal MCP authz request.
8751 Returns:
8752 Empty success response when the request is authorized.
8753 """
8754 return await _authorize_internal_mcp_server_scoped_method(
8755 request,
8756 permission="prompts.read",
8757 method="prompts/get",
8758 )
8761async def _maybe_forward_affinitized_rpc_request(
8762 request: Request,
8763 *,
8764 method: str,
8765 params: Dict[str, Any],
8766 req_id: Any,
8767 lowered_request_headers: Dict[str, str],
8768) -> Optional[Dict[str, Any]]:
8769 """Forward an MCP request to the owning worker when session affinity requires it.
8771 Args:
8772 request: Incoming RPC request.
8773 method: MCP method name being executed.
8774 params: Parsed JSON-RPC params payload.
8775 req_id: JSON-RPC request identifier.
8776 lowered_request_headers: Lower-cased request headers used for forwarding.
8778 Returns:
8779 Forwarded JSON-RPC response payload when affinity forwarding handled the
8780 request, otherwise ``None`` so local execution can continue.
8781 """
8782 request_headers = request.headers
8783 rpc_client_host = getattr(getattr(request, "client", None), "host", None)
8784 rpc_from_loopback = rpc_client_host in ("127.0.0.1", "::1") if rpc_client_host else False
8785 mcp_session_id = request_headers.get("mcp-session-id") or request_headers.get("x-mcp-session-id")
8786 is_internally_forwarded = rpc_from_loopback and request_headers.get("x-forwarded-internally") == "true"
8788 if settings.mcpgateway_session_affinity_enabled and mcp_session_id and method != "initialize" and not is_internally_forwarded:
8789 # First-Party
8790 from mcpgateway.services.mcp_session_pool import MCPSessionPool, WORKER_ID # pylint: disable=import-outside-toplevel
8792 if not MCPSessionPool.is_valid_mcp_session_id(mcp_session_id):
8793 logger.debug("Invalid MCP session id for affinity forwarding, executing locally")
8794 return None
8796 session_short = mcp_session_id[:8] if len(mcp_session_id) >= 8 else mcp_session_id
8797 logger.debug("[AFFINITY] Worker %s | Session %s... | Method: %s | RPC request received, checking affinity", WORKER_ID, session_short, method)
8798 try:
8799 # First-Party
8800 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool # pylint: disable=import-outside-toplevel
8802 pool = get_mcp_session_pool()
8803 forwarded_response = await pool.forward_request_to_owner(
8804 mcp_session_id,
8805 {"method": method, "params": params, "headers": lowered_request_headers, "req_id": req_id},
8806 )
8807 if forwarded_response is not None:
8808 logger.info("[AFFINITY] Worker %s | Session %s... | Method: %s | Forwarded response received", WORKER_ID, session_short, method)
8809 if "error" in forwarded_response:
8810 return {"jsonrpc": "2.0", "error": forwarded_response["error"], "id": req_id}
8811 return {"jsonrpc": "2.0", "result": forwarded_response.get("result", {}), "id": req_id}
8812 except RuntimeError:
8813 logger.debug("[AFFINITY] Worker %s | Session %s... | Method: %s | Pool not initialized, executing locally", WORKER_ID, session_short, method)
8814 return None
8816 if is_internally_forwarded and mcp_session_id:
8817 # First-Party
8818 from mcpgateway.services.mcp_session_pool import WORKER_ID # pylint: disable=import-outside-toplevel
8820 session_short = mcp_session_id[:8] if len(mcp_session_id) >= 8 else mcp_session_id
8821 logger.debug("[AFFINITY] Worker %s | Session %s... | Method: %s | Internally forwarded request, executing locally", WORKER_ID, session_short, method)
8823 return None
8826async def _execute_rpc_initialize(
8827 request: Request,
8828 user,
8829 *,
8830 params: Dict[str, Any],
8831 server_id: Optional[str],
8832 mcp_session_id: Optional[str],
8833):
8834 """Execute the MCP initialize handshake while preserving session ownership semantics.
8836 Args:
8837 request: Incoming RPC request.
8838 user: Authenticated user payload.
8839 params: Initialize params payload.
8840 server_id: Optional virtual server identifier.
8841 mcp_session_id: Session id from the transport headers, when present.
8843 Returns:
8844 Serialized initialize result payload.
8846 Raises:
8847 JSONRPCError: If session ownership cannot be claimed or validated.
8848 """
8849 init_session_id = params.get("session_id") or params.get("sessionId") or request.query_params.get("session_id")
8850 requester_email, requester_is_admin = _get_request_identity(request, user)
8852 if init_session_id:
8853 effective_owner = await session_registry.claim_session_owner(init_session_id, requester_email)
8854 if effective_owner is None:
8855 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": "initialize"})
8857 if effective_owner and not requester_is_admin and requester_email != effective_owner:
8858 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": "initialize"})
8860 result = await session_registry.handle_initialize_logic(params, session_id=init_session_id, server_id=server_id)
8861 if hasattr(result, "model_dump"):
8862 result = result.model_dump(by_alias=True, exclude_none=True)
8864 if settings.mcpgateway_session_affinity_enabled and mcp_session_id and mcp_session_id != "not-provided":
8865 try:
8866 # First-Party
8867 from mcpgateway.services.mcp_session_pool import get_mcp_session_pool, WORKER_ID # pylint: disable=import-outside-toplevel
8869 pool = get_mcp_session_pool()
8870 await pool.register_pool_session_owner(mcp_session_id)
8871 logger.debug("[AFFINITY_INIT] Worker %s | Session %s... | Registered ownership after initialize", WORKER_ID, mcp_session_id[:8])
8872 except Exception as e:
8873 logger.warning("[AFFINITY_INIT] Failed to register session ownership: %s", e)
8875 return result
8878async def _execute_rpc_tools_call(
8879 request: Request,
8880 db: Session,
8881 user,
8882 *,
8883 req_id: Any,
8884 params: Dict[str, Any],
8885 lowered_request_headers: Dict[str, str],
8886 server_id: Optional[str],
8887 skip_pre_invoke: bool = False,
8888):
8889 """Execute the hot-path ``tools/call`` branch without the generic RPC method switch.
8891 Args:
8892 request: Incoming RPC request.
8893 db: Active database session.
8894 user: Authenticated user payload.
8895 req_id: JSON-RPC request identifier.
8896 params: Parsed tools/call params payload.
8897 lowered_request_headers: Lower-cased request headers used for passthrough.
8898 server_id: Optional virtual server identifier.
8899 skip_pre_invoke: When True, skip TOOL_PRE_INVOKE hooks (used by trusted Rust fallback path).
8901 Returns:
8902 Serialized MCP tools/call result payload.
8904 Raises:
8905 JSONRPCError: If the tool name is missing, execution is cancelled, or the
8906 downstream tool branch reports a JSON-RPC-visible failure.
8907 """
8908 name = params.get("name")
8909 arguments = params.get("arguments", {})
8910 meta_data = params.get("_meta", None)
8911 if not name:
8912 raise JSONRPCError(-32602, "Missing tool name in parameters", params)
8914 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
8915 run_owner_email = auth_user_email
8916 run_owner_team_ids = [] if auth_token_teams is None else list(auth_token_teams)
8917 if auth_is_admin and auth_token_teams is None:
8918 auth_user_email = None
8919 elif auth_token_teams is None:
8920 auth_token_teams = []
8922 oauth_user_email = get_user_email(user)
8923 plugin_context_table = getattr(request.state, "plugin_context_table", None)
8924 plugin_global_context = getattr(request.state, "plugin_global_context", None)
8926 run_id = str(req_id) if req_id is not None else None
8927 tool_task: Optional[asyncio.Task] = None
8929 async def cancel_tool_task(reason: Optional[str] = None):
8930 """Cancel the active tool execution task when cancellation is requested.
8932 Args:
8933 reason: Optional human-readable cancellation reason.
8934 """
8935 if tool_task and not tool_task.done():
8936 logger.info("Cancelling tool task for run_id=%s, reason=%s", run_id, reason)
8937 tool_task.cancel()
8939 if settings.mcpgateway_tool_cancellation_enabled and run_id:
8940 await cancellation_service.register_run(
8941 run_id,
8942 name=f"tool:{name}",
8943 cancel_callback=cancel_tool_task,
8944 owner_email=run_owner_email,
8945 owner_team_ids=run_owner_team_ids,
8946 )
8948 try:
8949 if settings.mcpgateway_tool_cancellation_enabled and run_id:
8950 run_status = await cancellation_service.get_status(run_id)
8951 if run_status and run_status.get("cancelled"):
8952 raise JSONRPCError(-32800, f"Tool execution cancelled: {name}", {"requestId": run_id})
8954 async def execute_tool():
8955 """Execute the tool invocation using the existing Python service layer.
8957 Returns:
8958 Result returned by the Python tool service.
8960 Raises:
8961 JSONRPCError: If the requested tool cannot be found.
8962 """
8963 try:
8964 return await tool_service.invoke_tool(
8965 db=db,
8966 name=name,
8967 arguments=arguments,
8968 request_headers=lowered_request_headers,
8969 app_user_email=oauth_user_email,
8970 user_email=auth_user_email,
8971 token_teams=auth_token_teams,
8972 server_id=server_id,
8973 plugin_context_table=plugin_context_table,
8974 plugin_global_context=plugin_global_context,
8975 meta_data=meta_data,
8976 skip_pre_invoke=skip_pre_invoke,
8977 )
8978 except (ToolNotFoundError, ValueError):
8979 logger.error("Tool not found: %s", name)
8980 raise JSONRPCError(-32601, f"Tool not found: {name}", None)
8982 tool_task = asyncio.create_task(execute_tool())
8984 if settings.mcpgateway_tool_cancellation_enabled and run_id:
8985 run_status = await cancellation_service.get_status(run_id)
8986 if run_status and run_status.get("cancelled"):
8987 tool_task.cancel()
8989 try:
8990 result = await tool_task
8991 if hasattr(result, "model_dump"):
8992 result = result.model_dump(by_alias=True, exclude_none=True)
8993 return result
8994 except asyncio.CancelledError as exc:
8995 logger.info("Tool execution cancelled for run_id=%s, tool=%s", run_id, name)
8996 raise JSONRPCError(-32800, f"Tool execution cancelled: {name}", {"requestId": run_id, "partial": False}) from exc
8997 finally:
8998 if settings.mcpgateway_tool_cancellation_enabled and run_id:
8999 await cancellation_service.unregister_run(run_id)
9002@utility_router.post("/_internal/mcp/tools/call/")
9003@utility_router.post("/_internal/mcp/tools/call")
9004async def handle_internal_mcp_tools_call(request: Request):
9005 """Handle trusted tools/call requests forwarded from the local Rust runtime.
9007 Args:
9008 request: Trusted internal MCP tools/call request.
9010 Returns:
9011 JSON-RPC response payload for the tools/call request.
9013 Raises:
9014 PluginError: Re-raised so plugin middleware can preserve existing behavior.
9015 PluginViolationError: Re-raised so plugin middleware can preserve existing behavior.
9016 Exception: Propagated after best-effort rollback when unexpected failures occur.
9017 """
9018 req_id = None
9019 db = SessionLocal()
9020 try:
9021 user = _build_internal_mcp_forwarded_user(request)
9022 try:
9023 body = orjson.loads(await request.body())
9024 except orjson.JSONDecodeError:
9025 return ORJSONResponse(
9026 status_code=400,
9027 content={
9028 "jsonrpc": "2.0",
9029 "error": {"code": -32700, "message": "Parse error"},
9030 "id": None,
9031 },
9032 )
9034 if not isinstance(body, dict) or body.get("method") != "tools/call":
9035 return ORJSONResponse(
9036 status_code=400,
9037 content={
9038 "jsonrpc": "2.0",
9039 "error": {"code": -32600, "message": "Invalid Request"},
9040 "id": body.get("id") if isinstance(body, dict) else None,
9041 },
9042 )
9044 req_id = body.get("id")
9045 if req_id is None:
9046 req_id = str(uuid.uuid4())
9047 params = body.get("params", {})
9048 if not isinstance(params, dict):
9049 params = {}
9051 server_id = request.headers.get("x-contextforge-server-id") or params.get("server_id")
9052 if server_id:
9053 _enforce_internal_mcp_server_scope(request, server_id)
9055 lowered_request_headers = {k.lower(): v for k, v in request.headers.items()}
9056 forwarded_response = await _maybe_forward_affinitized_rpc_request(
9057 request,
9058 method="tools/call",
9059 params=params,
9060 req_id=req_id,
9061 lowered_request_headers=lowered_request_headers,
9062 )
9063 if forwarded_response is not None:
9064 return forwarded_response
9066 if (_get_internal_mcp_auth_context(request) or {}).get("is_authenticated", True) is True:
9067 await _ensure_rpc_permission(user, db, "tools.execute", "tools/call", request=request)
9069 # Trust the pre-invoke-ran marker only on this internal endpoint
9070 # (authenticated via x-contextforge-mcp-runtime-auth shared secret).
9071 # External clients cannot reach this path.
9072 pre_invoke_ran = lowered_request_headers.get("x-contextforge-pre-invoke-ran") == "true"
9074 try:
9075 result = await _execute_rpc_tools_call(
9076 request,
9077 db,
9078 user,
9079 req_id=req_id,
9080 params=params,
9081 lowered_request_headers=lowered_request_headers,
9082 server_id=server_id,
9083 skip_pre_invoke=pre_invoke_ran,
9084 )
9085 finally:
9086 if db.is_active and db.in_transaction() is not None:
9087 db.commit()
9088 db.close()
9090 return {"jsonrpc": "2.0", "result": result, "id": req_id}
9091 except (PluginError, PluginViolationError):
9092 raise
9093 except JSONRPCError as e:
9094 error = e.to_dict()
9095 return {"jsonrpc": "2.0", "error": error["error"], "id": req_id}
9096 except Exception:
9097 try:
9098 db.rollback()
9099 except Exception:
9100 try:
9101 db.invalidate()
9102 except Exception:
9103 pass # nosec B110 - Best effort cleanup on connection failure
9104 raise
9105 finally:
9106 try:
9107 db.close()
9108 except Exception:
9109 pass # nosec B110 - Best effort cleanup on connection failure
9112@utility_router.post("/_internal/mcp/tools/call/resolve/")
9113@utility_router.post("/_internal/mcp/tools/call/resolve")
9114async def handle_internal_mcp_tools_call_resolve(request: Request):
9115 """Resolve a Rust-direct MCP tools/call execution plan without executing the tool.
9117 Args:
9118 request: Trusted internal MCP tools/call resolve request.
9120 Returns:
9121 JSON response containing either an execution plan or a JSON-RPC-visible error.
9123 Raises:
9124 PluginError: Re-raised so plugin middleware can preserve existing behavior.
9125 PluginViolationError: Re-raised so plugin middleware can preserve existing behavior.
9126 Exception: Propagated after best-effort rollback when unexpected failures occur.
9127 """
9128 db = SessionLocal()
9129 try:
9130 user = _build_internal_mcp_forwarded_user(request)
9131 try:
9132 body = orjson.loads(await request.body())
9133 except orjson.JSONDecodeError:
9134 return ORJSONResponse(
9135 status_code=400,
9136 content={
9137 "jsonrpc": "2.0",
9138 "error": {"code": -32700, "message": "Parse error"},
9139 "id": None,
9140 },
9141 )
9143 if not isinstance(body, dict) or body.get("method") != "tools/call":
9144 return ORJSONResponse(
9145 status_code=400,
9146 content={
9147 "jsonrpc": "2.0",
9148 "error": {"code": -32600, "message": "Invalid Request"},
9149 "id": body.get("id") if isinstance(body, dict) else None,
9150 },
9151 )
9153 params = body.get("params", {})
9154 if not isinstance(params, dict):
9155 params = {}
9157 name = params.get("name")
9158 if not name:
9159 return ORJSONResponse(
9160 status_code=400,
9161 content={
9162 "jsonrpc": "2.0",
9163 "error": {"code": -32602, "message": "Missing tool name in parameters"},
9164 "id": body.get("id"),
9165 },
9166 )
9168 server_id = request.headers.get("x-contextforge-server-id") or params.get("server_id")
9169 if server_id:
9170 _enforce_internal_mcp_server_scope(request, server_id)
9172 if (_get_internal_mcp_auth_context(request) or {}).get("is_authenticated", True) is True:
9173 await _ensure_rpc_permission(user, db, "tools.execute", "tools/call", request=request)
9175 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
9176 if auth_is_admin and auth_token_teams is None:
9177 auth_user_email = None
9178 elif auth_token_teams is None:
9179 auth_token_teams = []
9181 arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
9182 plugin_context_table = getattr(request.state, "plugin_context_table", None)
9183 plugin_global_context = getattr(request.state, "plugin_global_context", None)
9184 plan = await tool_service.prepare_rust_mcp_tool_execution(
9185 db=db,
9186 name=name,
9187 arguments=arguments,
9188 request_headers={k.lower(): v for k, v in request.headers.items()},
9189 app_user_email=get_user_email(user),
9190 user_email=auth_user_email,
9191 token_teams=auth_token_teams,
9192 server_id=server_id,
9193 plugin_global_context=plugin_global_context,
9194 plugin_context_table=plugin_context_table,
9195 )
9197 if db.is_active and db.in_transaction() is not None:
9198 db.commit()
9199 return ORJSONResponse(content=plan)
9200 except ToolNotFoundError as exc:
9201 request_id = body.get("id") if isinstance(body, dict) else None
9202 return ORJSONResponse(
9203 status_code=404,
9204 content={
9205 "jsonrpc": "2.0",
9206 "error": {"code": -32601, "message": str(exc)},
9207 "id": request_id,
9208 },
9209 )
9210 except ToolError as exc:
9211 request_id = body.get("id") if isinstance(body, dict) else None
9212 return ORJSONResponse(
9213 status_code=400,
9214 content={
9215 "jsonrpc": "2.0",
9216 "error": {"code": -32000, "message": str(exc)},
9217 "id": request_id,
9218 },
9219 )
9220 except (PluginError, PluginViolationError):
9221 raise
9222 except JSONRPCError as exc:
9223 request_id = body.get("id") if isinstance(body, dict) else None
9224 return ORJSONResponse(
9225 status_code=403,
9226 content={
9227 "jsonrpc": "2.0",
9228 "error": {"code": exc.code, "message": exc.message, **({"data": exc.data} if exc.data is not None else {})},
9229 "id": exc.request_id if exc.request_id is not None else request_id,
9230 },
9231 )
9232 except Exception:
9233 try:
9234 db.rollback()
9235 except Exception:
9236 try:
9237 db.invalidate()
9238 except Exception:
9239 pass # nosec B110 - Best effort cleanup on connection failure
9240 raise
9241 finally:
9242 try:
9243 db.close()
9244 except Exception:
9245 pass # nosec B110 - Best effort cleanup on connection failure
9248@utility_router.post("/_internal/mcp/tools/call/metric/")
9249@utility_router.post("/_internal/mcp/tools/call/metric")
9250async def handle_internal_mcp_tools_call_metric(request: Request):
9251 """Record buffered tool/server metrics for a Rust-direct `tools/call`.
9253 Args:
9254 request: Trusted internal metrics writeback request.
9256 Returns:
9257 ORJSONResponse acknowledging the buffered metric writeback.
9258 """
9259 _build_internal_mcp_forwarded_user(request)
9260 try:
9261 body = orjson.loads(await request.body())
9262 except orjson.JSONDecodeError:
9263 return ORJSONResponse(status_code=400, content={"detail": "Invalid JSON body"})
9265 if not isinstance(body, dict):
9266 return ORJSONResponse(status_code=400, content={"detail": "Invalid metrics payload"})
9268 tool_id = body.get("toolId")
9269 duration_ms = body.get("durationMs")
9270 success = body.get("success")
9271 server_id = body.get("serverId")
9272 error_message = body.get("errorMessage")
9274 if not isinstance(tool_id, str) or not tool_id.strip():
9275 return ORJSONResponse(status_code=400, content={"detail": "Missing toolId"})
9276 if not isinstance(duration_ms, (int, float)) or duration_ms < 0:
9277 return ORJSONResponse(status_code=400, content={"detail": "Invalid durationMs"})
9278 if not isinstance(success, bool):
9279 return ORJSONResponse(status_code=400, content={"detail": "Invalid success flag"})
9280 if server_id is not None and (not isinstance(server_id, str) or not server_id.strip()):
9281 return ORJSONResponse(status_code=400, content={"detail": "Invalid serverId"})
9282 if error_message is not None and not isinstance(error_message, str):
9283 return ORJSONResponse(status_code=400, content={"detail": "Invalid errorMessage"})
9285 request_server_id = request.headers.get("x-contextforge-server-id")
9286 if request_server_id:
9287 _enforce_internal_mcp_server_scope(request, request_server_id)
9288 if server_id and server_id != request_server_id:
9289 return ORJSONResponse(status_code=400, content={"detail": "serverId does not match forwarded server scope"})
9290 server_id = request_server_id
9292 # First-Party
9293 from mcpgateway.services.metrics_buffer_service import get_metrics_buffer_service # pylint: disable=import-outside-toplevel
9295 metrics_buffer = get_metrics_buffer_service()
9296 response_time = float(duration_ms) / 1000.0
9297 metrics_buffer.record_tool_metric_with_duration(
9298 tool_id=tool_id,
9299 response_time=response_time,
9300 success=success,
9301 error_message=error_message,
9302 )
9303 if server_id:
9304 metrics_buffer.record_server_metric_with_duration(
9305 server_id=server_id,
9306 response_time=response_time,
9307 success=success,
9308 error_message=error_message,
9309 )
9311 return ORJSONResponse(content={"status": "ok"})
9314async def _handle_rpc_authenticated(request: Request, db: Session, user):
9315 """Handle RPC requests.
9317 Args:
9318 request (Request): The incoming FastAPI request.
9319 db (Session): Database session.
9320 user: The authenticated user (dict with RBAC context).
9322 Returns:
9323 Response with the RPC result or error.
9325 Raises:
9326 PluginError: If encounters issue with plugin
9327 PluginViolationError: If plugin violated the request. Example - In case of OPA plugin, if the request is denied by policy.
9328 """
9329 req_id = None
9330 try:
9331 # Extract user identifier from either RBAC user object or JWT payload
9332 if hasattr(user, "email"):
9333 user_id = getattr(user, "email", None) # RBAC user object
9334 elif isinstance(user, dict):
9335 user_id = user.get("sub") or user.get("email") or user.get("username", "unknown") # JWT payload
9336 else:
9337 user_id = str(user) # String username from basic auth
9339 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user_id))} made an RPC request")
9340 try:
9341 body = orjson.loads(await request.body())
9342 except orjson.JSONDecodeError:
9343 return ORJSONResponse(
9344 status_code=400,
9345 content={
9346 "jsonrpc": "2.0",
9347 "error": {"code": -32700, "message": "Parse error"},
9348 "id": None,
9349 },
9350 )
9351 request_headers = request.headers
9352 lowered_headers: Optional[Dict[str, str]] = None
9354 def _lowered_request_headers() -> Dict[str, str]:
9355 """Return a cached lower-cased copy of the incoming request headers.
9357 Returns:
9358 Dict[str, str]: Lower-cased request headers cached for repeated access.
9359 """
9360 nonlocal lowered_headers
9361 if lowered_headers is None:
9362 lowered_headers = {k.lower(): v for k, v in request_headers.items()}
9363 return lowered_headers
9365 _trusted_internal_mcp_dispatch = _get_internal_mcp_auth_context(request) is not None
9366 _internal_runtime_server_id = request_headers.get("x-contextforge-server-id") if request_headers.get("x-contextforge-mcp-runtime") == "rust" else None
9368 method = body["method"]
9369 req_id = body.get("id")
9370 if req_id is None:
9371 req_id = str(uuid.uuid4())
9372 params = body.get("params", {})
9373 if not isinstance(params, dict):
9374 params = {}
9375 if _internal_runtime_server_id:
9376 params["server_id"] = _internal_runtime_server_id
9377 server_id = params.get("server_id", None)
9378 cursor = params.get("cursor") # Extract cursor parameter
9379 mcp_session_id = request_headers.get("mcp-session-id") or request_headers.get("x-mcp-session-id")
9381 # RBAC: Enforce server_id scoping for server-scoped tokens.
9382 # Extract token scopes once, then:
9383 # 1. If request supplies server_id, validate it matches the token scope.
9384 # 2. If request omits server_id but token is server-scoped, auto-inject the
9385 # token's server_id so list operations stay properly scoped (parity with
9386 # the REST middleware which denies /tools for server-scoped tokens).
9387 _cached = getattr(request.state, "_jwt_verified_payload", None)
9388 _jwt_payload = _cached[1] if (isinstance(_cached, tuple) and len(_cached) == 2 and isinstance(_cached[1], dict)) else None
9389 _token_scopes = _jwt_payload.get("scopes", {}) if _jwt_payload else {}
9390 _internal_auth_context = _get_internal_mcp_auth_context(request)
9391 if (not _token_scopes) and isinstance(_internal_auth_context, dict):
9392 _scoped_server_id = _internal_auth_context.get("scoped_server_id")
9393 if isinstance(_scoped_server_id, str) and _scoped_server_id:
9394 _token_scopes = {"server_id": _scoped_server_id}
9395 _token_server_id = _token_scopes.get("server_id") if _token_scopes else None
9397 if server_id:
9398 if not validate_server_access(_token_scopes, server_id):
9399 return ORJSONResponse(
9400 status_code=403,
9401 content={
9402 "jsonrpc": "2.0",
9403 "error": {"code": -32003, "message": f"Token not authorized for server: {server_id}"},
9404 "id": req_id,
9405 },
9406 )
9407 elif _token_server_id is not None:
9408 server_id = _token_server_id
9410 if not _trusted_internal_mcp_dispatch:
9411 RPCRequest(jsonrpc="2.0", method=method, params=params) # Validate the request body against the RPCRequest model
9413 forwarded_response = await _maybe_forward_affinitized_rpc_request(
9414 request,
9415 method=method,
9416 params=params,
9417 req_id=req_id,
9418 lowered_request_headers=_lowered_request_headers(),
9419 )
9420 if forwarded_response is not None:
9421 return forwarded_response
9423 if method == "initialize":
9424 result = await _execute_rpc_initialize(
9425 request,
9426 user,
9427 params=params,
9428 server_id=server_id,
9429 mcp_session_id=mcp_session_id,
9430 )
9431 elif method == "tools/list":
9432 await _ensure_rpc_permission(user, db, "tools.read", method, request=request)
9433 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
9434 _req_email, _req_is_admin = user_email, is_admin
9435 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
9436 # Admin bypass - only when token has NO team restrictions
9437 if is_admin and token_teams is None:
9438 user_email = None
9439 token_teams = None # Admin unrestricted
9440 elif token_teams is None:
9441 token_teams = [] # Non-admin without teams = public-only (secure default)
9442 if server_id:
9443 tools = await tool_service.list_server_tools(
9444 db,
9445 server_id,
9446 cursor=cursor,
9447 user_email=user_email,
9448 token_teams=token_teams,
9449 requesting_user_email=_req_email,
9450 requesting_user_is_admin=_req_is_admin,
9451 requesting_user_team_roles=_req_team_roles,
9452 )
9453 # Release DB connection early to prevent idle-in-transaction under load
9454 db.commit()
9455 db.close()
9456 result = {"tools": _serialize_mcp_tool_definitions(tools)}
9457 else:
9458 tools, next_cursor = await tool_service.list_tools(
9459 db,
9460 cursor=cursor,
9461 limit=0,
9462 user_email=user_email,
9463 token_teams=token_teams,
9464 requesting_user_email=_req_email,
9465 requesting_user_is_admin=_req_is_admin,
9466 requesting_user_team_roles=_req_team_roles,
9467 )
9468 # Release DB connection early to prevent idle-in-transaction under load
9469 db.commit()
9470 db.close()
9471 result = {"tools": _serialize_mcp_tool_definitions(tools)}
9472 if next_cursor:
9473 result["nextCursor"] = next_cursor
9474 elif method == "list_tools": # Legacy endpoint
9475 await _ensure_rpc_permission(user, db, "tools.read", method, request=request)
9476 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
9477 _req_email, _req_is_admin = user_email, is_admin
9478 _req_team_roles = get_user_team_roles(db, _req_email) if _req_email and not _req_is_admin else None
9479 # Admin bypass - only when token has NO team restrictions (token_teams is None)
9480 # If token has explicit team scope (even empty [] for public-only), respect it
9481 if is_admin and token_teams is None:
9482 user_email = None
9483 token_teams = None # Admin unrestricted
9484 elif token_teams is None:
9485 token_teams = [] # Non-admin without teams = public-only (secure default)
9486 if server_id:
9487 tools = await tool_service.list_server_tools(
9488 db,
9489 server_id,
9490 cursor=cursor,
9491 user_email=user_email,
9492 token_teams=token_teams,
9493 requesting_user_email=_req_email,
9494 requesting_user_is_admin=_req_is_admin,
9495 requesting_user_team_roles=_req_team_roles,
9496 )
9497 db.commit()
9498 db.close()
9499 result = {"tools": _serialize_legacy_tool_payloads(tools)}
9500 else:
9501 tools, next_cursor = await tool_service.list_tools(
9502 db,
9503 cursor=cursor,
9504 limit=0,
9505 user_email=user_email,
9506 token_teams=token_teams,
9507 requesting_user_email=_req_email,
9508 requesting_user_is_admin=_req_is_admin,
9509 requesting_user_team_roles=_req_team_roles,
9510 )
9511 db.commit()
9512 db.close()
9513 result = {"tools": _serialize_legacy_tool_payloads(tools)}
9514 if next_cursor:
9515 result["nextCursor"] = next_cursor
9516 elif method == "list_gateways":
9517 await _ensure_rpc_permission(user, db, "gateways.read", method, request=request)
9518 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
9519 # Admin bypass - only when token has NO team restrictions
9520 if is_admin and token_teams is None:
9521 user_email = None
9522 token_teams = None # Admin unrestricted
9523 elif token_teams is None:
9524 token_teams = [] # Non-admin without teams = public-only (secure default)
9525 gateways, next_cursor = await gateway_service.list_gateways(db, include_inactive=False, user_email=user_email, token_teams=token_teams)
9526 db.commit()
9527 db.close()
9528 result = {"gateways": [g.model_dump(by_alias=True, exclude_none=True) for g in gateways]}
9529 if next_cursor:
9530 result["nextCursor"] = next_cursor
9531 elif method == "list_roots":
9532 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
9533 roots = await root_service.list_roots()
9534 result = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]}
9535 elif method == "resources/list":
9536 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
9537 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
9538 # Admin bypass - only when token has NO team restrictions
9539 if is_admin and token_teams is None:
9540 user_email = None
9541 token_teams = None # Admin unrestricted
9542 elif token_teams is None:
9543 token_teams = [] # Non-admin without teams = public-only (secure default)
9544 if server_id:
9545 resources = await resource_service.list_server_resources(db, server_id, user_email=user_email, token_teams=token_teams)
9546 db.commit()
9547 db.close()
9548 result = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]}
9549 else:
9550 resources, next_cursor = await resource_service.list_resources(db, cursor=cursor, limit=0, user_email=user_email, token_teams=token_teams)
9551 db.commit()
9552 db.close()
9553 result = {"resources": [r.model_dump(by_alias=True, exclude_none=True) for r in resources]}
9554 if next_cursor:
9555 result["nextCursor"] = next_cursor
9556 elif method == "resources/read":
9557 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
9558 uri = params.get("uri")
9559 request_id = params.get("requestId", None)
9560 meta_data = params.get("_meta", None)
9561 if not uri:
9562 raise JSONRPCError(-32602, "Missing resource URI in parameters", params)
9564 # Get authorization context (same as resources/list)
9565 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
9566 if auth_is_admin and auth_token_teams is None:
9567 auth_user_email = None
9568 # auth_token_teams stays None (unrestricted)
9569 elif auth_token_teams is None:
9570 auth_token_teams = [] # Non-admin without teams = public-only
9572 # Get user email for OAuth token selection
9573 oauth_user_email = get_user_email(user)
9574 # Get plugin contexts from request.state for cross-hook sharing
9575 plugin_context_table = getattr(request.state, "plugin_context_table", None)
9576 plugin_global_context = getattr(request.state, "plugin_global_context", None)
9577 try:
9578 result = await resource_service.read_resource(
9579 db,
9580 resource_uri=uri,
9581 request_id=request_id,
9582 user=auth_user_email,
9583 server_id=server_id,
9584 token_teams=auth_token_teams,
9585 plugin_context_table=plugin_context_table,
9586 plugin_global_context=plugin_global_context,
9587 meta_data=meta_data,
9588 )
9589 if hasattr(result, "model_dump"):
9590 result = {"contents": [result.model_dump(by_alias=True, exclude_none=True)]}
9591 else:
9592 result = {"contents": [result]}
9593 except (ValueError, ResourceNotFoundError):
9594 # Resource not found in the gateway
9595 logger.error("Resource not found: %s", uri)
9596 raise JSONRPCError(-32002, f"Resource not found: {uri}", {"uri": uri})
9597 # Release transaction after resources/read completes
9598 db.commit()
9599 db.close()
9600 elif method == "resources/subscribe":
9601 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
9602 # MCP spec-compliant resource subscription endpoint
9603 uri = params.get("uri")
9604 if not uri:
9605 raise JSONRPCError(-32602, "Missing resource URI in parameters", params)
9606 access_user_email, access_token_teams = _get_scoped_resource_access_context(request, user)
9607 # Get user email for subscriber ID
9608 user_email = get_user_email(user)
9609 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email)
9610 try:
9611 await resource_service.subscribe_resource(db, subscription, user_email=access_user_email, token_teams=access_token_teams)
9612 except PermissionError:
9613 raise JSONRPCError(-32003, _ACCESS_DENIED_MSG, {"method": method})
9614 db.commit()
9615 db.close()
9616 result = {}
9617 elif method == "resources/unsubscribe":
9618 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
9619 # MCP spec-compliant resource unsubscription endpoint
9620 uri = params.get("uri")
9621 if not uri:
9622 raise JSONRPCError(-32602, "Missing resource URI in parameters", params)
9623 # Get user email for subscriber ID
9624 user_email = get_user_email(user)
9625 subscription = ResourceSubscription(uri=uri, subscriber_id=user_email)
9626 await resource_service.unsubscribe_resource(db, subscription)
9627 db.commit()
9628 db.close()
9629 result = {}
9630 elif method == "prompts/list":
9631 await _ensure_rpc_permission(user, db, "prompts.read", method, request=request)
9632 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
9633 # Admin bypass - only when token has NO team restrictions
9634 if is_admin and token_teams is None:
9635 user_email = None
9636 token_teams = None # Admin unrestricted
9637 elif token_teams is None:
9638 token_teams = [] # Non-admin without teams = public-only (secure default)
9639 if server_id:
9640 prompts = await prompt_service.list_server_prompts(db, server_id, cursor=cursor, user_email=user_email, token_teams=token_teams)
9641 db.commit()
9642 db.close()
9643 result = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]}
9644 else:
9645 prompts, next_cursor = await prompt_service.list_prompts(db, cursor=cursor, limit=0, user_email=user_email, token_teams=token_teams)
9646 db.commit()
9647 db.close()
9648 result = {"prompts": [p.model_dump(by_alias=True, exclude_none=True) for p in prompts]}
9649 if next_cursor:
9650 result["nextCursor"] = next_cursor
9651 elif method == "prompts/get":
9652 await _ensure_rpc_permission(user, db, "prompts.read", method, request=request)
9653 name = params.get("name")
9654 arguments = params.get("arguments", {})
9655 meta_data = params.get("_meta", None)
9656 if not name:
9657 raise JSONRPCError(-32602, "Missing prompt name in parameters", params)
9659 # Get authorization context (same as prompts/list)
9660 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
9661 if auth_is_admin and auth_token_teams is None:
9662 auth_user_email = None
9663 # auth_token_teams stays None (unrestricted)
9664 elif auth_token_teams is None:
9665 auth_token_teams = [] # Non-admin without teams = public-only
9667 # Get plugin contexts from request.state for cross-hook sharing
9668 plugin_context_table = getattr(request.state, "plugin_context_table", None)
9669 plugin_global_context = getattr(request.state, "plugin_global_context", None)
9670 result = await prompt_service.get_prompt(
9671 db,
9672 name,
9673 arguments,
9674 user=auth_user_email,
9675 server_id=server_id,
9676 token_teams=auth_token_teams,
9677 plugin_context_table=plugin_context_table,
9678 plugin_global_context=plugin_global_context,
9679 _meta_data=meta_data,
9680 )
9681 if hasattr(result, "model_dump"):
9682 result = result.model_dump(by_alias=True, exclude_none=True)
9683 # Release transaction after prompts/get completes
9684 db.commit()
9685 db.close()
9686 elif method == "ping":
9687 # Per the MCP spec, a ping returns an empty result.
9688 result = {}
9689 elif method == "tools/call": # pylint: disable=too-many-nested-blocks
9690 await _ensure_rpc_permission(user, db, "tools.execute", method, request=request)
9691 # Note: Multi-worker session affinity forwarding is handled earlier
9692 # (before method routing) to apply to ALL methods, not just tools/call
9693 try:
9694 result = await _execute_rpc_tools_call(
9695 request,
9696 db,
9697 user,
9698 req_id=req_id,
9699 params=params,
9700 lowered_request_headers=_lowered_request_headers(),
9701 server_id=server_id,
9702 )
9703 finally:
9704 # Release transaction after tools/call completes
9705 db.commit()
9706 db.close()
9707 # TODO: Implement methods # pylint: disable=fixme
9708 elif method == "resources/templates/list":
9709 await _ensure_rpc_permission(user, db, "resources.read", method, request=request)
9710 # MCP spec-compliant resource templates list endpoint
9711 # Use _get_rpc_filter_context - same pattern as tools/list
9712 user_email_rpc, token_teams_rpc, is_admin_rpc = _get_rpc_filter_context(request, user)
9714 # Admin bypass - only when token has NO team restrictions
9715 if is_admin_rpc and token_teams_rpc is None:
9716 token_teams_rpc = None # Admin unrestricted
9717 elif token_teams_rpc is None:
9718 token_teams_rpc = [] # Non-admin without teams = public-only
9720 resource_templates = await resource_service.list_resource_templates(
9721 db,
9722 user_email=user_email_rpc,
9723 token_teams=token_teams_rpc,
9724 server_id=server_id,
9725 )
9726 db.commit()
9727 db.close()
9728 result = {"resourceTemplates": [rt.model_dump(by_alias=True, exclude_none=True) for rt in resource_templates]}
9729 elif method == "roots/list":
9730 # MCP spec-compliant method name
9731 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
9732 roots = await root_service.list_roots()
9733 result = {"roots": [r.model_dump(by_alias=True, exclude_none=True) for r in roots]}
9734 elif method.startswith("roots/"):
9735 # Catch-all for other roots/* methods (currently unsupported)
9736 result = {}
9737 elif method == "notifications/initialized":
9738 # MCP spec-compliant notification: client initialized
9739 logger.info("Client initialized")
9740 await logging_service.notify("Client initialized", LogLevel.INFO)
9741 result = {}
9742 elif method == "notifications/cancelled":
9743 # MCP spec-compliant notification: request cancelled
9744 # Note: requestId can be 0 (valid per JSON-RPC), so use 'is not None' and normalize to string
9745 raw_request_id = params.get("requestId")
9746 request_id = str(raw_request_id) if raw_request_id is not None else None
9747 reason = params.get("reason")
9748 logger.info("Request cancelled: %s, reason: %s", request_id, reason)
9749 # Attempt local cancellation per MCP spec
9750 if request_id is not None:
9751 await _authorize_run_cancellation(request, user, request_id, as_jsonrpc_error=True)
9752 await cancellation_service.cancel_run(request_id, reason=reason)
9753 await logging_service.notify(f"Request cancelled: {request_id}", LogLevel.INFO)
9754 result = {}
9755 elif method == "notifications/message":
9756 # MCP spec-compliant notification: log message
9757 await logging_service.notify(
9758 params.get("data"),
9759 LogLevel(params.get("level", "info")),
9760 params.get("logger"),
9761 )
9762 result = {}
9763 elif method.startswith("notifications/"):
9764 # Catch-all for other notifications/* methods (currently unsupported)
9765 result = {}
9766 elif method == "sampling/createMessage":
9767 # MCP spec-compliant sampling endpoint
9768 result = await sampling_handler.create_message(db, params)
9769 elif method.startswith("sampling/"):
9770 # Catch-all for other sampling/* methods (currently unsupported)
9771 result = {}
9772 elif method == "elicitation/create":
9773 # MCP spec 2025-06-18: Elicitation support (server-to-client requests)
9774 # Elicitation allows servers to request structured user input through clients
9776 # Check if elicitation is enabled
9777 if not settings.mcpgateway_elicitation_enabled:
9778 raise JSONRPCError(-32601, "Elicitation feature is disabled", {"feature": "elicitation", "config": "MCPGATEWAY_ELICITATION_ENABLED=false"})
9780 # Validate params
9781 # First-Party
9782 from mcpgateway.common.models import ElicitRequestParams # pylint: disable=import-outside-toplevel
9783 from mcpgateway.services.elicitation_service import get_elicitation_service # pylint: disable=import-outside-toplevel
9785 try:
9786 elicit_params = ElicitRequestParams(**params)
9787 except Exception as e:
9788 raise JSONRPCError(-32602, f"Invalid elicitation params: {e}", params)
9790 # Get target session (from params or find elicitation-capable session)
9791 target_session_id = params.get("session_id") or params.get("sessionId")
9792 if not target_session_id:
9793 # Find an elicitation-capable session
9794 capable_sessions = await session_registry.get_elicitation_capable_sessions()
9795 if not capable_sessions:
9796 raise JSONRPCError(-32000, "No elicitation-capable clients available", {"message": elicit_params.message})
9797 target_session_id = capable_sessions[0]
9798 logger.debug("Selected session %s for elicitation", target_session_id)
9800 # Verify session has elicitation capability
9801 if not await session_registry.has_elicitation_capability(target_session_id):
9802 raise JSONRPCError(-32000, f"Session {target_session_id} does not support elicitation", {"session_id": target_session_id})
9804 # Get elicitation service and create request
9805 elicitation_service = get_elicitation_service()
9807 # Extract timeout from params or use default
9808 timeout = params.get("timeout", settings.mcpgateway_elicitation_timeout)
9810 try:
9811 # Create elicitation request - this stores it and waits for response
9812 # For now, use dummy upstream_session_id - in full bidirectional proxy,
9813 # this would be the session that initiated the request
9814 upstream_session_id = "gateway"
9816 # Start the elicitation (creates pending request and future)
9817 elicitation_task = asyncio.create_task(
9818 elicitation_service.create_elicitation(
9819 upstream_session_id=upstream_session_id, downstream_session_id=target_session_id, message=elicit_params.message, requested_schema=elicit_params.requestedSchema, timeout=timeout
9820 )
9821 )
9823 # Get the pending elicitation to extract request_id
9824 # Wait a moment for it to be created
9825 await asyncio.sleep(0.01)
9826 pending_elicitations = [e for e in elicitation_service._pending.values() if e.downstream_session_id == target_session_id] # pylint: disable=protected-access
9827 if not pending_elicitations:
9828 raise JSONRPCError(-32000, "Failed to create elicitation request", {})
9830 pending = pending_elicitations[-1] # Get most recent
9832 # Send elicitation request to client via broadcast
9833 elicitation_request = {
9834 "jsonrpc": "2.0",
9835 "id": pending.request_id,
9836 "method": "elicitation/create",
9837 "params": {"message": elicit_params.message, "requestedSchema": elicit_params.requestedSchema},
9838 }
9840 await session_registry.broadcast(target_session_id, elicitation_request)
9841 logger.debug("Sent elicitation request %s to session %s", pending.request_id, target_session_id)
9843 # Wait for response
9844 elicit_result = await elicitation_task
9846 # Return result
9847 result = elicit_result.model_dump(by_alias=True, exclude_none=True)
9849 except asyncio.TimeoutError:
9850 raise JSONRPCError(-32000, f"Elicitation timed out after {timeout}s", {"message": elicit_params.message, "timeout": timeout})
9851 except ValueError as e:
9852 raise JSONRPCError(-32000, str(e), {"message": elicit_params.message})
9853 elif method.startswith("elicitation/"):
9854 # Catch-all for other elicitation/* methods
9855 result = {}
9856 elif method == "completion/complete":
9857 await _ensure_rpc_permission(user, db, "tools.read", method, request=request)
9858 # MCP spec-compliant completion endpoint
9859 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
9860 if is_admin and token_teams is None:
9861 user_email = None
9862 elif token_teams is None:
9863 token_teams = []
9864 result = await completion_service.handle_completion(db, params, user_email=user_email, token_teams=token_teams)
9865 elif method.startswith("completion/"):
9866 # Catch-all for other completion/* methods (currently unsupported)
9867 result = {}
9868 elif method == "logging/setLevel":
9869 await _ensure_rpc_permission(user, db, "admin.system_config", method, request=request)
9870 level = LogLevel(params.get("level"))
9871 await logging_service.set_level(level)
9872 result = {}
9873 elif method.startswith("logging/"):
9874 # Catch-all for other logging/* methods (currently unsupported)
9875 result = {}
9876 else:
9877 # Backward compatibility: Try to invoke as a tool directly
9878 # This allows both old format (method=tool_name) and new format (method=tools/call)
9879 await _ensure_rpc_permission(user, db, "tools.execute", method, request=request)
9881 # Get authorization context (same as tools/call)
9882 auth_user_email, auth_token_teams, auth_is_admin = _get_rpc_filter_context(request, user)
9883 if auth_is_admin and auth_token_teams is None:
9884 auth_user_email = None
9885 # auth_token_teams stays None (unrestricted)
9886 elif auth_token_teams is None:
9887 auth_token_teams = [] # Non-admin without teams = public-only
9889 # Get user email for OAuth token selection
9890 oauth_user_email = get_user_email(user)
9891 # Get server_id from params if provided
9892 server_id = params.get("server_id")
9893 # Get plugin contexts from request.state for cross-hook sharing
9894 plugin_context_table = getattr(request.state, "plugin_context_table", None)
9895 plugin_global_context = getattr(request.state, "plugin_global_context", None)
9897 meta_data = params.get("_meta", None)
9899 try:
9900 result = await tool_service.invoke_tool(
9901 db=db,
9902 name=method,
9903 arguments=params,
9904 request_headers=_lowered_request_headers(),
9905 app_user_email=oauth_user_email,
9906 user_email=auth_user_email,
9907 token_teams=auth_token_teams,
9908 server_id=server_id,
9909 plugin_context_table=plugin_context_table,
9910 plugin_global_context=plugin_global_context,
9911 meta_data=meta_data,
9912 )
9913 if hasattr(result, "model_dump"):
9914 result = result.model_dump(by_alias=True, exclude_none=True)
9915 except (PluginError, PluginViolationError):
9916 raise
9917 except Exception:
9918 # Log error and return invalid method
9919 logger.error("Method not found: %s", method)
9920 raise JSONRPCError(-32000, "Invalid method", params)
9922 return {"jsonrpc": "2.0", "result": result, "id": req_id}
9924 except (PluginError, PluginViolationError):
9925 raise
9926 except JSONRPCError as e:
9927 error = e.to_dict()
9928 return {"jsonrpc": "2.0", "error": error["error"], "id": req_id}
9929 except Exception as e:
9930 if isinstance(e, ValueError):
9931 return ORJSONResponse(content={"message": "Method invalid"}, status_code=422)
9932 logger.error(f"RPC error: {str(e)}")
9933 return {
9934 "jsonrpc": "2.0",
9935 "error": {"code": -32000, "message": "Internal error", "data": str(e)},
9936 "id": req_id,
9937 }
9940_WS_RELAY_REQUIRED_PERMISSIONS = [
9941 "tools.read",
9942 "tools.execute",
9943 "resources.read",
9944 "prompts.read",
9945 "servers.use",
9946 "a2a.read",
9947]
9950def _get_websocket_bearer_token(websocket: WebSocket) -> Optional[str]:
9951 """Extract bearer token from WebSocket Authorization headers.
9953 Args:
9954 websocket: Incoming WebSocket connection.
9956 Returns:
9957 Bearer token value when present, otherwise None.
9958 """
9959 return extract_websocket_bearer_token(
9960 getattr(websocket, "query_params", {}),
9961 getattr(websocket, "headers", {}),
9962 query_param_warning="WebSocket authentication token passed via query parameter",
9963 )
9966async def _authenticate_websocket_user(websocket: WebSocket) -> tuple[Optional[str], Optional[str]]:
9967 """Authenticate and authorize a WebSocket relay connection.
9969 Args:
9970 websocket: Incoming WebSocket connection.
9972 Returns:
9973 A tuple of `(auth_token, proxy_user)` where each value may be None.
9975 Raises:
9976 HTTPException: If authentication fails or required permissions are missing.
9977 """
9978 auth_required = settings.mcp_client_auth_enabled or settings.auth_required
9979 auth_token = _get_websocket_bearer_token(websocket)
9980 proxy_user: Optional[str] = None
9981 user_context: Optional[dict[str, Any]] = None
9983 # JWT authentication path
9984 if auth_token:
9985 credentials = HTTPAuthorizationCredentials(scheme="Bearer", credentials=auth_token)
9986 try:
9987 user = await get_current_user(credentials, request=websocket)
9988 except HTTPException:
9989 raise
9990 except Exception as exc:
9991 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication failed") from exc
9992 user_context = {
9993 "email": user.email,
9994 "full_name": user.full_name,
9995 "is_admin": user.is_admin,
9996 "ip_address": websocket.client.host if websocket.client else None,
9997 "user_agent": websocket.headers.get("user-agent"),
9998 "team_id": getattr(websocket.state, "team_id", None),
9999 "token_teams": getattr(websocket.state, "token_teams", None),
10000 "token_use": getattr(websocket.state, "token_use", None),
10001 }
10002 # Proxy authentication path (only valid when MCP client auth is disabled)
10003 elif is_proxy_auth_trust_active(settings):
10004 proxy_user = websocket.headers.get(settings.proxy_user_header)
10005 if proxy_user:
10006 user_context = {
10007 "email": proxy_user,
10008 "full_name": proxy_user,
10009 "is_admin": False,
10010 "ip_address": websocket.client.host if websocket.client else None,
10011 "user_agent": websocket.headers.get("user-agent"),
10012 }
10013 elif auth_required:
10014 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
10015 elif auth_required:
10016 raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Authentication required")
10018 # RBAC gate: require at least one MCP interaction permission before allowing WS relay access
10019 if user_context:
10020 checker = PermissionChecker(user_context)
10021 if not await checker.has_any_permission(_WS_RELAY_REQUIRED_PERMISSIONS):
10022 logger.warning("WebSocket relay permission denied: user=%s", user_context.get("email"))
10023 raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=_ACCESS_DENIED_MSG)
10025 return auth_token, proxy_user
10028@utility_router.websocket("/ws")
10029async def websocket_endpoint(websocket: WebSocket):
10030 """
10031 Handle WebSocket connection to relay JSON-RPC requests to the internal RPC endpoint.
10033 Accepts incoming text messages, parses them as JSON-RPC requests, sends them to /rpc,
10034 and returns the result to the client over the same WebSocket.
10036 Args:
10037 websocket: The WebSocket connection instance.
10038 """
10039 try:
10040 if not settings.mcpgateway_ws_relay_enabled:
10041 await websocket.close(code=1008, reason="WebSocket relay is disabled")
10042 return
10044 try:
10045 auth_token, proxy_user = await _authenticate_websocket_user(websocket)
10046 except HTTPException as e:
10047 await websocket.close(code=1008, reason=str(e.detail))
10048 return
10050 # Capture passthrough headers from the WebSocket handshake request.
10051 # Without this, headers like X-Upstream-Authorization are silently dropped. See #3640.
10052 # First-Party
10053 from mcpgateway.utils.passthrough_headers import filter_loopback_skip_headers, safe_extract_headers_for_loopback # pylint: disable=import-outside-toplevel
10055 ws_passthrough_headers = safe_extract_headers_for_loopback(dict(websocket.headers), "WebSocket")
10057 await websocket.accept()
10058 while True:
10059 try:
10060 data = await websocket.receive_text()
10061 client_args = {"timeout": settings.federation_timeout, "verify": internal_loopback_verify()}
10063 # Build headers for /rpc request - forward auth credentials
10064 rpc_headers: Dict[str, str] = {"Content-Type": "application/json"}
10065 if auth_token:
10066 rpc_headers["Authorization"] = f"Bearer {auth_token}"
10067 if proxy_user:
10068 rpc_headers[settings.proxy_user_header] = proxy_user
10069 # Forward passthrough headers captured from the WebSocket handshake (see #3640).
10070 # Defense-in-depth: filter via filter_loopback_skip_headers() so passthrough
10071 # can never override the gateway's internal auth, content-type, or session/routing headers.
10072 if ws_passthrough_headers:
10073 rpc_headers.update(filter_loopback_skip_headers(ws_passthrough_headers))
10075 async with ResilientHttpClient(client_args=client_args) as client:
10076 response = await client.post(
10077 f"{internal_loopback_base_url()}{settings.app_root_path}/rpc",
10078 json=orjson.loads(data),
10079 headers=rpc_headers,
10080 )
10081 await websocket.send_text(response.text)
10082 except JSONRPCError as e:
10083 await websocket.send_text(orjson.dumps(e.to_dict()).decode())
10084 except orjson.JSONDecodeError:
10085 await websocket.send_text(
10086 orjson.dumps(
10087 {
10088 "jsonrpc": "2.0",
10089 "error": {"code": -32700, "message": "Parse error"},
10090 "id": None,
10091 }
10092 ).decode()
10093 )
10094 except Exception as e:
10095 logger.error(f"WebSocket error: {str(e)}")
10096 await websocket.close(code=1011)
10097 break
10098 except WebSocketDisconnect:
10099 logger.info("WebSocket disconnected")
10100 except Exception as e:
10101 logger.error(f"WebSocket connection error: {str(e)}")
10102 try:
10103 await websocket.close(code=1011)
10104 except Exception as er:
10105 logger.error(f"Error while closing WebSocket: {er}")
10108@utility_router.get("/sse")
10109@require_permission("servers.use")
10110async def utility_sse_endpoint(request: Request, user=Depends(get_current_user_with_permissions)):
10111 """
10112 Establish a Server-Sent Events (SSE) connection for real-time updates.
10114 Args:
10115 request (Request): The incoming HTTP request.
10116 user (str): Authenticated username.
10118 Returns:
10119 StreamingResponse: A streaming response that keeps the connection
10120 open and pushes events to the client.
10122 Raises:
10123 HTTPException: Returned with **500 Internal Server Error** if the SSE connection cannot be established or an unexpected error occurs while creating the transport.
10124 asyncio.CancelledError: If the request is cancelled during SSE setup.
10125 """
10126 try:
10127 logger.debug("User %s requested SSE connection", user)
10128 base_url = update_url_protocol(request)
10130 # SSE transport generates its own session_id - server-initiated, not client-provided
10131 transport = SSETransport(base_url=base_url)
10132 await transport.connect()
10133 await session_registry.add_session(transport.session_id, transport)
10134 await session_registry.set_session_owner(transport.session_id, get_user_email(user))
10136 # Defensive cleanup callback - runs immediately on client disconnect
10137 async def on_disconnect_cleanup() -> None:
10138 """Clean up session when SSE client disconnects."""
10139 try:
10140 await session_registry.remove_session(transport.session_id)
10141 logger.debug("Defensive session cleanup completed: %s", transport.session_id)
10142 except Exception as e:
10143 logger.warning("Defensive session cleanup failed for %s: %s", transport.session_id, e)
10145 # Extract auth token from request (header OR cookie, like get_current_user_with_permissions)
10146 auth_token = None
10147 auth_header = request.headers.get("authorization", "")
10148 if auth_header.lower().startswith("bearer "):
10149 auth_token = auth_header[7:]
10150 elif hasattr(request, "cookies") and request.cookies:
10151 # Cookie auth (admin UI sessions)
10152 auth_token = request.cookies.get("jwt_token") or request.cookies.get("access_token")
10154 # Extract and normalize token teams
10155 # Returns None if no JWT payload (non-JWT auth), or list if JWT exists
10156 # SECURITY: Preserve None vs [] distinction for admin bypass:
10157 # - None: unrestricted (admin keeps bypass, non-admin gets their accessible resources)
10158 # - []: public-only (admin bypass disabled)
10159 # - [...]: team-scoped access
10160 token_teams = _get_token_teams_from_request(request)
10162 # Preserve is_admin from user object (for cookie-authenticated admins)
10163 is_admin = False
10164 if hasattr(user, "is_admin"):
10165 is_admin = getattr(user, "is_admin", False)
10166 elif isinstance(user, dict):
10167 is_admin = user.get("is_admin", False) or user.get("user", {}).get("is_admin", False)
10169 # Create enriched user dict
10170 user_with_token = dict(user) if isinstance(user, dict) else {"email": getattr(user, "email", str(user))}
10171 user_with_token["auth_token"] = auth_token
10172 user_with_token["token_teams"] = token_teams # None for unrestricted, [] for public-only, [...] for team-scoped
10173 user_with_token["is_admin"] = is_admin # Preserve admin status for fallback token
10175 # Capture passthrough headers from the original SSE request for loopback /rpc calls.
10176 # Without this, headers like X-Upstream-Authorization are silently dropped. See #3640.
10177 # First-Party
10178 from mcpgateway.utils.passthrough_headers import safe_extract_headers_for_loopback # pylint: disable=import-outside-toplevel
10180 user_with_token["_passthrough_headers"] = safe_extract_headers_for_loopback(dict(request.headers), "SSE")
10182 # Create respond task and register for cancellation on disconnect
10183 respond_task = asyncio.create_task(session_registry.respond(None, user_with_token, session_id=transport.session_id))
10184 session_registry.register_respond_task(transport.session_id, respond_task)
10186 try:
10187 response = await transport.create_sse_response(request, on_disconnect_callback=on_disconnect_cleanup)
10188 except asyncio.CancelledError:
10189 # Request cancelled - still need to clean up to prevent orphaned tasks
10190 logger.debug("SSE request cancelled for %s, cleaning up", transport.session_id)
10191 try:
10192 await session_registry.remove_session(transport.session_id)
10193 except Exception as cleanup_error:
10194 logger.warning("Cleanup after SSE cancellation failed: %s", cleanup_error)
10195 raise # Re-raise CancelledError
10196 except Exception as sse_error:
10197 # CRITICAL: Cleanup on failure - respond task and session would be orphaned otherwise
10198 logger.error("create_sse_response failed for %s: %s", transport.session_id, sse_error)
10199 try:
10200 await session_registry.remove_session(transport.session_id)
10201 except Exception as cleanup_error:
10202 logger.warning("Cleanup after SSE failure also failed: %s", cleanup_error)
10203 raise
10205 tasks = BackgroundTasks()
10206 tasks.add_task(session_registry.remove_session, transport.session_id)
10207 response.background = tasks
10208 logger.info("SSE connection established: %s", transport.session_id)
10209 return response
10210 except Exception as e:
10211 logger.error("SSE connection error: %s", e)
10212 raise HTTPException(status_code=500, detail="SSE connection failed")
10215@utility_router.post("/message")
10216@require_permission("tools.execute")
10217async def utility_message_endpoint(request: Request, user=Depends(get_current_user_with_permissions)):
10218 """
10219 Handle a JSON-RPC message directed to a specific SSE session.
10221 Args:
10222 request (Request): Incoming request containing the JSON-RPC payload.
10223 user (str): Authenticated user.
10225 Returns:
10226 JSONResponse: ``{"status": "success"}`` with HTTP 202 on success.
10228 Raises:
10229 HTTPException: * **400 Bad Request** - ``session_id`` query parameter is missing or the payload cannot be parsed as JSON.
10230 * **500 Internal Server Error** - An unexpected error occurs while broadcasting the message.
10231 """
10232 try:
10233 logger.debug("User %s sent a message to SSE session", user)
10235 session_id = request.query_params.get("session_id")
10236 if not session_id:
10237 logger.error("Missing session_id in message request")
10238 raise HTTPException(status_code=400, detail="Missing session_id")
10239 set_trace_session_id(session_id)
10241 await _assert_session_owner_or_admin(request, user, session_id)
10243 message = await _read_request_json(request)
10245 await session_registry.broadcast(
10246 session_id=session_id,
10247 message=message,
10248 )
10250 return ORJSONResponse(content={"status": "success"}, status_code=202)
10252 except ValueError as e:
10253 logger.error("Invalid message format: %s", e)
10254 raise HTTPException(status_code=400, detail=str(e))
10255 except HTTPException:
10256 raise
10257 except Exception as exc:
10258 logger.error("Message handling error: %s", exc)
10259 raise HTTPException(status_code=500, detail="Failed to process message")
10262@utility_router.post("/logging/setLevel")
10263@require_permission("admin.system_config")
10264async def set_log_level(request: Request, user=Depends(get_current_user_with_permissions)) -> None:
10265 """
10266 Update the server's log level at runtime.
10268 Args:
10269 request: HTTP request with log level JSON body.
10270 user: Authenticated user.
10271 """
10272 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested to set log level")
10273 body = await _read_request_json(request)
10274 level = LogLevel(body["level"])
10275 await logging_service.set_level(level)
10278####################
10279# Metrics #
10280####################
10281@metrics_router.get("", response_model=MetricsResponse)
10282@require_permission("admin.metrics")
10283async def get_metrics(db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> MetricsResponse:
10284 """
10285 Retrieve aggregated metrics for all entity types (Tools, Resources, Servers, Prompts, A2A Agents).
10287 Args:
10288 db: Database session
10289 user: Authenticated user
10291 Returns:
10292 A MetricsResponse with keys for each entity type and their aggregated metrics.
10293 """
10294 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested aggregated metrics")
10295 tool_metrics = await tool_service.aggregate_metrics(db)
10296 resource_metrics = await resource_service.aggregate_metrics(db)
10297 server_metrics = await server_service.aggregate_metrics(db)
10298 prompt_metrics = await prompt_service.aggregate_metrics(db)
10300 kwargs = {
10301 "tools": tool_metrics,
10302 "resources": resource_metrics,
10303 "servers": server_metrics,
10304 "prompts": prompt_metrics,
10305 }
10307 if a2a_service and settings.mcpgateway_a2a_metrics_enabled:
10308 kwargs["a2a_agents"] = await a2a_service.aggregate_metrics(db)
10310 return MetricsResponse(**kwargs)
10313@metrics_router.post("/reset", response_model=dict)
10314@require_permission("admin.metrics")
10315async def reset_metrics(entity: Optional[str] = None, entity_id: Optional[int] = None, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)) -> dict:
10316 """
10317 Reset metrics for a specific entity type and optionally a specific entity ID,
10318 or perform a global reset if no entity is specified.
10320 Args:
10321 entity: One of "tool", "resource", "server", "prompt", "a2a_agent", or None for global reset.
10322 entity_id: Specific entity ID to reset metrics for (optional).
10323 db: Database session
10324 user: Authenticated user
10326 Returns:
10327 A success message in a dictionary.
10329 Raises:
10330 HTTPException: If an invalid entity type is specified.
10331 """
10332 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested metrics reset for entity: {entity}, id: {entity_id}")
10333 if entity is None:
10334 # Global reset
10335 await tool_service.reset_metrics(db)
10336 await resource_service.reset_metrics(db)
10337 await server_service.reset_metrics(db)
10338 await prompt_service.reset_metrics(db)
10339 if a2a_service and settings.mcpgateway_a2a_metrics_enabled:
10340 await a2a_service.reset_metrics(db)
10341 elif entity.lower() == "tool":
10342 await tool_service.reset_metrics(db, entity_id)
10343 elif entity.lower() == "resource":
10344 await resource_service.reset_metrics(db)
10345 elif entity.lower() == "server":
10346 await server_service.reset_metrics(db)
10347 elif entity.lower() == "prompt":
10348 await prompt_service.reset_metrics(db)
10349 elif entity.lower() in ("a2a_agent", "a2a"):
10350 if a2a_service and settings.mcpgateway_a2a_metrics_enabled:
10351 await a2a_service.reset_metrics(db, str(entity_id) if entity_id is not None else None)
10352 else:
10353 raise HTTPException(status_code=400, detail="A2A features are disabled")
10354 else:
10355 raise HTTPException(status_code=400, detail="Invalid entity type for metrics reset")
10356 return {"status": "success", "message": f"Metrics reset for {entity if entity else 'all entities'}"}
10359####################
10360# Healthcheck #
10361####################
10362@app.get("/health")
10363def healthcheck(response: Response = None):
10364 """
10365 Perform a basic health check to verify database connectivity.
10367 Sync function so FastAPI runs it in a threadpool, avoiding event loop blocking.
10368 Uses a dedicated session to avoid cross-thread issues and double-commit
10369 from get_db dependency. All DB operations happen in the same thread.
10371 Args:
10372 response: Optional response object used to attach runtime-mode headers.
10374 Returns:
10375 A dictionary with the health status and optional error message.
10376 """
10377 db = SessionLocal()
10378 try:
10379 db.execute(text("SELECT 1"))
10380 # Explicitly commit to release PgBouncer backend connection in transaction mode.
10381 db.commit()
10382 if response is not None:
10383 _apply_runtime_mode_headers(response)
10384 return {"status": "healthy", "mcp_runtime": _mcp_runtime_status_payload()}
10385 except Exception as e:
10386 # Rollback, then invalidate if rollback fails (mirrors get_db cleanup).
10387 try:
10388 db.rollback()
10389 except Exception:
10390 try:
10391 db.invalidate()
10392 except Exception:
10393 pass # nosec B110 - Best effort cleanup on connection failure
10394 error_message = f"Database connection error: {str(e)}"
10395 logger.error(error_message)
10396 if response is not None:
10397 _apply_runtime_mode_headers(response)
10398 return {"status": "unhealthy", "error": error_message, "mcp_runtime": _mcp_runtime_status_payload()}
10399 finally:
10400 db.close()
10403@app.get("/ready")
10404async def readiness_check():
10405 """
10406 Perform a readiness check to verify if the application is ready to receive traffic.
10408 Creates and manages its own session inside the worker thread to ensure all DB
10409 operations (create, execute, commit, rollback, close) happen in the same thread.
10410 This avoids cross-thread session issues and double-commit from get_db.
10412 Returns:
10413 JSONResponse with status 200 if ready, 503 if not.
10414 """
10416 def _check_db() -> str | None:
10417 """Check database connectivity by executing a simple query.
10419 Returns:
10420 None if successful, error message string if failed.
10421 """
10422 # Create session in this thread - all DB operations stay in the same thread.
10423 db = SessionLocal()
10424 try:
10425 db.execute(text("SELECT 1"))
10426 # Explicitly commit to release PgBouncer backend connection.
10427 db.commit()
10428 return None # Success
10429 except Exception as e:
10430 # Rollback, then invalidate if rollback fails (mirrors get_db cleanup).
10431 try:
10432 db.rollback()
10433 except Exception:
10434 try:
10435 db.invalidate()
10436 except Exception:
10437 pass # nosec B110 - Best effort cleanup on connection failure
10438 return str(e)
10439 finally:
10440 db.close()
10442 # Run the blocking DB check in a thread to avoid blocking the event loop.
10443 error = await asyncio.to_thread(_check_db)
10444 if error:
10445 error_message = f"Readiness check failed: {error}"
10446 logger.error(error_message)
10447 response = ORJSONResponse(content={"status": "not ready", "error": error_message, "mcp_runtime": _mcp_runtime_status_payload()}, status_code=503)
10448 _apply_runtime_mode_headers(response)
10449 return response
10450 response = ORJSONResponse(content={"status": "ready", "mcp_runtime": _mcp_runtime_status_payload()}, status_code=200)
10451 _apply_runtime_mode_headers(response)
10452 return response
10455@app.get("/health/security", tags=["health"])
10456async def security_health(request: Request, _user=Depends(require_admin_auth)): # pylint: disable=unused-argument
10457 """
10458 Get the security configuration health status (admin only).
10460 Args:
10461 request (Request): The incoming HTTP request.
10462 _user: Authenticated admin user (injected by require_admin_auth).
10464 Returns:
10465 dict: A dictionary containing the overall security health status, score,
10466 individual checks, warning count, and timestamp.
10467 """
10468 security_status = settings.get_security_status()
10470 # Determine overall health
10471 score = security_status["security_score"]
10472 is_healthy = score >= 60 # Minimum acceptable score
10474 # Build response
10475 response = {
10476 "status": "healthy" if is_healthy else "unhealthy",
10477 "score": score,
10478 "checks": {
10479 "authentication": security_status["auth_enabled"],
10480 "secure_secrets": security_status["secure_secrets"],
10481 "ssl_verification": security_status["ssl_verification"],
10482 "debug_disabled": security_status["debug_disabled"],
10483 "cors_restricted": security_status["cors_restricted"],
10484 "ui_protected": security_status["ui_protected"],
10485 },
10486 "warning_count": len(security_status["warnings"]),
10487 "timestamp": datetime.now(timezone.utc).isoformat(),
10488 }
10490 # Include warnings for admin users
10491 if security_status["warnings"]:
10492 response["warnings"] = security_status["warnings"]
10494 return response
10497####################
10498# Tag Endpoints #
10499####################
10502@tag_router.get("", response_model=List[TagInfo])
10503@tag_router.get("/", response_model=List[TagInfo])
10504@require_permission("tags.read")
10505async def list_tags(
10506 request: Request,
10507 entity_types: Optional[str] = None,
10508 include_entities: bool = False,
10509 db: Session = Depends(get_db),
10510 user=Depends(get_current_user_with_permissions),
10511) -> List[TagInfo]:
10512 """
10513 Retrieve all unique tags across specified entity types.
10515 Args:
10516 request: FastAPI request object used to derive token/team visibility scope
10517 entity_types: Comma-separated list of entity types to filter by
10518 (e.g., "tools,resources,prompts,servers,gateways").
10519 If not provided, returns tags from all entity types.
10520 include_entities: Whether to include the list of entities that have each tag
10521 db: Database session
10522 user: Authenticated user
10524 Returns:
10525 List of TagInfo objects containing tag names, statistics, and optionally entities
10527 Raises:
10528 HTTPException: If tag retrieval fails
10529 """
10530 # Parse entity types parameter if provided
10531 entity_types_list = None
10532 if entity_types:
10533 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()]
10535 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is retrieving tags for entity types: {entity_types_list}, include_entities: {include_entities}")
10537 try:
10538 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
10539 if is_admin and token_teams is None:
10540 user_email = None
10541 elif token_teams is None:
10542 token_teams = []
10544 tags = await tag_service.get_all_tags(
10545 db,
10546 entity_types=entity_types_list,
10547 include_entities=include_entities,
10548 user_email=user_email,
10549 token_teams=token_teams,
10550 )
10551 return tags
10552 except Exception as e:
10553 logger.error(f"Failed to retrieve tags: {str(e)}")
10554 raise HTTPException(status_code=500, detail=f"Failed to retrieve tags: {str(e)}")
10557@tag_router.get("/{tag_name}/entities", response_model=List[TaggedEntity])
10558@require_permission("tags.read")
10559async def get_entities_by_tag(
10560 request: Request,
10561 tag_name: str,
10562 entity_types: Optional[str] = None,
10563 db: Session = Depends(get_db),
10564 user=Depends(get_current_user_with_permissions),
10565) -> List[TaggedEntity]:
10566 """
10567 Get all entities that have a specific tag.
10569 Args:
10570 request: FastAPI request object used to derive token/team visibility scope
10571 tag_name: The tag to search for
10572 entity_types: Comma-separated list of entity types to filter by
10573 (e.g., "tools,resources,prompts,servers,gateways").
10574 If not provided, returns entities from all types.
10575 db: Database session
10576 user: Authenticated user
10578 Returns:
10579 List of TaggedEntity objects
10581 Raises:
10582 HTTPException: If entity retrieval fails
10583 """
10584 # Parse entity types parameter if provided
10585 entity_types_list = None
10586 if entity_types:
10587 entity_types_list = [et.strip().lower() for et in entity_types.split(",") if et.strip()]
10589 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} is retrieving entities for tag '{tag_name}' with entity types: {entity_types_list}")
10591 try:
10592 user_email, token_teams, is_admin = _get_rpc_filter_context(request, user)
10593 if is_admin and token_teams is None:
10594 user_email = None
10595 elif token_teams is None:
10596 token_teams = []
10598 entities = await tag_service.get_entities_by_tag(
10599 db,
10600 tag_name=tag_name,
10601 entity_types=entity_types_list,
10602 user_email=user_email,
10603 token_teams=token_teams,
10604 )
10605 return entities
10606 except Exception as e:
10607 logger.error(f"Failed to retrieve entities for tag '{tag_name}': {str(e)}")
10608 raise HTTPException(status_code=500, detail=f"Failed to retrieve entities: {str(e)}")
10611####################
10612# Export/Import #
10613####################
10616@export_import_router.get("/export", response_model=Dict[str, Any])
10617@require_permission("admin.export")
10618async def export_configuration(
10619 request: Request, # pylint: disable=unused-argument
10620 export_format: str = "json", # pylint: disable=unused-argument
10621 types: Optional[str] = None,
10622 exclude_types: Optional[str] = None,
10623 tags: Optional[str] = None,
10624 include_inactive: bool = False,
10625 include_dependencies: bool = True,
10626 db: Session = Depends(get_db),
10627 user=Depends(get_current_user_with_permissions),
10628) -> Dict[str, Any]:
10629 """
10630 Export gateway configuration to JSON format.
10632 Args:
10633 request: FastAPI request object for extracting root path
10634 export_format: Export format (currently only 'json' supported)
10635 types: Comma-separated list of entity types to include (tools,gateways,servers,prompts,resources,roots)
10636 exclude_types: Comma-separated list of entity types to exclude
10637 tags: Comma-separated list of tags to filter by
10638 include_inactive: Whether to include inactive entities
10639 include_dependencies: Whether to include dependent entities
10640 db: Database session
10641 user: Authenticated user
10643 Returns:
10644 Export data in the specified format
10646 Raises:
10647 HTTPException: If export fails
10648 """
10649 try:
10650 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested configuration export")
10651 username: Optional[str] = None
10652 # Parse parameters
10653 include_types = None
10654 if types:
10655 include_types = [t.strip() for t in types.split(",") if t.strip()]
10657 exclude_types_list = None
10658 if exclude_types:
10659 exclude_types_list = [t.strip() for t in exclude_types.split(",") if t.strip()]
10661 tags_list = None
10662 if tags:
10663 tags_list = [t.strip() for t in tags.split(",") if t.strip()]
10665 # Extract username from user (which is now an EmailUser object)
10666 if hasattr(user, "email"):
10667 username = getattr(user, "email", None)
10668 elif isinstance(user, dict):
10669 username = user.get("email", None)
10670 else:
10671 username = None
10673 # Get root path for URL construction - prefer configured APP_ROOT_PATH
10674 root_path = settings.app_root_path
10676 # Derive team-scoped visibility from the requesting user's token
10677 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user)
10679 # Perform export
10680 export_data = await export_service.export_configuration(
10681 db=db,
10682 include_types=include_types,
10683 exclude_types=exclude_types_list,
10684 tags=tags_list,
10685 include_inactive=include_inactive,
10686 include_dependencies=include_dependencies,
10687 exported_by=username or "unknown",
10688 root_path=root_path,
10689 user_email=scoped_user_email,
10690 token_teams=scoped_token_teams,
10691 )
10693 return export_data
10695 except ExportError as e:
10696 logger.error(f"Export failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10697 raise HTTPException(status_code=400, detail=str(e))
10698 except Exception as e:
10699 logger.error(f"Unexpected export error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10700 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
10703@export_import_router.post("/export/selective", response_model=Dict[str, Any])
10704@require_permission("admin.export")
10705async def export_selective_configuration(
10706 request: Request, entity_selections: Dict[str, List[str]] = Body(...), include_dependencies: bool = True, db: Session = Depends(get_db), user=Depends(get_current_user_with_permissions)
10707) -> Dict[str, Any]:
10708 """
10709 Export specific entities by their IDs/names.
10711 Args:
10712 request: FastAPI request object for token scope context
10713 entity_selections: Dict mapping entity types to lists of IDs/names to export
10714 include_dependencies: Whether to include dependent entities
10715 db: Database session
10716 user: Authenticated user
10718 Returns:
10719 Selective export data
10721 Raises:
10722 HTTPException: If export fails
10724 Example request body:
10725 {
10726 "tools": ["tool1", "tool2"],
10727 "servers": ["server1"],
10728 "prompts": ["prompt1"]
10729 }
10730 """
10731 try:
10732 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested selective configuration export")
10734 username: Optional[str] = None
10735 # Extract username from user (which is now an EmailUser object)
10736 if hasattr(user, "email"):
10737 username = getattr(user, "email", None)
10738 elif isinstance(user, dict):
10739 username = user.get("email")
10741 # Get root path for URL construction - prefer configured APP_ROOT_PATH
10742 root_path = settings.app_root_path
10744 # Derive team-scoped visibility from the requesting user's token
10745 scoped_user_email, scoped_token_teams = _get_scoped_resource_access_context(request, user)
10747 export_data = await export_service.export_selective(
10748 db=db,
10749 entity_selections=entity_selections,
10750 include_dependencies=include_dependencies,
10751 exported_by=username or "unknown",
10752 root_path=root_path,
10753 user_email=scoped_user_email,
10754 token_teams=scoped_token_teams,
10755 )
10757 return export_data
10759 except ExportError as e:
10760 logger.error(f"Selective export failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10761 raise HTTPException(status_code=400, detail=str(e))
10762 except Exception as e:
10763 logger.error(f"Unexpected selective export error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10764 raise HTTPException(status_code=500, detail=f"Export failed: {str(e)}")
10767@export_import_router.post("/import", response_model=Dict[str, Any])
10768@require_permission("admin.import")
10769async def import_configuration(
10770 import_data: Dict[str, Any] = Body(...),
10771 conflict_strategy: str = Body("update"),
10772 dry_run: bool = Body(False),
10773 rekey_secret: Optional[str] = Body(None),
10774 selected_entities: Optional[Dict[str, List[str]]] = Body(None),
10775 db: Session = Depends(get_db),
10776 user=Depends(get_current_user_with_permissions),
10777) -> Dict[str, Any]:
10778 """
10779 Import configuration data with conflict resolution.
10781 Args:
10782 import_data: The configuration data to import
10783 conflict_strategy: How to handle conflicts: skip, update, rename, fail
10784 dry_run: If true, validate but don't make changes
10785 rekey_secret: New encryption secret for cross-environment imports
10786 selected_entities: Dict of entity types to specific entity names/ids to import
10787 db: Database session
10788 user: Authenticated user
10790 Returns:
10791 Import status and results
10793 Raises:
10794 HTTPException: If import fails or validation errors occur
10795 """
10796 try:
10797 if not import_data:
10798 raise HTTPException(status_code=400, detail="Missing 'import_data' in request body")
10800 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested configuration import (dry_run={dry_run})")
10802 # Validate conflict strategy
10803 try:
10804 strategy = ConflictStrategy(conflict_strategy.lower())
10805 except ValueError:
10806 raise HTTPException(status_code=400, detail=f"Invalid conflict strategy. Must be one of: {[s.value for s in list(ConflictStrategy)]}")
10808 # Extract username from user (which is now an EmailUser object)
10809 if hasattr(user, "email"):
10810 username = getattr(user, "email", None)
10811 elif isinstance(user, dict):
10812 username = user.get("email", None)
10813 else:
10814 username = None
10816 # Perform import
10817 import_status = await import_service.import_configuration(
10818 db=db, import_data=import_data, conflict_strategy=strategy, dry_run=dry_run, rekey_secret=rekey_secret, imported_by=username or "unknown", selected_entities=selected_entities
10819 )
10821 return import_status.to_dict()
10823 except HTTPException:
10824 raise
10825 except ImportValidationError as e:
10826 logger.error(f"Import validation failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10827 raise HTTPException(status_code=422, detail=f"Validation error: {str(e)}")
10828 except ImportConflictError as e:
10829 logger.error(f"Import conflict for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10830 raise HTTPException(status_code=409, detail=f"Conflict error: {str(e)}")
10831 except ImportServiceError as e:
10832 logger.error(f"Import failed for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10833 raise HTTPException(status_code=400, detail=str(e))
10834 except Exception as e:
10835 logger.error(f"Unexpected import error for user {SecurityValidator.sanitize_log_message(str(user))}: {str(e)}")
10836 raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
10839@export_import_router.get("/import/status/{import_id}", response_model=Dict[str, Any])
10840@require_permission("admin.import")
10841async def get_import_status(import_id: str, user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
10842 """
10843 Get the status of an import operation.
10845 Args:
10846 import_id: The import operation ID
10847 user: Authenticated user
10849 Returns:
10850 Import status information
10852 Raises:
10853 HTTPException: If import not found
10854 """
10855 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested import status for {import_id}")
10857 import_status = import_service.get_import_status(import_id)
10858 if not import_status:
10859 raise HTTPException(status_code=404, detail=f"Import {import_id} not found")
10861 return import_status.to_dict()
10864@export_import_router.get("/import/status", response_model=List[Dict[str, Any]])
10865@require_permission("admin.import")
10866async def list_import_statuses(user=Depends(get_current_user_with_permissions)) -> List[Dict[str, Any]]:
10867 """
10868 List all import operation statuses.
10870 Args:
10871 user: Authenticated user
10873 Returns:
10874 List of import status information
10875 """
10876 logger.debug(f"User {SecurityValidator.sanitize_log_message(str(user))} requested all import statuses")
10878 statuses = import_service.list_import_statuses()
10879 return [status.to_dict() for status in statuses]
10882@export_import_router.post("/import/cleanup", response_model=Dict[str, Any])
10883@require_permission("admin.import")
10884async def cleanup_import_statuses(max_age_hours: int = 24, user=Depends(get_current_user_with_permissions)) -> Dict[str, Any]:
10885 """
10886 Clean up completed import statuses older than specified age.
10888 Args:
10889 max_age_hours: Maximum age in hours for keeping completed imports
10890 user: Authenticated user
10892 Returns:
10893 Cleanup results
10894 """
10895 logger.info(f"User {SecurityValidator.sanitize_log_message(str(user))} requested import status cleanup (max_age_hours={max_age_hours})")
10897 removed_count = import_service.cleanup_completed_imports(max_age_hours)
10898 return {"status": "success", "message": f"Cleaned up {removed_count} completed import statuses", "removed_count": removed_count}
10901# Mount static files
10902# app.mount("/static", StaticFiles(directory=str(settings.static_dir)), name="static")
10904# Include routers
10905app.include_router(version_router)
10906app.include_router(protocol_router)
10907app.include_router(tool_router)
10908app.include_router(resource_router)
10909app.include_router(prompt_router)
10910app.include_router(gateway_router)
10911app.include_router(root_router)
10912app.include_router(utility_router)
10913app.include_router(server_router)
10914app.include_router(server_well_known_router, prefix="/servers")
10915app.include_router(metrics_router)
10916app.include_router(tag_router)
10917app.include_router(export_import_router)
10919# Include log search router if structured logging is enabled
10920if getattr(settings, "structured_logging_enabled", True):
10921 try:
10922 # First-Party
10923 from mcpgateway.routers.log_search import router as log_search_router
10925 app.include_router(log_search_router)
10926 logger.info("Log search router included - structured logging enabled")
10927 except ImportError as e:
10928 logger.warning(f"Failed to import log search router: {e}")
10929else:
10930 logger.info("Log search router not included - structured logging disabled")
10932# Conditionally include observability router if enabled
10933if settings.observability_enabled:
10934 # First-Party
10935 from mcpgateway.routers.observability import router as observability_router
10937 app.include_router(observability_router)
10938 logger.info("Observability router included - observability API endpoints enabled")
10939else:
10940 logger.info("Observability router not included - observability disabled")
10942# Conditionally include metrics maintenance router if cleanup or rollup is enabled
10943if settings.metrics_cleanup_enabled or settings.metrics_rollup_enabled:
10944 # First-Party
10945 from mcpgateway.routers.metrics_maintenance import router as metrics_maintenance_router
10947 app.include_router(metrics_maintenance_router)
10948 logger.info("Metrics maintenance router included - cleanup/rollup API endpoints enabled")
10950# Conditionally include A2A router if A2A features are enabled
10951if settings.mcpgateway_a2a_enabled:
10952 app.include_router(a2a_router)
10953 logger.info("A2A router included - A2A features enabled")
10954else:
10955 logger.info("A2A router not included - A2A features disabled")
10957app.include_router(well_known_router)
10959# Include Email Authentication router if enabled
10960if settings.email_auth_enabled:
10961 try:
10962 # First-Party
10963 from mcpgateway.routers.auth import auth_router
10964 from mcpgateway.routers.email_auth import email_auth_router
10966 app.include_router(email_auth_router, prefix="/auth/email", tags=["Email Authentication"])
10967 app.include_router(auth_router, tags=["Main Authentication"])
10968 logger.info("Authentication routers included - Auth enabled")
10970 # Include SSO router if enabled
10971 if settings.sso_enabled:
10972 try:
10973 # First-Party
10974 from mcpgateway.routers.sso import sso_router
10976 app.include_router(sso_router, tags=["SSO Authentication"])
10977 logger.info("SSO router included - SSO authentication enabled")
10978 except ImportError as e:
10979 logger.error(f"SSO router not available: {e}")
10980 else:
10981 logger.info("SSO router not included - SSO authentication disabled")
10982 except ImportError as e:
10983 logger.error(f"Authentication routers not available: {e}")
10984else:
10985 logger.info("Email authentication router not included - Email auth disabled")
10987# Include Team Management router if email auth is enabled
10988if settings.email_auth_enabled:
10989 try:
10990 # First-Party
10991 from mcpgateway.routers.teams import teams_router
10993 app.include_router(teams_router, prefix="/teams", tags=["Teams"])
10994 logger.info("Team management router included - Teams enabled with email auth")
10995 except ImportError as e:
10996 logger.error(f"Team management router not available: {e}")
10997else:
10998 logger.info("Team management router not included - Email auth disabled")
11000# Include JWT Token Catalog router if email auth is enabled
11001if settings.email_auth_enabled:
11002 try:
11003 # First-Party
11004 from mcpgateway.routers.tokens import router as tokens_router
11006 app.include_router(tokens_router, tags=["JWT Token Catalog"])
11007 logger.info("JWT Token Catalog router included - Token management enabled with email auth")
11008 except ImportError as e:
11009 logger.error(f"JWT Token Catalog router not available: {e}")
11010else:
11011 logger.info("JWT Token Catalog router not included - Email auth disabled")
11013# Include RBAC router if email auth is enabled
11014if settings.email_auth_enabled:
11015 try:
11016 # First-Party
11017 from mcpgateway.routers.rbac import router as rbac_router
11019 app.include_router(rbac_router, tags=["RBAC"])
11020 logger.info("RBAC router included - Role-based access control enabled")
11021 except ImportError as e:
11022 logger.error(f"RBAC router not available: {e}")
11023else:
11024 logger.info("RBAC router not included - Email auth disabled")
11026# Include OAuth router
11027try:
11028 # First-Party
11029 from mcpgateway.routers.oauth_router import oauth_router
11031 app.include_router(oauth_router)
11032 logger.info("OAuth router included")
11033except ImportError:
11034 logger.debug("OAuth router not available")
11036# Include reverse proxy router if enabled
11037if settings.mcpgateway_reverse_proxy_enabled:
11038 try:
11039 # First-Party
11040 from mcpgateway.routers.reverse_proxy import router as reverse_proxy_router
11042 app.include_router(reverse_proxy_router)
11043 logger.info("Reverse proxy router included")
11044 except ImportError:
11045 logger.debug("Reverse proxy router not available")
11046else:
11047 logger.info("Reverse proxy router not included - feature disabled")
11049# Include LLMChat router
11050if settings.llmchat_enabled:
11051 try:
11052 # First-Party
11053 from mcpgateway.routers.llmchat_router import llmchat_router
11055 app.include_router(llmchat_router)
11056 logger.info("LLM Chat router included")
11057 except ImportError:
11058 logger.debug("LLM Chat router not available")
11060 # Include LLM configuration and proxy routers (internal API)
11061 try:
11062 # First-Party
11063 from mcpgateway.routers.llm_admin_router import llm_admin_router
11064 from mcpgateway.routers.llm_config_router import llm_config_router
11065 from mcpgateway.routers.llm_proxy_router import llm_proxy_router
11067 app.include_router(llm_config_router, prefix="/llm", tags=["LLM Configuration"])
11068 app.include_router(llm_proxy_router, prefix=settings.llm_api_prefix, tags=["LLM Proxy"])
11069 app.include_router(llm_admin_router, prefix="/admin/llm", tags=["LLM Admin"])
11070 logger.info("LLM configuration, proxy, and admin routers included")
11071 except ImportError as e:
11072 logger.debug(f"LLM routers not available: {e}")
11074# Include Toolops router
11075if settings.toolops_enabled:
11076 try:
11077 # First-Party
11078 from mcpgateway.routers.toolops_router import toolops_router
11080 app.include_router(toolops_router)
11081 logger.info("Toolops router included")
11082 except ImportError:
11083 logger.debug("Toolops router not available")
11085# Cancellation router (tool cancellation endpoints)
11086if settings.mcpgateway_tool_cancellation_enabled:
11087 try:
11088 # First-Party
11089 from mcpgateway.routers.cancellation_router import router as cancellation_router
11091 app.include_router(cancellation_router)
11092 logger.info("Cancellation router included (tool cancellation enabled)")
11093 except ImportError:
11094 logger.debug("Orchestrate router not available")
11095else:
11096 logger.info("Tool cancellation feature disabled - cancellation endpoints not available")
11098# Feature flags for admin UI and API
11099UI_ENABLED = settings.mcpgateway_ui_enabled
11100ADMIN_API_ENABLED = settings.mcpgateway_admin_api_enabled
11101logger.info(f"Admin UI enabled: {UI_ENABLED}")
11102logger.info(f"Admin API enabled: {ADMIN_API_ENABLED}")
11104# Conditional UI and admin API handling
11105if ADMIN_API_ENABLED:
11106 logger.info("Including admin_router - Admin API enabled")
11107 app.include_router(admin_router) # Admin routes imported from admin.py
11109 # Validate section-to-permission mapping consistency at startup
11110 # First-Party
11111 from mcpgateway.admin import validate_section_permissions
11113 validate_section_permissions(admin_router)
11114else:
11115 logger.warning("Admin API routes not mounted - Admin API disabled via MCPGATEWAY_ADMIN_API_ENABLED=False")
11118class MCPRuntimeHeaderTransportWrapper:
11119 """Annotate Python-owned MCP transport responses with the active runtime marker."""
11121 def __init__(self, transport_app, *, runtime_name: str) -> None:
11122 """Wrap an MCP transport app and stamp a runtime header on responses.
11124 Args:
11125 transport_app: Underlying MCP transport app.
11126 runtime_name: Runtime label to expose via response headers.
11127 """
11128 self.transport_app = transport_app
11129 self.runtime_name = runtime_name.encode("ascii")
11131 async def handle_streamable_http(self, scope, receive, send):
11132 """Forward an MCP request while ensuring the runtime marker header is present.
11134 Args:
11135 scope: Incoming ASGI scope.
11136 receive: ASGI receive callable.
11137 send: ASGI send callable.
11138 """
11140 async def _send_with_runtime_header(message):
11141 """Attach MCP runtime mode headers before sending the ASGI event downstream.
11143 Args:
11144 message: Outgoing ASGI message emitted by the wrapped application.
11145 """
11146 if message.get("type") == "http.response.start":
11147 headers = list(message.get("headers") or [])
11148 if not any(isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-runtime" for item in headers):
11149 headers.append((b"x-contextforge-mcp-runtime", self.runtime_name))
11150 if not any(
11151 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-session-core" for item in headers
11152 ):
11153 headers.append((b"x-contextforge-mcp-session-core", _current_mcp_session_core_mode().encode("ascii")))
11154 if not any(isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-resume-core" for item in headers):
11155 headers.append((b"x-contextforge-mcp-resume-core", _current_mcp_resume_core_mode().encode("ascii")))
11156 if not any(
11157 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-live-stream-core" for item in headers
11158 ):
11159 headers.append((b"x-contextforge-mcp-live-stream-core", _current_mcp_live_stream_core_mode().encode("ascii")))
11160 if not any(
11161 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-affinity-core" for item in headers
11162 ):
11163 headers.append((b"x-contextforge-mcp-affinity-core", _current_mcp_affinity_core_mode().encode("ascii")))
11164 if not any(
11165 isinstance(item, (tuple, list)) and len(item) == 2 and isinstance(item[0], (bytes, bytearray)) and item[0].lower() == b"x-contextforge-mcp-session-auth-reuse" for item in headers
11166 ):
11167 headers.append((b"x-contextforge-mcp-session-auth-reuse", _current_mcp_session_auth_reuse_mode().encode("ascii")))
11168 message = dict(message)
11169 message["headers"] = headers
11170 await send(message)
11172 await self.transport_app.handle_streamable_http(scope, receive, _send_with_runtime_header)
11175def _build_mcp_transport_app():
11176 """Choose the MCP transport app for the mounted /mcp path.
11178 Returns:
11179 Transport app object that should be mounted at the public ``/mcp`` path.
11180 """
11181 if _should_mount_public_rust_transport():
11182 logger.warning(
11183 "MCP runtime mode: %s. GET/POST/DELETE /mcp requests will be proxied to %s. MCP session core mode: %s. MCP replay/resume core mode: %s. MCP live stream core mode: %s. MCP affinity core mode: %s. MCP session auth reuse mode: %s.",
11184 _current_mcp_runtime_mode(),
11185 settings.experimental_rust_mcp_runtime_uds or settings.experimental_rust_mcp_runtime_url,
11186 _current_mcp_session_core_mode(),
11187 _current_mcp_resume_core_mode(),
11188 _current_mcp_live_stream_core_mode(),
11189 _current_mcp_affinity_core_mode(),
11190 _current_mcp_session_auth_reuse_mode(),
11191 )
11192 return RustMCPRuntimeProxy(streamable_http_session.handle_streamable_http)
11194 if settings.experimental_rust_mcp_runtime_enabled:
11195 logger.warning(
11196 "MCP runtime mode: %s. Rust sidecar remains enabled, but public /mcp stays on the Python transport because MCP session auth reuse is disabled. MCP session core mode: %s. MCP replay/resume core mode: %s. MCP live stream core mode: %s. MCP affinity core mode: %s. MCP session auth reuse mode: %s.",
11197 _current_mcp_runtime_mode(),
11198 _current_mcp_session_core_mode(),
11199 _current_mcp_resume_core_mode(),
11200 _current_mcp_live_stream_core_mode(),
11201 _current_mcp_affinity_core_mode(),
11202 _current_mcp_session_auth_reuse_mode(),
11203 )
11204 return MCPRuntimeHeaderTransportWrapper(streamable_http_session, runtime_name="python")
11206 if _rust_build_included():
11207 logger.warning(
11208 "MCP runtime mode: %s. Rust MCP artifacts are present in this image, but EXPERIMENTAL_RUST_MCP_RUNTIME_ENABLED=false so /mcp remains on the Python transport. Set RUST_MCP_MODE=edge or RUST_MCP_MODE=full to activate the Rust runtime with the simple env flow.",
11209 _current_mcp_runtime_mode(),
11210 )
11211 else:
11212 logger.info("MCP runtime mode: %s. /mcp is mounted on the Python transport.", _current_mcp_runtime_mode())
11214 return MCPRuntimeHeaderTransportWrapper(streamable_http_session, runtime_name="python")
11217class InternalTrustedMCPTransportBridge:
11218 """Trusted internal bridge from Rust MCP transport requests to the Python session manager."""
11220 def __init__(self, transport_app) -> None:
11221 """Store the underlying Python transport app used for trusted forwarding.
11223 Args:
11224 transport_app: Python transport app that ultimately owns session handling.
11225 """
11226 self.transport_app = transport_app
11228 async def handle_streamable_http(self, scope, receive, send):
11229 """Translate trusted Rust transport requests into Python session-manager calls.
11231 Args:
11232 scope: Incoming ASGI scope.
11233 receive: ASGI receive callable.
11234 send: ASGI send callable.
11235 """
11236 if scope.get("type") != "http":
11237 response = ORJSONResponse(status_code=404, content={"detail": "Not found"})
11238 await response(scope, receive, send)
11239 return
11241 method = str(scope.get("method", "GET")).upper()
11242 if method not in {"GET", "POST", "DELETE"}:
11243 response = ORJSONResponse(status_code=405, content={"detail": "Method not allowed"})
11244 await response(scope, receive, send)
11245 return
11247 request = Request(scope, receive=receive)
11248 try:
11249 _build_internal_mcp_forwarded_user(request)
11250 except HTTPException as exc:
11251 response = ORJSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
11252 await response(scope, receive, send)
11253 return
11255 auth_context = _get_internal_mcp_auth_context(request) or {}
11256 server_id = request.headers.get("x-contextforge-server-id")
11257 forwarded_scope = dict(scope)
11258 forwarded_scope["path"] = "/mcp/"
11259 forwarded_scope["modified_path"] = f"/servers/{server_id}/mcp" if server_id else "/mcp/"
11260 forwarded_auth_method = auth_context.get("auth_method") or "mcp_internal_forward"
11262 token = user_context_var.set(auth_context)
11263 try:
11264 set_trace_context_from_teams(
11265 auth_context.get("teams"),
11266 user_email=auth_context.get("email"),
11267 is_admin=bool(auth_context.get("permission_is_admin", auth_context.get("is_admin", False))),
11268 auth_method=forwarded_auth_method,
11269 team_name=auth_context.get("team_name"),
11270 )
11271 await self.transport_app.handle_streamable_http(forwarded_scope, receive, send)
11272 finally:
11273 user_context_var.reset(token)
11274 clear_trace_context()
11277mcp_transport_app = _build_mcp_transport_app()
11278internal_trusted_mcp_transport = InternalTrustedMCPTransportBridge(streamable_http_session)
11280# Streamable http Mount
11281app.mount("/mcp", app=mcp_transport_app.handle_streamable_http)
11282app.mount("/_internal/mcp/transport", app=internal_trusted_mcp_transport.handle_streamable_http)
11284# Conditional static files mounting and root redirect
11285if UI_ENABLED:
11286 # Mount static files for UI
11287 logger.info("Mounting static files - UI enabled")
11288 try:
11289 # Create a sub-application for static files that will respect root_path
11290 static_app = StaticFiles(directory=str(settings.static_dir))
11291 STATIC_PATH = "/static"
11293 app.mount(
11294 STATIC_PATH,
11295 static_app,
11296 name="static",
11297 )
11298 logger.info("Static assets served from %s at %s", settings.static_dir, STATIC_PATH)
11299 except RuntimeError as exc:
11300 logger.warning(
11301 "Static dir %s not found - Admin UI disabled (%s)",
11302 settings.static_dir,
11303 exc,
11304 )
11306 # Redirect root path to admin UI
11307 @app.get("/")
11308 async def root_redirect():
11309 """
11310 Redirects the root path ("/") to "/admin/".
11312 Logs a debug message before redirecting.
11314 Returns:
11315 RedirectResponse: Redirects to /admin/.
11317 Raises:
11318 HTTPException: If there is an error during redirection.
11319 """
11320 logger.debug("Redirecting root path to /admin/")
11321 root_path = settings.app_root_path
11322 return RedirectResponse(f"{root_path}/admin/", status_code=303)
11323 # return RedirectResponse(request.url_for("admin_home"))
11325 # Redirect /favicon.ico to /static/favicon.ico for browser compatibility
11326 @app.get("/favicon.ico", include_in_schema=False)
11327 async def favicon_redirect() -> RedirectResponse:
11328 """Redirect /favicon.ico to /static/favicon.ico for browser compatibility.
11330 Returns:
11331 RedirectResponse: 301 redirect to /static/favicon.ico.
11332 """
11333 root_path = settings.app_root_path
11334 return RedirectResponse(f"{root_path}/static/favicon.ico", status_code=301)
11336else:
11337 # If UI is disabled, provide API info at root
11338 logger.warning("Static files not mounted - UI disabled via MCPGATEWAY_UI_ENABLED=False")
11340 @app.get("/")
11341 async def root_info():
11342 """
11343 Returns basic API information at the root path.
11345 Logs an info message indicating UI is disabled and provides details
11346 about the app, including its name, version, and whether the UI and
11347 admin API are enabled.
11349 Returns:
11350 dict: API info with app name, version, and UI/admin API status.
11351 """
11352 logger.info("UI disabled, serving API info at root path")
11353 return {"name": settings.app_name, "description": f"{settings.app_name} API"}
11356# Expose some endpoints at the root level as well
11357app.post("/initialize")(initialize)
11358app.post("/notifications")(handle_notification)