diff --git a/.gitignore b/.gitignore index 71812a5c..10ef2a05 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ extensions_config.json coverage.xml coverage/ .deer-flow/ +.flowlens-agentops/ .claude/ skills/custom/* logs/ diff --git a/README.md b/README.md index e0424446..9edfa58a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ English | [中文](./README_zh.md) ![FlowLens AgentOps dashboard](./docs/assets/flowlens-agentops-dashboard.png) -[Pages Demo](https://try1004.github.io/FlowLens-AgentOps/) | [Quick Start](#quick-start) | [Architecture](./docs/architecture.md) +[Pages Demo](https://try1004.github.io/FlowLens-AgentOps/) | [Quick Start](#quick-start) | [Architecture](./docs/architecture.md) | [Dual MCP Validation](./docs/mcp-validation-guide.md) | [Maintainer Handoff](./now.md) > Built on [DeerFlow 2.0](https://github.com/bytedance/deer-flow) under the MIT License. DeerFlow provides the Agent runtime, tools, subagents, memory, MCP, sandbox, and application foundation. FlowLens adds the diagnostics and AgentOps product layer described below. @@ -32,6 +32,7 @@ A checkpoint can restore state, but it does not explain why a multi-agent run fa - **Root Cause**: rule-based primary cause, evidence links, contributing failures, and remediation guidance. - **Replay mode**: success, tool error, subagent timeout, guardrail denial, checkpoint rollback, MCP auth error, and memory error. - **Connected mode**: reads the real DeerFlow Gateway and RunEventStore through owner-checked APIs. +- **Dual MCP validation**: internal SQLite Analytics and FlowLens Inspector services demonstrate bounded read-only data access and user-bound runtime delegation. - **Portable delivery**: Next.js route, Vite static build, Docker/Nginx demo, CI, and GitHub Pages workflow. FlowLens AgentOps mobile Root Cause view @@ -44,6 +45,14 @@ The diagnostics path is read-only: it does not mutate checkpoints, prompts, RunR ## Quick Start +### Choose a mode + +| Mode | What it proves | Requirements | URL | +| --- | --- | --- | --- | +| Replay | Dashboard interactions and deterministic synthetic failure scenarios | Node.js or Docker; no model key | `http://localhost:5173` or `http://localhost:4173` | +| Connected Runtime | Real DeerFlow runs emitted into the AgentOps event pipeline | Normal DeerFlow configuration, account, and model provider | `http://localhost:2026/agentops` | +| Connected + dual MCP | Runtime delegation, read-only SQLite Analytics MCP, and FlowLens Inspector MCP | Docker Desktop plus the Connected Runtime requirements | `http://localhost:2027/agentops` | + ### No-key Replay with pnpm ```bash @@ -66,6 +75,14 @@ make flowlens-demo Open `http://localhost:4173`, then stop it with `make flowlens-stop`. +On Windows without `make`, run the Compose command directly: + +```powershell +docker compose -f docker/docker-compose-flowlens-demo.yaml up --build +``` + +Press `Ctrl+C` to stop the foreground Replay container. + ### Connected DeerFlow runtime ```bash @@ -75,6 +92,39 @@ make up Open `http://localhost:2026/agentops`. Connected mode requires the normal DeerFlow configuration, authentication, and at least one configured model provider. It uses the same React components and Zod contracts as Replay mode. +### Connected runtime with dual MCP services + +The development stack runs two internal-only Streamable HTTP MCP services: + +- **SQLite Analytics MCP** exposes schema inspection and `SELECT`-only local demonstration-data queries. +- **FlowLens Inspector MCP** queries owner-checked diagnostic data through the Gateway. + +Schemas are discovered with a restricted service token. A real tool invocation receives a short-lived token bound to the current `user_id` and `run_id`; the model never receives that token. + +From the repository root in PowerShell: + +```powershell +$env:DEER_FLOW_ROOT = (Get-Location).Path +$env:HOME = $env:USERPROFILE +$env:DEER_FLOW_CONTAINER_PREFIX = "flowlens-agentops" +$env:DEER_FLOW_HTTP_PORT = "2027" + +docker compose -p flowlens-agentops ` + -f docker/docker-compose-dev.yaml ` + -f docker/docker-compose-agentops-isolated.yaml ` + up -d --build --remove-orphans frontend gateway nginx sqlite-analytics flowlens-inspector + +Invoke-RestMethod http://localhost:2027/health +``` + +Open `http://localhost:2027/`, sign in, then open `http://localhost:2027/agentops`. To exercise the real SQLite path, start a new chat and send: + +```text +Use the SQLite Analytics MCP to inspect the available schema, describe the agent_runs table, then count runs by status. Execute read-only SQL only, state the SQL used, and label the result as local demonstration data. +``` + +The selected Run should show MCP events in Timeline and an `MCP > 0` metric. The [Dual MCP Validation Guide](./docs/mcp-validation-guide.md) and [Chinese end-to-end runbook](./docs/flowlens-dual-mcp-test-runbook.zh-CN.md) cover expected evidence, policy-rejection cases, and shutdown. + ## Diagnostic APIs | Method | Endpoint | Purpose | @@ -125,8 +175,10 @@ FlowLens does not claim authorship of the DeerFlow runtime modules. See [NOTICE] | Gate | Result | | ---------------------------------- | ------------------------------------------------------------------ | -| Focused backend FlowLens/API suite | 53 tests passed | -| Frontend Vitest suite | 219 tests passed | +| Focused backend FlowLens/API suite | 58 tests passed on 2026-07-31 | +| Frontend Vitest suite | 232 tests passed on 2026-07-31 | +| Frontend quality gate | ESLint, TypeScript, Next production build, and Vite Replay build passed on 2026-07-31 | +| Real MCP integration probe | `ToolNode -> delegated OAuth token -> SQLite schema tool` completed locally | | Next and static Replay Playwright | 5 tests passed across route, desktop, tablet, and mobile workflows | | Secret scan | No high-confidence credentials in Git-tracked files | | In-process diagnostics benchmark | 2,000 events, 20 iterations, median 24.7578 ms, P95 36.5606 ms | @@ -155,6 +207,9 @@ The machine-readable result is committed at [flowlens-benchmark.json](./docs/ass - [Event schema and instrumentation](./docs/event-schema.md) - [Failure attribution rules](./docs/failure-attribution.md) - [Replay and Connected demo guide](./docs/demo-guide.md) +- [Dual MCP validation guide](./docs/mcp-validation-guide.md) +- [Chinese dual MCP end-to-end runbook](./docs/flowlens-dual-mcp-test-runbook.zh-CN.md) +- [Maintainer handoff and next steps](./now.md) - [Contributing](./CONTRIBUTING.md) - [Changelog](./CHANGELOG.md) diff --git a/backend/app/gateway/app.py b/backend/app/gateway/app.py index 30662d2f..3069a0b6 100644 --- a/backend/app/gateway/app.py +++ b/backend/app/gateway/app.py @@ -19,6 +19,7 @@ channels, feedback, mcp, + mcp_tokens, memory, models, runs, @@ -178,6 +179,11 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: logger.exception(error_msg) raise RuntimeError(error_msg) from e config = get_gateway_config() + from app.gateway.auth.platform_tokens import ( + ensure_platform_tokens_configured_if_enabled, + ) + + ensure_platform_tokens_configured_if_enabled() logger.info(f"Starting API Gateway on {config.host}:{config.port}") # Pre-warm tiktoken encoding cache so the first memory-injection request @@ -385,6 +391,7 @@ def create_app() -> FastAPI: # Auth API is mounted at /api/v1/auth app.include_router(auth.router) + app.include_router(mcp_tokens.router) # Feedback API is mounted at /api/threads/{thread_id}/runs/{run_id}/feedback app.include_router(feedback.router) diff --git a/backend/app/gateway/auth/models.py b/backend/app/gateway/auth/models.py index 25c6476f..fbf435ec 100644 --- a/backend/app/gateway/auth/models.py +++ b/backend/app/gateway/auth/models.py @@ -18,6 +18,7 @@ class User(BaseModel): model_config = ConfigDict(from_attributes=True) id: UUID = Field(default_factory=uuid4, description="Primary key") + tenant_id: str = Field(default="default", description="Owning tenant") email: EmailStr = Field(..., description="Unique email address") password_hash: str | None = Field(None, description="bcrypt hash, nullable for OAuth users") system_role: Literal["admin", "user"] = Field(default="user") diff --git a/backend/app/gateway/auth/platform_tokens.py b/backend/app/gateway/auth/platform_tokens.py new file mode 100644 index 00000000..88665253 --- /dev/null +++ b/backend/app/gateway/auth/platform_tokens.py @@ -0,0 +1,311 @@ +"""RS256 platform tokens used by DeerFlow Runtime and FlowLens MCP.""" + +from __future__ import annotations + +import base64 +import os +import uuid +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta +from pathlib import Path +from typing import Any + +import jwt +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey +from pydantic import BaseModel, ValidationError, field_validator + +PLATFORM_ALGORITHM = "RS256" +TOOLS_DISCOVERY_SCOPE = "agentops.tools.discover" +AGENTOPS_READ_SCOPES: tuple[str, ...] = ( + "agentops.health.read", + "agentops.runs.read", + "agentops.timeline.read", + "agentops.diagnostics.read", + "agentops.runs.compare", +) +SQLITE_ANALYTICS_READ_SCOPES: tuple[str, ...] = ( + "analytics.schema.read", + "analytics.query.read", +) +_TRUTHY_VALUES = frozenset({"1", "true", "yes", "on"}) + + +class PlatformTokenError(ValueError): + """Raised when platform token configuration or validation fails.""" + + +@dataclass(frozen=True, slots=True) +class PlatformTokenSettings: + """Key material and claim policy for FlowLens platform tokens.""" + + private_key_pem: str | None = field(repr=False) + public_key_pem: str = field(repr=False) + kid: str + issuer: str + audience: str + client_id: str + client_secret: str = field(repr=False) + token_ttl_seconds: int = 300 + + @classmethod + def from_env( + cls, + *, + require_private_key: bool = True, + ) -> PlatformTokenSettings: + """Load platform token settings from environment-backed PEM files.""" + private_path = os.getenv("FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH", "").strip() + public_path = os.getenv("FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH", "").strip() + client_secret = os.getenv("FLOWLENS_MCP_CLIENT_SECRET", "") + + if not public_path: + raise PlatformTokenError( + "FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH is required", + ) + if require_private_key and not private_path: + raise PlatformTokenError( + "FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH is required", + ) + if not client_secret: + raise PlatformTokenError("FLOWLENS_MCP_CLIENT_SECRET is required") + + try: + ttl_seconds = int( + os.getenv("FLOWLENS_MCP_TOKEN_TTL_SECONDS", "300"), + ) + except ValueError as exc: + raise PlatformTokenError( + "FLOWLENS_MCP_TOKEN_TTL_SECONDS must be an integer", + ) from exc + if ttl_seconds <= 0: + raise PlatformTokenError( + "FLOWLENS_MCP_TOKEN_TTL_SECONDS must be positive", + ) + + return cls( + private_key_pem=(_read_key_file(private_path) if private_path else None), + public_key_pem=_read_key_file(public_path), + kid=os.getenv( + "FLOWLENS_MCP_JWT_KID", + "flowlens-local-1", + ).strip() + or "flowlens-local-1", + issuer=os.getenv( + "FLOWLENS_MCP_ISSUER", + "deerflow-gateway", + ).strip() + or "deerflow-gateway", + audience=os.getenv( + "FLOWLENS_MCP_AUDIENCE", + "flowlens-platform", + ).strip() + or "flowlens-platform", + client_id=os.getenv( + "FLOWLENS_MCP_CLIENT_ID", + "deerflow-runtime", + ).strip() + or "deerflow-runtime", + client_secret=client_secret, + token_ttl_seconds=ttl_seconds, + ) + + +class PlatformTokenClaims(BaseModel): + """Validated immutable identity and authorization claims.""" + + model_config = {"frozen": True} + + iss: str + sub: str + tenant_id: str + aud: str | list[str] + scope: list[str] + origin_run_id: str | None = None + azp: str + jti: str + iat: datetime + exp: datetime + ver: int + + @field_validator("scope") + @classmethod + def _require_unique_scopes(cls, value: list[str]) -> list[str]: + if not value or any(not scope for scope in value): + raise ValueError("scope must contain non-empty values") + if len(value) != len(set(value)): + raise ValueError("scope values must be unique") + return value + + +class PlatformTokenIssuer: + """Issue short-lived service and delegated platform tokens.""" + + def __init__(self, settings: PlatformTokenSettings) -> None: + if not settings.private_key_pem: + raise PlatformTokenError( + "Private key is required for token issuance", + ) + self._settings = settings + + def issue_service_token(self) -> str: + """Issue a least-privilege client token for MCP tool discovery.""" + return self._issue( + subject=self._settings.client_id, + tenant_id="system", + scopes=[TOOLS_DISCOVERY_SCOPE], + origin_run_id=None, + token_version=0, + ) + + def issue_delegated_token( + self, + *, + user_id: str, + tenant_id: str, + token_version: int, + origin_run_id: str, + scopes: tuple[str, ...] = AGENTOPS_READ_SCOPES, + ) -> str: + """Issue a user-bound token for read-only AgentOps inspection.""" + return self._issue( + subject=user_id, + tenant_id=tenant_id, + scopes=list(scopes), + origin_run_id=origin_run_id, + token_version=token_version, + ) + + def _issue( + self, + *, + subject: str, + tenant_id: str, + scopes: list[str], + origin_run_id: str | None, + token_version: int, + ) -> str: + now = datetime.now(UTC) + payload = { + "iss": self._settings.issuer, + "sub": subject, + "tenant_id": tenant_id, + "aud": self._settings.audience, + "scope": scopes, + "origin_run_id": origin_run_id, + "azp": self._settings.client_id, + "jti": str(uuid.uuid4()), + "iat": now, + "exp": now + timedelta(seconds=self._settings.token_ttl_seconds), + "ver": token_version, + } + return jwt.encode( + payload, + self._settings.private_key_pem, + algorithm=PLATFORM_ALGORITHM, + headers={"kid": self._settings.kid}, + ) + + +def ensure_platform_tokens_configured_if_enabled() -> PlatformTokenSettings | None: + """Validate key material at startup when FlowLens MCP is enabled.""" + enabled = os.getenv("FLOWLENS_MCP_ENABLED", "").strip().lower() in _TRUTHY_VALUES + if not enabled: + return None + return PlatformTokenSettings.from_env() + + +def verify_platform_token( + token: str, + settings: PlatformTokenSettings | None = None, +) -> PlatformTokenClaims: + """Verify signature, key id, issuer, audience, time, and claims.""" + resolved_settings = settings or PlatformTokenSettings.from_env( + require_private_key=False, + ) + try: + header = jwt.get_unverified_header(token) + if header.get("alg") != PLATFORM_ALGORITHM: + raise PlatformTokenError("Unsupported platform token algorithm") + if header.get("kid") != resolved_settings.kid: + raise PlatformTokenError("Unknown platform token key id") + + payload = jwt.decode( + token, + resolved_settings.public_key_pem, + algorithms=[PLATFORM_ALGORITHM], + audience=resolved_settings.audience, + issuer=resolved_settings.issuer, + options={ + "require": [ + "iss", + "sub", + "tenant_id", + "aud", + "scope", + "azp", + "jti", + "iat", + "exp", + "ver", + ], + }, + ) + claims = PlatformTokenClaims.model_validate(payload) + if claims.azp != resolved_settings.client_id: + raise PlatformTokenError( + "Untrusted platform token authorized party", + ) + return claims + except PlatformTokenError: + raise + except (jwt.PyJWTError, ValidationError, TypeError, ValueError) as exc: + raise PlatformTokenError("Invalid platform token") from exc + + +def build_platform_jwks( + settings: PlatformTokenSettings | None = None, +) -> dict[str, list[dict[str, Any]]]: + """Return the public key as an RFC 7517 JSON Web Key Set.""" + resolved_settings = settings or PlatformTokenSettings.from_env( + require_private_key=False, + ) + try: + public_key = serialization.load_pem_public_key( + resolved_settings.public_key_pem.encode(), + ) + except (TypeError, ValueError) as exc: + raise PlatformTokenError("Invalid RSA public key") from exc + if not isinstance(public_key, RSAPublicKey): + raise PlatformTokenError("Platform public key must be RSA") + + numbers = public_key.public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": PLATFORM_ALGORITHM, + "kid": resolved_settings.kid, + "n": _base64url_uint(numbers.n), + "e": _base64url_uint(numbers.e), + } + ] + } + + +def _read_key_file(path: str) -> str: + try: + return Path(path).read_text(encoding="utf-8") + except OSError as exc: + raise PlatformTokenError( + f"Unable to read platform key file: {path}", + ) from exc + + +def _base64url_uint(value: int) -> str: + length = max(1, (value.bit_length() + 7) // 8) + encoded = base64.urlsafe_b64encode( + value.to_bytes(length, "big"), + ) + return encoded.rstrip(b"=").decode("ascii") diff --git a/backend/app/gateway/auth/repositories/sqlite.py b/backend/app/gateway/auth/repositories/sqlite.py index 3ee3978e..39f4a61c 100644 --- a/backend/app/gateway/auth/repositories/sqlite.py +++ b/backend/app/gateway/auth/repositories/sqlite.py @@ -36,6 +36,7 @@ def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None: def _row_to_user(row: UserRow) -> User: return User( id=UUID(row.id), + tenant_id=row.tenant_id, email=row.email, password_hash=row.password_hash, system_role=row.system_role, # type: ignore[arg-type] @@ -52,6 +53,7 @@ def _row_to_user(row: UserRow) -> User: def _user_to_row(user: User) -> UserRow: return UserRow( id=str(user.id), + tenant_id=user.tenant_id, email=user.email, password_hash=user.password_hash, system_role=user.system_role, @@ -100,6 +102,7 @@ async def update_user(self, user: User) -> User: # a row that no longer exists. raise UserNotFoundError(f"User {user.id} no longer exists") row.email = user.email + row.tenant_id = user.tenant_id row.password_hash = user.password_hash row.system_role = user.system_role row.oauth_provider = user.oauth_provider diff --git a/backend/app/gateway/auth_middleware.py b/backend/app/gateway/auth_middleware.py index 6b645226..1aa77340 100644 --- a/backend/app/gateway/auth_middleware.py +++ b/backend/app/gateway/auth_middleware.py @@ -17,7 +17,12 @@ from starlette.types import ASGIApp from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse -from app.gateway.authz import _ALL_PERMISSIONS, AuthContext +from app.gateway.auth.platform_tokens import ( + AGENTOPS_READ_SCOPES, + PlatformTokenError, + verify_platform_token, +) +from app.gateway.authz import _ALL_PERMISSIONS, AuthContext, Permissions from app.gateway.internal_auth import INTERNAL_AUTH_HEADER_NAME, get_internal_user, is_valid_internal_auth_token from deerflow.runtime.user_context import reset_current_user, set_current_user @@ -38,10 +43,35 @@ "/api/v1/auth/logout", "/api/v1/auth/setup-status", "/api/v1/auth/initialize", + "/api/v1/auth/mcp/token", + "/.well-known/jwks.json", } ) +def _bearer_token(request: Request) -> str | None: + authorization = request.headers.get("Authorization", "") + scheme, separator, token = authorization.partition(" ") + if not separator or scheme.lower() != "bearer": + return None + return token.strip() or None + + +def _authentication_error( + code: AuthErrorCode, + message: str, +) -> JSONResponse: + return JSONResponse( + status_code=401, + content={ + "detail": AuthErrorResponse( + code=code, + message=message, + ).model_dump() + }, + ) + + def _is_public(path: str) -> bool: stripped = path.rstrip("/") if stripped in _PUBLIC_EXACT_PATHS: @@ -77,19 +107,49 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: return await call_next(request) internal_user = None + platform_user = None + platform_scopes: frozenset[str] = frozenset() if is_valid_internal_auth_token(request.headers.get(INTERNAL_AUTH_HEADER_NAME)): internal_user = get_internal_user() - # Non-public path: require session cookie - if internal_user is None and not request.cookies.get("access_token"): - return JSONResponse( - status_code=401, - content={ - "detail": AuthErrorResponse( - code=AuthErrorCode.NOT_AUTHENTICATED, - message="Authentication required", - ).model_dump() - }, + bearer_token = _bearer_token(request) if internal_user is None else None + if bearer_token is not None: + try: + claims = verify_platform_token(bearer_token) + except PlatformTokenError: + return _authentication_error( + AuthErrorCode.TOKEN_INVALID, + "Invalid platform token", + ) + + from app.gateway.deps import get_local_provider + + platform_user = await get_local_provider().get_user(claims.sub) + if platform_user is None: + return _authentication_error( + AuthErrorCode.USER_NOT_FOUND, + "Platform token user not found", + ) + if ( + platform_user.token_version != claims.ver + or platform_user.tenant_id != claims.tenant_id + ): + return _authentication_error( + AuthErrorCode.TOKEN_INVALID, + "Platform token is stale", + ) + platform_scopes = frozenset(claims.scope) + request.state.platform_claims = claims + request.state.platform_scopes = platform_scopes + + if ( + internal_user is None + and platform_user is None + and not request.cookies.get("access_token") + ): + return _authentication_error( + AuthErrorCode.NOT_AUTHENTICATED, + "Authentication required", ) # Strict JWT validation: reject junk/expired tokens with 401 @@ -107,18 +167,30 @@ async def dispatch(self, request: Request, call_next: Callable) -> Response: if internal_user is not None: user = internal_user + permissions = _ALL_PERMISSIONS + elif platform_user is not None: + user = platform_user + permissions = ( + [Permissions.RUNS_READ] + if platform_scopes.intersection(AGENTOPS_READ_SCOPES) + else [] + ) else: try: user = await get_current_user_from_request(request) except HTTPException as exc: return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + permissions = _ALL_PERMISSIONS # Stamp both request.state.user (for the contextvar pattern) # and request.state.auth (so @require_permission's "auth is # None" branch short-circuits instead of running the entire # JWT-decode + DB-lookup pipeline a second time per request). request.state.user = user - request.state.auth = AuthContext(user=user, permissions=_ALL_PERMISSIONS) + request.state.auth = AuthContext( + user=user, + permissions=permissions, + ) token = set_current_user(user) try: return await call_next(request) diff --git a/backend/app/gateway/csrf_middleware.py b/backend/app/gateway/csrf_middleware.py index f3488203..61b12ad9 100644 --- a/backend/app/gateway/csrf_middleware.py +++ b/backend/app/gateway/csrf_middleware.py @@ -51,6 +51,7 @@ def should_check_csrf(request: Request) -> bool: "/api/v1/auth/logout", "/api/v1/auth/register", "/api/v1/auth/initialize", + "/api/v1/auth/mcp/token", } ) diff --git a/backend/app/gateway/deps.py b/backend/app/gateway/deps.py index 5739d217..56e99091 100644 --- a/backend/app/gateway/deps.py +++ b/backend/app/gateway/deps.py @@ -31,6 +31,7 @@ from deerflow.runtime import RunContext, RunManager, StreamBridge from deerflow.runtime.events.store.base import RunEventStore from deerflow.runtime.runs.store.base import RunStore +from deerflow.runtime.user_context import DEFAULT_TENANT_ID logger = logging.getLogger(__name__) @@ -378,11 +379,23 @@ async def get_optional_user_from_request(request: Request): async def get_current_user(request: Request) -> str | None: - """Extract user_id from request cookie, or None if not authenticated. + """Return the middleware-validated user id, with cookie fallback. Thin adapter that returns the string id for callers that only need identification (e.g., ``feedback.py``). Full-user callers should use ``get_current_user_from_request`` or ``get_optional_user_from_request``. """ + state_user = getattr(request.state, "user", None) + if state_user is not None: + return str(state_user.id) user = await get_optional_user_from_request(request) return str(user.id) if user else None + + +def get_current_tenant_id(request: Request) -> str: + """Return the server-authenticated tenant, defaulting legacy users.""" + user = getattr(request.state, "user", None) + return str( + getattr(user, "tenant_id", None) + or DEFAULT_TENANT_ID + ) diff --git a/backend/app/gateway/routers/agentops.py b/backend/app/gateway/routers/agentops.py index f4ff0aee..8b50e6ed 100644 --- a/backend/app/gateway/routers/agentops.py +++ b/backend/app/gateway/routers/agentops.py @@ -2,17 +2,26 @@ from __future__ import annotations +import asyncio +import logging from datetime import datetime from typing import Any from fastapi import APIRouter, HTTPException, Query, Request from app.gateway.authz import require_permission -from app.gateway.deps import get_current_user, get_run_manager +from app.gateway.deps import ( + get_current_tenant_id, + get_current_user, + get_run_event_store, + get_run_manager, +) from deerflow.runtime import RunRecord, RunStatus +from deerflow.runtime.diagnostics import build_diagnostics_response from deerflow.runtime.runs.cursor import InvalidRunCursor, decode_run_cursor, encode_run_cursor router = APIRouter(prefix="/api/agentops", tags=["agentops"]) +logger = logging.getLogger(__name__) def _run_summary(record: RunRecord) -> dict[str, Any]: @@ -30,6 +39,28 @@ def _run_summary(record: RunRecord) -> dict[str, Any]: } +async def _run_summary_with_diagnostics(record: RunRecord, event_store: Any) -> dict[str, Any]: + """Enrich the list view without redefining raw run lifecycle status. + + A run may complete its outer loop successfully after an unrecovered tool + incident. The index exposes that derived incident separately so the UI can + show an operator-facing error state while retaining the factual run status. + """ + + summary = _run_summary(record) + try: + events = await event_store.list_events(record.thread_id, record.run_id, limit=5000) + summary["diagnostic_category"] = build_diagnostics_response( + record, + events, + limit=5000, + )["failure_attribution"]["primary_cause"] + except Exception: + logger.warning("Could not derive AgentOps incident for run %s", record.run_id, exc_info=True) + summary["diagnostic_category"] = None + return summary + + @router.get("/runs") @require_permission("runs", "read") async def list_agentops_runs( @@ -37,6 +68,7 @@ async def list_agentops_runs( limit: int = Query(default=50, ge=1, le=200), cursor: str | None = Query(default=None), status: RunStatus | None = Query(default=None), + thread_id: str | None = Query(default=None, min_length=1), started_after: datetime | None = Query(default=None), started_before: datetime | None = Query(default=None), ) -> dict[str, Any]: @@ -51,8 +83,11 @@ async def list_agentops_runs( user_id = await get_current_user(request) if user_id is None: raise HTTPException(status_code=401, detail="Authentication required") + tenant_id = get_current_tenant_id(request) records = await get_run_manager(request).list_recent( user_id=user_id, + tenant_id=tenant_id, + thread_id=thread_id, limit=limit + 1, cursor=cursor, status=status.value if status is not None else None, @@ -65,8 +100,38 @@ async def list_agentops_runs( if has_more and page_records: last = page_records[-1] next_cursor = encode_run_cursor(last.created_at, last.run_id) + event_store = get_run_event_store(request) + semaphore = asyncio.Semaphore(8) + + async def _enrich(record: RunRecord) -> dict[str, Any]: + async with semaphore: + return await _run_summary_with_diagnostics(record, event_store) + return { - "items": [_run_summary(record) for record in page_records], + "items": await asyncio.gather(*(_enrich(record) for record in page_records)), "has_more": has_more, "next_cursor": next_cursor, } + + +@router.get("/runs/{run_id}") +@require_permission("runs", "read") +async def get_agentops_run( + run_id: str, + request: Request, +) -> dict[str, Any]: + """Return an owner-safe stable run summary for MCP consumers.""" + user_id = await get_current_user(request) + if user_id is None: + raise HTTPException( + status_code=401, + detail="Authentication required", + ) + record = await get_run_manager(request).get( + run_id, + user_id=user_id, + tenant_id=get_current_tenant_id(request), + ) + if record is None: + raise HTTPException(status_code=404, detail="Run not found") + return _run_summary(record) diff --git a/backend/app/gateway/routers/mcp_tokens.py b/backend/app/gateway/routers/mcp_tokens.py new file mode 100644 index 00000000..1ce4561d --- /dev/null +++ b/backend/app/gateway/routers/mcp_tokens.py @@ -0,0 +1,123 @@ +"""OAuth-style token and JWKS endpoints for FlowLens MCP.""" + +from __future__ import annotations + +import hmac + +from fastapi import APIRouter, Form, HTTPException, status +from pydantic import BaseModel + +from app.gateway.auth.platform_tokens import ( + AGENTOPS_READ_SCOPES, + SQLITE_ANALYTICS_READ_SCOPES, + TOOLS_DISCOVERY_SCOPE, + PlatformTokenIssuer, + PlatformTokenSettings, + build_platform_jwks, +) +from app.gateway.deps import get_local_provider + +RUNTIME_DELEGATION_GRANT = "urn:deerflow:params:oauth:grant-type:runtime-delegation" +_RESOURCE_SCOPES: dict[str, tuple[str, ...]] = { + "flowlens-inspector": AGENTOPS_READ_SCOPES, + "sqlite-analytics": SQLITE_ANALYTICS_READ_SCOPES, +} + +router = APIRouter(tags=["mcp-auth"]) + + +class PlatformTokenResponse(BaseModel): + """OAuth-compatible bearer token response.""" + + access_token: str + token_type: str = "Bearer" + expires_in: int + scope: str + + +def _authenticate_client( + client_id: str, + client_secret: str, + settings: PlatformTokenSettings, +) -> None: + valid_id = hmac.compare_digest(client_id, settings.client_id) + valid_secret = hmac.compare_digest( + client_secret, + settings.client_secret, + ) + if not (valid_id and valid_secret): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid MCP client credentials", + headers={"WWW-Authenticate": "Basic"}, + ) + + +@router.get("/.well-known/jwks.json") +async def platform_jwks() -> dict: + """Expose the current FlowLens platform verification key.""" + settings = PlatformTokenSettings.from_env( + require_private_key=False, + ) + return build_platform_jwks(settings) + + +@router.post( + "/api/v1/auth/mcp/token", + response_model=PlatformTokenResponse, +) +async def issue_mcp_token( + grant_type: str = Form(...), + client_id: str = Form(...), + client_secret: str = Form(...), + subject_user_id: str | None = Form(default=None), + origin_run_id: str | None = Form(default=None), + resource: str = Form(default="flowlens-inspector"), +) -> PlatformTokenResponse: + """Issue least-privilege service or user-delegated MCP tokens.""" + settings = PlatformTokenSettings.from_env() + _authenticate_client(client_id, client_secret, settings) + issuer = PlatformTokenIssuer(settings) + + if grant_type == "client_credentials": + return PlatformTokenResponse( + access_token=issuer.issue_service_token(), + expires_in=settings.token_ttl_seconds, + scope=TOOLS_DISCOVERY_SCOPE, + ) + + if grant_type != RUNTIME_DELEGATION_GRANT: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported grant_type", + ) + if not subject_user_id or not origin_run_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=("subject_user_id and origin_run_id are required for runtime delegation"), + ) + scopes = _RESOURCE_SCOPES.get(resource) + if scopes is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Unsupported MCP resource", + ) + + user = await get_local_provider().get_user(subject_user_id) + if user is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid delegation subject", + ) + + return PlatformTokenResponse( + access_token=issuer.issue_delegated_token( + user_id=str(user.id), + tenant_id=user.tenant_id, + token_version=user.token_version, + origin_run_id=origin_run_id, + scopes=scopes, + ), + expires_in=settings.token_ttl_seconds, + scope=" ".join(scopes), + ) diff --git a/backend/app/gateway/routers/thread_runs.py b/backend/app/gateway/routers/thread_runs.py index dfebc6ec..d67a6e53 100644 --- a/backend/app/gateway/routers/thread_runs.py +++ b/backend/app/gateway/routers/thread_runs.py @@ -22,7 +22,16 @@ from sqlalchemy.exc import SQLAlchemyError from app.gateway.authz import require_permission -from app.gateway.deps import get_checkpointer, get_current_user, get_feedback_repo, get_run_event_store, get_run_manager, get_run_store, get_stream_bridge +from app.gateway.deps import ( + get_checkpointer, + get_current_tenant_id, + get_current_user, + get_feedback_repo, + get_run_event_store, + get_run_manager, + get_run_store, + get_stream_bridge, +) from app.gateway.pagination import trim_run_message_page from app.gateway.services import sse_consumer, start_run, wait_for_run_completion from deerflow.models import create_chat_model @@ -146,7 +155,11 @@ def _record_to_response(record: RunRecord) -> RunResponse: async def _get_run_or_404(thread_id: str, run_id: str, request: Request) -> RunRecord: run_mgr = get_run_manager(request) user_id = await get_current_user(request) - record = await run_mgr.get(run_id, user_id=user_id) + record = await run_mgr.get( + run_id, + user_id=user_id, + tenant_id=get_current_tenant_id(request), + ) if record is None or record.thread_id != thread_id: raise HTTPException(status_code=404, detail=f"Run {run_id} not found") return record @@ -248,7 +261,11 @@ async def list_runs(thread_id: str, request: Request) -> list[RunResponse]: """List all runs for a thread.""" run_mgr = get_run_manager(request) user_id = await get_current_user(request) - records = await run_mgr.list_by_thread(thread_id, user_id=user_id) + records = await run_mgr.list_by_thread( + thread_id, + user_id=user_id, + tenant_id=get_current_tenant_id(request), + ) return [_record_to_response(r) for r in records] @@ -277,9 +294,7 @@ async def cancel_run( - wait=false: Return immediately with 202 """ run_mgr = get_run_manager(request) - record = await run_mgr.get(run_id) - if record is None or record.thread_id != thread_id: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + record = await _get_run_or_404(thread_id, run_id, request) cancelled = await run_mgr.cancel(run_id, action=action) if not cancelled: @@ -300,9 +315,7 @@ async def cancel_run( async def join_run(thread_id: str, run_id: str, request: Request) -> StreamingResponse: """Join an existing run's SSE stream.""" run_mgr = get_run_manager(request) - record = await run_mgr.get(run_id) - if record is None or record.thread_id != thread_id: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + record = await _get_run_or_404(thread_id, run_id, request) if record.store_only: raise HTTPException(status_code=409, detail=f"Run {run_id} is not active on this worker and cannot be streamed") @@ -340,9 +353,7 @@ async def stream_existing_run( remaining buffered events so the client observes a clean shutdown. """ run_mgr = get_run_manager(request) - record = await run_mgr.get(run_id) - if record is None or record.thread_id != thread_id: - raise HTTPException(status_code=404, detail=f"Run {run_id} not found") + record = await _get_run_or_404(thread_id, run_id, request) if record.store_only and action is None: raise HTTPException(status_code=409, detail=f"Run {run_id} is not active on this worker and cannot be streamed") diff --git a/backend/app/gateway/services.py b/backend/app/gateway/services.py index 2c5c01e6..524915d5 100644 --- a/backend/app/gateway/services.py +++ b/backend/app/gateway/services.py @@ -35,6 +35,7 @@ run_agent, ) from deerflow.runtime.runs.naming import resolve_root_run_name +from deerflow.runtime.user_context import DEFAULT_TENANT_ID logger = logging.getLogger(__name__) @@ -182,6 +183,9 @@ def inject_authenticated_user_context(config: dict[str, Any], request: Request) runtime_context = config.setdefault("context", {}) if isinstance(runtime_context, dict): runtime_context["user_id"] = str(user_id) + runtime_context["tenant_id"] = str( + getattr(user, "tenant_id", None) or DEFAULT_TENANT_ID + ) def resolve_agent_factory(assistant_id: str | None): diff --git a/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/__init__.py b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/__init__.py new file mode 100644 index 00000000..6bc2c6a5 --- /dev/null +++ b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/__init__.py @@ -0,0 +1 @@ +"""Read-only FlowLens Inspector MCP service.""" diff --git a/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth.py b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth.py new file mode 100644 index 00000000..31d40a93 --- /dev/null +++ b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth.py @@ -0,0 +1,30 @@ +"""FastMCP adapter for DeerFlow's signed platform tokens.""" + +from __future__ import annotations + +from mcp.server.auth.provider import AccessToken + +from app.gateway.auth.platform_tokens import ( + PlatformTokenError, + PlatformTokenSettings, + verify_platform_token, +) + + +class PlatformTokenVerifier: + """Verify a DeerFlow platform token at the Inspector MCP boundary.""" + + def __init__(self, settings: PlatformTokenSettings | None = None) -> None: + self._settings = settings + + async def verify_token(self, token: str) -> AccessToken | None: + try: + claims = verify_platform_token(token, self._settings) + except PlatformTokenError: + return None + return AccessToken( + token=token, + client_id=claims.azp, + scopes=claims.scope, + expires_at=int(claims.exp.timestamp()), + ) diff --git a/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/gateway_client.py b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/gateway_client.py new file mode 100644 index 00000000..28d6887b --- /dev/null +++ b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/gateway_client.py @@ -0,0 +1,63 @@ +"""Bounded HTTP client for Gateway-owned AgentOps data.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import httpx +from mcp.server.auth.middleware.auth_context import get_access_token + + +class GatewayClient: + """Call only existing read-only Gateway endpoints with one bearer token.""" + + def __init__( + self, + base_url: str, + *, + token_provider: Callable[[], str] | None = None, + ) -> None: + self._base_url = base_url.rstrip("/") + self._token_provider = token_provider + + @property + def request_headers(self) -> dict[str, str]: + """Return a caller-bound header for the current tool invocation.""" + + if self._token_provider is not None: + token = self._token_provider() + else: + access_token = get_access_token() + if access_token is None: + raise RuntimeError("Inspector MCP request is not authenticated") + token = access_token.token + return {"Authorization": f"Bearer {token}"} + + async def health_check(self) -> dict[str, Any]: + return await self._get("/health") + + async def list_runs(self, **params: Any) -> dict[str, Any]: + return await self._get("/api/agentops/runs", params=params) + + async def get_diagnostics(self, thread_id: str, run_id: str) -> dict[str, Any]: + return await self._get( + f"/api/threads/{thread_id}/runs/{run_id}/diagnostics", + ) + + async def get_timeline(self, thread_id: str, run_id: str, **params: Any) -> dict[str, Any]: + return await self._get( + f"/api/threads/{thread_id}/runs/{run_id}/timeline", + params=params, + ) + + async def _get(self, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get( + f"{self._base_url}{path}", + headers=self.request_headers, + params=params, + ) + response.raise_for_status() + payload = response.json() + return payload if isinstance(payload, dict) else {"items": payload} diff --git a/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/server.py b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/server.py new file mode 100644 index 00000000..3a6f5a8a --- /dev/null +++ b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/server.py @@ -0,0 +1,200 @@ +"""Streamable HTTP MCP server for FlowLens read-only investigation tools.""" + +from __future__ import annotations + +from typing import Any, Protocol + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + +from flowlens_inspector_mcp.auth import PlatformTokenVerifier +from flowlens_inspector_mcp.gateway_client import GatewayClient +from flowlens_inspector_mcp.settings import InspectorSettings + + +class InspectorGateway(Protocol): + async def health_check(self) -> dict[str, Any]: ... + + async def list_runs(self, **params: Any) -> dict[str, Any]: ... + + async def get_diagnostics(self, thread_id: str, run_id: str) -> dict[str, Any]: ... + + async def get_timeline(self, thread_id: str, run_id: str, **params: Any) -> dict[str, Any]: ... + + +def require_scope(scope: str) -> None: + """Require a delegated caller scope after MCP tool discovery succeeds.""" + + access_token = get_access_token() + if access_token is None or scope not in access_token.scopes: + raise PermissionError(f"MCP tool requires delegated scope: {scope}") + + +class FlowLensInspectorTools: + """Transport-independent handlers for the five read-only tools.""" + + def __init__(self, gateway: InspectorGateway) -> None: + self._gateway = gateway + + @staticmethod + def _bounded_limit(limit: int) -> int: + if not 1 <= limit <= 100: + raise ValueError("limit must be between 1 and 100") + return limit + + async def health_check(self) -> dict[str, Any]: + """Return MCP's dependency health without exposing configuration.""" + + return {"schema_version": 1, **await self._gateway.health_check()} + + async def list_runs(self, limit: int = 20, thread_id: str | None = None) -> dict[str, Any]: + """List only runs Gateway authorizes for the bearer identity.""" + + return { + "schema_version": 1, + **await self._gateway.list_runs( + limit=self._bounded_limit(limit), + thread_id=thread_id, + ), + } + + async def get_run_diagnostics(self, thread_id: str, run_id: str) -> dict[str, Any]: + """Return deterministic diagnostics for one authorized run.""" + + return {"schema_version": 1, **await self._gateway.get_diagnostics(thread_id, run_id)} + + async def get_timeline_window( + self, + thread_id: str, + run_id: str, + limit: int = 20, + after_seq: int | None = None, + ) -> dict[str, Any]: + """Return a bounded normalized Timeline window for one run.""" + + return { + "schema_version": 1, + **await self._gateway.get_timeline( + thread_id, + run_id, + limit=self._bounded_limit(limit), + after_seq=after_seq, + ), + } + + async def compare_runs( + self, + base_run_id: str, + base_thread_id: str, + candidate_run_id: str, + candidate_thread_id: str, + ) -> dict[str, Any]: + """Compare two Gateway-authorized diagnostics without invoking an LLM.""" + + base, candidate = await self._gateway.get_diagnostics(base_thread_id, base_run_id), await self._gateway.get_diagnostics(candidate_thread_id, candidate_run_id) + base_metrics = base.get("metrics", {}) + candidate_metrics = candidate.get("metrics", {}) + return { + "schema_version": 1, + "base_run_id": base_run_id, + "candidate_run_id": candidate_run_id, + "duration_delta_ms": int(candidate_metrics.get("duration_ms", 0)) - int(base_metrics.get("duration_ms", 0)), + "token_delta": int(candidate_metrics.get("total_tokens", 0)) - int(base_metrics.get("total_tokens", 0)), + "base_primary_cause": base.get("failure_attribution", {}).get("primary_cause", "UNKNOWN"), + "candidate_primary_cause": candidate.get("failure_attribution", {}).get("primary_cause", "UNKNOWN"), + } + + +def create_server( + gateway: InspectorGateway, + *, + host: str, + port: int, + token_verifier: TokenVerifier | None = None, + auth: AuthSettings | None = None, +) -> FastMCP: + """Register Inspector handlers on a Streamable HTTP FastMCP server.""" + + if (token_verifier is None) != (auth is None): + raise ValueError("token_verifier and auth must be configured together") + + handlers = FlowLensInspectorTools(gateway) + server = FastMCP( + "FlowLens Inspector MCP", + instructions="Use these read-only tools to inspect authorized FlowLens AgentOps runs.", + host=host, + port=port, + streamable_http_path="/mcp", + token_verifier=token_verifier, + auth=auth, + ) + + async def health_check() -> dict[str, Any]: + require_scope("agentops.health.read") + return await handlers.health_check() + + async def list_runs( + limit: int = 20, + thread_id: str | None = None, + ) -> dict[str, Any]: + require_scope("agentops.runs.read") + return await handlers.list_runs(limit=limit, thread_id=thread_id) + + async def get_run_diagnostics(thread_id: str, run_id: str) -> dict[str, Any]: + require_scope("agentops.diagnostics.read") + return await handlers.get_run_diagnostics(thread_id, run_id) + + async def get_timeline_window( + thread_id: str, + run_id: str, + limit: int = 20, + after_seq: int | None = None, + ) -> dict[str, Any]: + require_scope("agentops.timeline.read") + return await handlers.get_timeline_window( + thread_id, + run_id, + limit=limit, + after_seq=after_seq, + ) + + async def compare_runs( + base_run_id: str, + base_thread_id: str, + candidate_run_id: str, + candidate_thread_id: str, + ) -> dict[str, Any]: + require_scope("agentops.runs.compare") + return await handlers.compare_runs( + base_run_id, + base_thread_id, + candidate_run_id, + candidate_thread_id, + ) + + server.tool()(health_check) + server.tool()(list_runs) + server.tool()(get_run_diagnostics) + server.tool()(get_timeline_window) + server.tool()(compare_runs) + return server + + +def main() -> None: + """Start the authenticated Streamable HTTP Inspector MCP service.""" + + settings = InspectorSettings.from_env() + server = create_server( + GatewayClient(settings.gateway_url), + host=settings.host, + port=settings.port, + token_verifier=PlatformTokenVerifier(), + auth=AuthSettings( + issuer_url=settings.issuer_url, + resource_server_url=settings.resource_url, + required_scopes=[], + ), + ) + server.run(transport="streamable-http") diff --git a/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/settings.py b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/settings.py new file mode 100644 index 00000000..2df2f97c --- /dev/null +++ b/backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/settings.py @@ -0,0 +1,36 @@ +"""Environment-backed settings for the FlowLens Inspector MCP service.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class InspectorSettings: + """Network configuration only; identity comes from each MCP request.""" + + gateway_url: str + host: str + port: int + issuer_url: str + resource_url: str + + @classmethod + def from_env(cls) -> InspectorSettings: + return cls( + gateway_url=os.getenv( + "FLOWLENS_INSPECTOR_GATEWAY_URL", + "http://gateway:8000", + ).rstrip("/"), + host=os.getenv("FLOWLENS_INSPECTOR_HOST", "0.0.0.0"), + port=int(os.getenv("FLOWLENS_INSPECTOR_PORT", "8012")), + issuer_url=os.getenv( + "FLOWLENS_INSPECTOR_ISSUER_URL", + "http://gateway:8000", + ).rstrip("/"), + resource_url=os.getenv( + "FLOWLENS_INSPECTOR_RESOURCE_URL", + "http://flowlens-inspector:8012", + ).rstrip("/"), + ) diff --git a/backend/packages/flowlens-inspector-mcp/pyproject.toml b/backend/packages/flowlens-inspector-mcp/pyproject.toml new file mode 100644 index 00000000..d2c62f39 --- /dev/null +++ b/backend/packages/flowlens-inspector-mcp/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "flowlens-inspector-mcp" +version = "0.1.0" +description = "Read-only FlowLens AgentOps Inspector MCP service" +requires-python = ">=3.12" +dependencies = ["httpx>=0.28.0", "mcp>=1.0.0"] + +[project.scripts] +flowlens-inspector-mcp = "flowlens_inspector_mcp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 2e4d32ce..f9769ae6 100644 --- a/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -449,6 +449,12 @@ def _build_subagent_section(max_concurrent: int, *, app_config: AppConfig | None {deferred_tools_section} + +- If a Tool or MCP operation fails and you use a replacement path, call `report_fallback` only after the replacement succeeds. +- Declare the exact failed and replacement components, whether the user's explicit constraints remain satisfied, and a concrete verification method. +- If the user required the original source, freshness, tool, or write guarantee, set `constraint_satisfied=false`; do not claim the task was completed under the original constraint. + + {subagent_section} diff --git a/backend/packages/harness/deerflow/config/database_config.py b/backend/packages/harness/deerflow/config/database_config.py index 37cfd579..02a946df 100644 --- a/backend/packages/harness/deerflow/config/database_config.py +++ b/backend/packages/harness/deerflow/config/database_config.py @@ -67,16 +67,22 @@ class DatabaseConfig(BaseModel): # -- Derived helpers (not user-configured) -- @property - def _resolved_sqlite_dir(self) -> str: - """Resolve sqlite_dir to an absolute path (relative to CWD).""" + def resolved_sqlite_dir(self) -> str: + """Resolve the configured SQLite directory to an absolute path. + + ``DEER_FLOW_SQLITE_DIR`` is an explicit runtime-only override for + isolated deployments. It keeps a shared config file usable while a + Docker acceptance stack needs a clean database location. + """ from pathlib import Path - return str(Path(self.sqlite_dir).resolve()) + sqlite_dir = os.getenv("DEER_FLOW_SQLITE_DIR", self.sqlite_dir) + return str(Path(sqlite_dir).resolve()) @property def sqlite_path(self) -> str: """Unified SQLite file path shared by checkpointer and app.""" - return os.path.join(self._resolved_sqlite_dir, "deerflow.db") + return os.path.join(self.resolved_sqlite_dir, "deerflow.db") # Backward-compatible aliases @property diff --git a/backend/packages/harness/deerflow/config/extensions_config.py b/backend/packages/harness/deerflow/config/extensions_config.py index 18b8cd5e..a264d316 100644 --- a/backend/packages/harness/deerflow/config/extensions_config.py +++ b/backend/packages/harness/deerflow/config/extensions_config.py @@ -15,10 +15,14 @@ class McpOAuthConfig(BaseModel): enabled: bool = Field(default=True, description="Whether OAuth token injection is enabled") token_url: str = Field(description="OAuth token endpoint URL") - grant_type: Literal["client_credentials", "refresh_token"] = Field( + grant_type: str = Field( default="client_credentials", description="OAuth grant type", ) + discovery_grant_type: Literal["client_credentials", "refresh_token"] | None = Field( + default=None, + description="Optional grant used only to discover protected MCP tool schemas", + ) client_id: str | None = Field(default=None, description="OAuth client ID") client_secret: str | None = Field(default=None, description="OAuth client secret") refresh_token: str | None = Field(default=None, description="OAuth refresh token (for refresh_token grant)") diff --git a/backend/packages/harness/deerflow/mcp/oauth.py b/backend/packages/harness/deerflow/mcp/oauth.py index df0e1ead..112be3e0 100644 --- a/backend/packages/harness/deerflow/mcp/oauth.py +++ b/backend/packages/harness/deerflow/mcp/oauth.py @@ -13,6 +13,7 @@ from deerflow.runtime.diagnostics.emitter import DiagnosticRecorder, find_diagnostic_recorder logger = logging.getLogger(__name__) +RUNTIME_DELEGATION_GRANT = "urn:deerflow:params:oauth:grant-type:runtime-delegation" def _record_oauth_event( @@ -83,11 +84,24 @@ async def get_authorization_header( server_name: str, *, recorder: DiagnosticRecorder | None = None, + runtime: Any | None = None, + for_discovery: bool = False, ) -> str | None: oauth = self._oauth_by_server.get(server_name) if not oauth: return None + grant_type = oauth.discovery_grant_type if for_discovery and oauth.discovery_grant_type else oauth.grant_type + if grant_type == RUNTIME_DELEGATION_GRANT: + fresh = await self._fetch_token( + server_name, + oauth, + grant_type=grant_type, + runtime=runtime, + recorder=recorder, + ) + return f"{fresh.token_type} {fresh.access_token}" + token = self._tokens.get(server_name) if token and not self._is_expiring(token, oauth): return f"{token.token_type} {token.access_token}" @@ -98,7 +112,12 @@ async def get_authorization_header( if token and not self._is_expiring(token, oauth): return f"{token.token_type} {token.access_token}" - fresh = await self._fetch_token(server_name, oauth, recorder=recorder) + fresh = await self._fetch_token( + server_name, + oauth, + grant_type=grant_type, + recorder=recorder, + ) self._tokens[server_name] = fresh logger.info(f"Refreshed OAuth access token for MCP server: {server_name}") return f"{fresh.token_type} {fresh.access_token}" @@ -113,6 +132,8 @@ async def _fetch_token( server_name: str, oauth: McpOAuthConfig, *, + grant_type: str, + runtime: Any | None = None, recorder: DiagnosticRecorder | None = None, ) -> _OAuthToken: started_at = time.monotonic() @@ -121,18 +142,22 @@ async def _fetch_token( recorder, "mcp.oauth.refresh", server_name=server_name, - grant_type=oauth.grant_type, + grant_type=grant_type, status="running", span_id=span_id, ) try: - token = await self._fetch_token_unobserved(oauth) + token = await self._fetch_token_unobserved( + oauth, + grant_type=grant_type, + runtime=runtime, + ) except Exception as exc: _record_oauth_event( recorder, "mcp.oauth.error", server_name=server_name, - grant_type=oauth.grant_type, + grant_type=grant_type, status="error", span_id=span_id, duration_ms=int((time.monotonic() - started_at) * 1000), @@ -143,18 +168,24 @@ async def _fetch_token( recorder, "mcp.oauth.refresh", server_name=server_name, - grant_type=oauth.grant_type, + grant_type=grant_type, status="success", span_id=span_id, duration_ms=int((time.monotonic() - started_at) * 1000), ) return token - async def _fetch_token_unobserved(self, oauth: McpOAuthConfig) -> _OAuthToken: + async def _fetch_token_unobserved( + self, + oauth: McpOAuthConfig, + *, + grant_type: str, + runtime: Any | None = None, + ) -> _OAuthToken: import httpx # pyright: ignore[reportMissingImports] data: dict[str, str] = { - "grant_type": oauth.grant_type, + "grant_type": grant_type, **oauth.extra_token_params, } @@ -163,12 +194,12 @@ async def _fetch_token_unobserved(self, oauth: McpOAuthConfig) -> _OAuthToken: if oauth.audience: data["audience"] = oauth.audience - if oauth.grant_type == "client_credentials": + if grant_type == "client_credentials": if not oauth.client_id or not oauth.client_secret: raise ValueError("OAuth client_credentials requires client_id and client_secret") data["client_id"] = oauth.client_id data["client_secret"] = oauth.client_secret - elif oauth.grant_type == "refresh_token": + elif grant_type == "refresh_token": if not oauth.refresh_token: raise ValueError("OAuth refresh_token grant requires refresh_token") data["refresh_token"] = oauth.refresh_token @@ -176,8 +207,26 @@ async def _fetch_token_unobserved(self, oauth: McpOAuthConfig) -> _OAuthToken: data["client_id"] = oauth.client_id if oauth.client_secret: data["client_secret"] = oauth.client_secret + elif grant_type == RUNTIME_DELEGATION_GRANT: + if not oauth.client_id or not oauth.client_secret: + raise ValueError("Runtime delegation requires client_id and client_secret") + context = getattr(runtime, "context", None) + if not isinstance(context, dict): + raise ValueError("Runtime delegation requires an MCP tool runtime") + user_id = context.get("user_id") + run_id = context.get("run_id") + if not user_id or not run_id: + raise ValueError("Runtime delegation requires user_id and run_id") + data.update( + { + "client_id": oauth.client_id, + "client_secret": oauth.client_secret, + "subject_user_id": str(user_id), + "origin_run_id": str(run_id), + } + ) else: - raise ValueError(f"Unsupported OAuth grant type: {oauth.grant_type}") + raise ValueError(f"Unsupported OAuth grant type: {grant_type}") async with httpx.AsyncClient(timeout=15.0) as client: response = await client.post(oauth.token_url, data=data) @@ -208,7 +257,11 @@ def build_oauth_tool_interceptor(extensions_config: ExtensionsConfig) -> Any | N async def oauth_interceptor(request: Any, handler: Any) -> Any: recorder = find_diagnostic_recorder(getattr(request, "runtime", None)) - header = await token_manager.get_authorization_header(request.server_name, recorder=recorder) + header = await token_manager.get_authorization_header( + request.server_name, + recorder=recorder, + runtime=getattr(request, "runtime", None), + ) if not header: return await handler(request) @@ -227,6 +280,12 @@ async def get_initial_oauth_headers(extensions_config: ExtensionsConfig) -> dict headers: dict[str, str] = {} for server_name in token_manager.oauth_server_names(): - headers[server_name] = await token_manager.get_authorization_header(server_name) or "" + headers[server_name] = ( + await token_manager.get_authorization_header( + server_name, + for_discovery=True, + ) + or "" + ) return {name: value for name, value in headers.items() if value} diff --git a/backend/packages/harness/deerflow/persistence/engine.py b/backend/packages/harness/deerflow/persistence/engine.py index 127ee73f..1523707a 100644 --- a/backend/packages/harness/deerflow/persistence/engine.py +++ b/backend/packages/harness/deerflow/persistence/engine.py @@ -175,7 +175,7 @@ async def init_engine_from_config(config) -> None: url=config.app_sqlalchemy_url, echo=config.echo_sql, pool_size=config.pool_size, - sqlite_dir=config.sqlite_dir if config.backend == "sqlite" else "", + sqlite_dir=config.resolved_sqlite_dir if config.backend == "sqlite" else "", ) diff --git a/backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0001_flowlens_mcp_tenant.py b/backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0001_flowlens_mcp_tenant.py new file mode 100644 index 00000000..9146dde5 --- /dev/null +++ b/backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0001_flowlens_mcp_tenant.py @@ -0,0 +1,93 @@ +"""Add tenant ownership to users and runs. + +Revision ID: 20260728_0001 +Revises: +Create Date: 2026-07-28 +""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import sqlalchemy as sa +from alembic import op + +revision = "20260728_0001" +down_revision = None +branch_labels = None +depends_on = None + +_DEFAULT_TENANT_ID = "default" +_INDEX_NAME = "ix_runs_tenant_user_created" + + +def _columns(table_name: str) -> dict[str, Mapping[str, Any]]: + inspector = sa.inspect(op.get_bind()) + return { + column["name"]: column + for column in inspector.get_columns(table_name) + } + + +def _index_names(table_name: str) -> set[str]: + inspector = sa.inspect(op.get_bind()) + return { + index["name"] + for index in inspector.get_indexes(table_name) + if index.get("name") + } + + +def _ensure_tenant_column(table_name: str) -> None: + columns = _columns(table_name) + if "tenant_id" not in columns: + op.add_column( + table_name, + sa.Column( + "tenant_id", + sa.String(length=64), + nullable=False, + server_default=sa.text(f"'{_DEFAULT_TENANT_ID}'"), + ), + ) + return + + op.execute( + sa.text( + f"UPDATE {table_name} " + "SET tenant_id = :tenant_id " + "WHERE tenant_id IS NULL OR tenant_id = ''" + ).bindparams(tenant_id=_DEFAULT_TENANT_ID) + ) + if columns["tenant_id"].get("nullable", True): + with op.batch_alter_table(table_name) as batch_op: + batch_op.alter_column( + "tenant_id", + existing_type=sa.String(length=64), + nullable=False, + server_default=sa.text(f"'{_DEFAULT_TENANT_ID}'"), + ) + + +def upgrade() -> None: + _ensure_tenant_column("users") + _ensure_tenant_column("runs") + + if _INDEX_NAME not in _index_names("runs"): + op.create_index( + _INDEX_NAME, + "runs", + ["tenant_id", "user_id", "created_at"], + unique=False, + ) + + +def downgrade() -> None: + if _INDEX_NAME in _index_names("runs"): + op.drop_index(_INDEX_NAME, table_name="runs") + + for table_name in ("runs", "users"): + if "tenant_id" in _columns(table_name): + with op.batch_alter_table(table_name) as batch_op: + batch_op.drop_column("tenant_id") diff --git a/backend/packages/harness/deerflow/persistence/run/model.py b/backend/packages/harness/deerflow/persistence/run/model.py index d0dfe408..d6396303 100644 --- a/backend/packages/harness/deerflow/persistence/run/model.py +++ b/backend/packages/harness/deerflow/persistence/run/model.py @@ -17,6 +17,12 @@ class RunRow(Base): thread_id: Mapped[str] = mapped_column(String(64), nullable=False, index=True) assistant_id: Mapped[str | None] = mapped_column(String(128)) user_id: Mapped[str | None] = mapped_column(String(64), index=True) + tenant_id: Mapped[str] = mapped_column( + String(64), + nullable=False, + default="default", + server_default="default", + ) status: Mapped[str] = mapped_column(String(20), default="pending") # "pending" | "running" | "success" | "error" | "timeout" | "interrupted" @@ -46,4 +52,12 @@ class RunRow(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC)) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) - __table_args__ = (Index("ix_runs_thread_status", "thread_id", "status"),) + __table_args__ = ( + Index("ix_runs_thread_status", "thread_id", "status"), + Index( + "ix_runs_tenant_user_created", + "tenant_id", + "user_id", + "created_at", + ), + ) diff --git a/backend/packages/harness/deerflow/persistence/run/sql.py b/backend/packages/harness/deerflow/persistence/run/sql.py index b0b0bf97..8bc01a37 100644 --- a/backend/packages/harness/deerflow/persistence/run/sql.py +++ b/backend/packages/harness/deerflow/persistence/run/sql.py @@ -17,7 +17,12 @@ from deerflow.persistence.run.model import RunRow from deerflow.runtime.runs.cursor import decode_run_cursor from deerflow.runtime.runs.store.base import RunStore -from deerflow.runtime.user_context import AUTO, _AutoSentinel, resolve_user_id +from deerflow.runtime.user_context import ( + AUTO, + _AutoSentinel, + resolve_tenant_id, + resolve_user_id, +) from deerflow.utils.time import coerce_iso @@ -86,6 +91,7 @@ async def put( thread_id, assistant_id=None, user_id: str | None | _AutoSentinel = AUTO, + tenant_id: str | None | _AutoSentinel = AUTO, model_name: str | None = None, status="pending", multitask_strategy="reject", @@ -102,12 +108,17 @@ async def put( commit from turning the retry into a primary-key failure. """ resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.put") + resolved_tenant_id = resolve_tenant_id( + tenant_id, + method_name="RunRepository.put", + ) now = datetime.now(UTC) created = datetime.fromisoformat(created_at) if created_at else now values = { "thread_id": thread_id, "assistant_id": assistant_id, "user_id": resolved_user_id, + "tenant_id": resolved_tenant_id, "model_name": self._normalize_model_name(model_name), "status": status, "multitask_strategy": multitask_strategy, @@ -131,14 +142,24 @@ async def get( run_id, *, user_id: str | None | _AutoSentinel = AUTO, + tenant_id: str | None | _AutoSentinel = AUTO, ): resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.get") + resolved_tenant_id = resolve_tenant_id( + tenant_id, + method_name="RunRepository.get", + ) async with self._sf() as session: row = await session.get(RunRow, run_id) if row is None: return None if resolved_user_id is not None and row.user_id != resolved_user_id: return None + if ( + resolved_tenant_id is not None + and row.tenant_id != resolved_tenant_id + ): + return None return self._row_to_dict(row) async def list_by_thread( @@ -146,12 +167,19 @@ async def list_by_thread( thread_id, *, user_id: str | None | _AutoSentinel = AUTO, + tenant_id: str | None | _AutoSentinel = AUTO, limit=100, ): resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.list_by_thread") + resolved_tenant_id = resolve_tenant_id( + tenant_id, + method_name="RunRepository.list_by_thread", + ) stmt = select(RunRow).where(RunRow.thread_id == thread_id) if resolved_user_id is not None: stmt = stmt.where(RunRow.user_id == resolved_user_id) + if resolved_tenant_id is not None: + stmt = stmt.where(RunRow.tenant_id == resolved_tenant_id) stmt = stmt.order_by(RunRow.created_at.desc()).limit(limit) async with self._sf() as session: result = await session.execute(stmt) @@ -161,6 +189,8 @@ async def list_recent( self, *, user_id: str | None | _AutoSentinel = AUTO, + tenant_id: str | None | _AutoSentinel = AUTO, + thread_id: str | None = None, limit: int = 50, cursor: str | None = None, status: str | None = None, @@ -168,9 +198,17 @@ async def list_recent( started_before: str | None = None, ) -> list[dict[str, Any]]: resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.list_recent") + resolved_tenant_id = resolve_tenant_id( + tenant_id, + method_name="RunRepository.list_recent", + ) stmt = select(RunRow) if resolved_user_id is not None: stmt = stmt.where(RunRow.user_id == resolved_user_id) + if resolved_tenant_id is not None: + stmt = stmt.where(RunRow.tenant_id == resolved_tenant_id) + if thread_id is not None: + stmt = stmt.where(RunRow.thread_id == thread_id) if status is not None: stmt = stmt.where(RunRow.status == status) if started_after is not None: @@ -210,14 +248,24 @@ async def delete( run_id, *, user_id: str | None | _AutoSentinel = AUTO, + tenant_id: str | None | _AutoSentinel = AUTO, ): resolved_user_id = resolve_user_id(user_id, method_name="RunRepository.delete") + resolved_tenant_id = resolve_tenant_id( + tenant_id, + method_name="RunRepository.delete", + ) async with self._sf() as session: row = await session.get(RunRow, run_id) if row is None: return if resolved_user_id is not None and row.user_id != resolved_user_id: return + if ( + resolved_tenant_id is not None + and row.tenant_id != resolved_tenant_id + ): + return await session.delete(row) await session.commit() diff --git a/backend/packages/harness/deerflow/persistence/user/model.py b/backend/packages/harness/deerflow/persistence/user/model.py index 130d4bfc..53fc5225 100644 --- a/backend/packages/harness/deerflow/persistence/user/model.py +++ b/backend/packages/harness/deerflow/persistence/user/model.py @@ -24,6 +24,12 @@ class UserRow(Base): # UUIDs are stored as 36-char strings for cross-backend portability. id: Mapped[str] = mapped_column(String(36), primary_key=True) + tenant_id: Mapped[str] = mapped_column( + String(64), + nullable=False, + default="default", + server_default="default", + ) email: Mapped[str] = mapped_column(String(320), unique=True, nullable=False, index=True) password_hash: Mapped[str | None] = mapped_column(String(128), nullable=True) diff --git a/backend/packages/harness/deerflow/runtime/diagnostics/attribution.py b/backend/packages/harness/deerflow/runtime/diagnostics/attribution.py index 98cae860..88867753 100644 --- a/backend/packages/harness/deerflow/runtime/diagnostics/attribution.py +++ b/backend/packages/harness/deerflow/runtime/diagnostics/attribution.py @@ -41,6 +41,67 @@ def _find(timeline: list[dict[str, Any]], predicate) -> dict[str, Any] | None: return next((item for item in timeline if predicate(item)), None) +def _component_name(item: dict[str, Any]) -> str: + """Return the normalized component identity used by fallback declarations.""" + + component = str(item.get("component") or "").strip() + if component and component not in {"mcp", "tool", "runtime"}: + return component + + metadata = item.get("metadata") if isinstance(item.get("metadata"), dict) else {} + if item.get("phase") == "mcp": + server_name = str(metadata.get("server_name") or "mcp") + tool_name = metadata.get("tool_name") + return f"{server_name}.{tool_name}" if tool_name else server_name + if item.get("phase") == "tool": + return str(metadata.get("tool_name") or "tool") + return component + + +def _has_verified_fallback(timeline: list[dict[str, Any]], error_index: int) -> bool: + """Require an explicit declaration tied to a successful fallback tool.""" + + error = timeline[error_index] + failed_component = _component_name(error) + for declaration_index, declaration in enumerate(timeline[error_index + 1 :], start=error_index + 1): + if declaration.get("event_type") != "runtime.fallback.declared": + continue + metadata = declaration.get("metadata") if isinstance(declaration.get("metadata"), dict) else {} + if metadata.get("failed_component") != failed_component: + continue + if metadata.get("constraint_satisfied") is not True or metadata.get("result_verified") is not True: + continue + fallback_component = str(metadata.get("fallback_component") or "").strip() + verification = str(metadata.get("verification") or "").strip() + if not fallback_component or not verification: + continue + if any( + item.get("status") == "success" and _component_name(item) == fallback_component + for item in timeline[error_index + 1 : declaration_index] + ): + return True + return False + + +def _find_actionable_error(timeline: list[dict[str, Any]], predicate) -> dict[str, Any] | None: + """Ignore retried errors and explicitly verified, constraint-safe fallbacks.""" + + for index, item in enumerate(timeline): + if not predicate(item): + continue + phase = item.get("phase") + component = item.get("component") + retried = any( + later.get("phase") == phase + and later.get("component") == component + and later.get("status") == "success" + for later in timeline[index + 1 :] + ) + if not retried and not _has_verified_fallback(timeline, index): + return item + return None + + def _contains(item: dict[str, Any], *needles: str) -> bool: haystack = " ".join( ( @@ -80,9 +141,6 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic """Classify primary and contributing causes using deterministic rules.""" status = _status(record) - if status == "success": - return _response([_match("NONE", "runtime", 1.0, "Run completed successfully.", [], "No action required.")]) - matches: list[CauseMatch] = [] abort_action = getattr(record, "abort_action", None) if record is not None else None error = str(getattr(record, "error", "") or "") @@ -139,7 +197,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) - mcp_auth = _find( + mcp_auth = _find_actionable_error( timeline, lambda item: item.get("phase") == "mcp" and item.get("status") == "error" and _contains(item, "auth", "oauth", "unauthorized", "401"), ) @@ -155,7 +213,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) - mcp_session = _find( + mcp_session = _find_actionable_error( timeline, lambda item: item.get("phase") == "mcp" and item.get("status") == "error" and _contains(item, "session", "transport", "connect"), ) @@ -171,7 +229,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) - memory_error = _find( + memory_error = _find_actionable_error( timeline, lambda item: item.get("phase") == "memory" and item.get("status") == "error", ) @@ -203,7 +261,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) else: - subagent_error = _find( + subagent_error = _find_actionable_error( timeline, lambda item: item.get("phase") == "subagent" and item.get("status") == "error", ) @@ -219,7 +277,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) - tool_error = _find( + tool_error = _find_actionable_error( timeline, lambda item: item.get("phase") == "tool" and item.get("status") == "error", ) @@ -235,7 +293,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) - mcp_tool_error = _find( + mcp_tool_error = _find_actionable_error( timeline, lambda item: item.get("phase") == "mcp" and item.get("status") == "error" and _contains(item, "tool"), ) @@ -251,7 +309,7 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) ) - model_error = _find( + model_error = _find_actionable_error( timeline, lambda item: item.get("phase") == "model" and item.get("status") == "error", ) @@ -280,5 +338,8 @@ def attribute_failure(record: Any | None, timeline: list[dict[str, Any]]) -> dic ) if not matches: - matches.append(_match("NONE", "runtime", 0.8, "No failure was detected.", [], "No action required.")) + if status == "success": + matches.append(_match("NONE", "runtime", 1.0, "Run completed successfully.", [], "No action required.")) + else: + matches.append(_match("NONE", "runtime", 0.8, "No failure was detected.", [], "No action required.")) return _response(matches) diff --git a/backend/packages/harness/deerflow/runtime/runs/manager.py b/backend/packages/harness/deerflow/runtime/runs/manager.py index 67d1daec..eef800a3 100644 --- a/backend/packages/harness/deerflow/runtime/runs/manager.py +++ b/backend/packages/harness/deerflow/runtime/runs/manager.py @@ -10,6 +10,12 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from deerflow.runtime.user_context import ( + DEFAULT_TENANT_ID, + DEFAULT_USER_ID, + get_effective_tenant_id, + get_effective_user_id, +) from deerflow.utils.time import now_iso as _now_iso from .schemas import DisconnectMode, RunStatus @@ -80,6 +86,8 @@ class RunRecord: assistant_id: str | None status: RunStatus on_disconnect: DisconnectMode + user_id: str = DEFAULT_USER_ID + tenant_id: str = DEFAULT_TENANT_ID multitask_strategy: str = "reject" metadata: dict = field(default_factory=dict) kwargs: dict = field(default_factory=dict) @@ -127,6 +135,8 @@ def _store_put_payload(record: RunRecord, *, error: str | None = None) -> dict[s return { "thread_id": record.thread_id, "assistant_id": record.assistant_id, + "user_id": record.user_id, + "tenant_id": record.tenant_id, "status": record.status.value, "multitask_strategy": record.multitask_strategy, "metadata": record.metadata or {}, @@ -236,6 +246,8 @@ def _record_from_store(row: dict[str, Any]) -> RunRecord: assistant_id=row.get("assistant_id"), status=RunStatus(row.get("status") or RunStatus.pending.value), on_disconnect=DisconnectMode(row.get("on_disconnect") or DisconnectMode.cancel.value), + user_id=str(row.get("user_id") or DEFAULT_USER_ID), + tenant_id=str(row.get("tenant_id") or DEFAULT_TENANT_ID), multitask_strategy=row.get("multitask_strategy") or "reject", metadata=row.get("metadata") or {}, kwargs=row.get("kwargs") or {}, @@ -330,6 +342,8 @@ async def create( assistant_id=assistant_id, status=RunStatus.pending, on_disconnect=on_disconnect, + user_id=get_effective_user_id(), + tenant_id=get_effective_tenant_id(), multitask_strategy=multitask_strategy, metadata=metadata or {}, kwargs=kwargs or {}, @@ -352,21 +366,36 @@ async def create( logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id) return record - async def get(self, run_id: str, *, user_id: str | None = None) -> RunRecord | None: + async def get( + self, + run_id: str, + *, + user_id: str | None = None, + tenant_id: str | None = None, + ) -> RunRecord | None: """Return a run record by ID, or ``None``. Args: run_id: The run ID to look up. - user_id: Optional user ID for permission filtering when hydrating from store. + user_id: Optional user ID for permission filtering. + tenant_id: Optional tenant ID for permission filtering. """ async with self._lock: record = self._runs.get(run_id) if record is not None: + if user_id is not None and record.user_id != user_id: + return None + if tenant_id is not None and record.tenant_id != tenant_id: + return None return record if self._store is None: return None try: - row = await self._store.get(run_id, user_id=user_id) + row = await self._store.get( + run_id, + user_id=user_id, + tenant_id=tenant_id, + ) except Exception: logger.warning("Failed to hydrate run %s from store", run_id, exc_info=True) return None @@ -375,6 +404,10 @@ async def get(self, run_id: str, *, user_id: str | None = None) -> RunRecord | N async with self._lock: record = self._runs.get(run_id) if record is not None: + if user_id is not None and record.user_id != user_id: + return None + if tenant_id is not None and record.tenant_id != tenant_id: + return None return record if row is None: return None @@ -384,14 +417,31 @@ async def get(self, run_id: str, *, user_id: str | None = None) -> RunRecord | N logger.warning("Failed to map store row for run %s", run_id, exc_info=True) return None - async def aget(self, run_id: str, *, user_id: str | None = None) -> RunRecord | None: + async def aget( + self, + run_id: str, + *, + user_id: str | None = None, + tenant_id: str | None = None, + ) -> RunRecord | None: """Return a run record by ID, checking the persistent store as fallback. Alias for :meth:`get` for backward compatibility. """ - return await self.get(run_id, user_id=user_id) + return await self.get( + run_id, + user_id=user_id, + tenant_id=tenant_id, + ) - async def list_by_thread(self, thread_id: str, *, user_id: str | None = None, limit: int = 100) -> list[RunRecord]: + async def list_by_thread( + self, + thread_id: str, + *, + user_id: str | None = None, + tenant_id: str | None = None, + limit: int = 100, + ) -> list[RunRecord]: """Return runs for a given thread, newest first, at most ``limit`` records. In-memory runs take precedence only when the same ``run_id`` exists in both @@ -405,13 +455,24 @@ async def list_by_thread(self, thread_id: str, *, user_id: str | None = None, li """ async with self._lock: # Dict insertion order gives deterministic results when timestamps tie. - memory_records = [r for r in self._runs.values() if r.thread_id == thread_id] + memory_records = [ + record + for record in self._runs.values() + if record.thread_id == thread_id + and (user_id is None or record.user_id == user_id) + and (tenant_id is None or record.tenant_id == tenant_id) + ] if self._store is None: return sorted(memory_records, key=lambda r: r.created_at, reverse=True)[:limit] records_by_id = {record.run_id: record for record in memory_records} store_limit = max(0, limit - len(memory_records)) try: - rows = await self._store.list_by_thread(thread_id, user_id=user_id, limit=store_limit) + rows = await self._store.list_by_thread( + thread_id, + user_id=user_id, + tenant_id=tenant_id, + limit=store_limit, + ) except Exception: logger.warning("Failed to hydrate runs for thread %s from store", thread_id, exc_info=True) return sorted(memory_records, key=lambda r: r.created_at, reverse=True)[:limit] @@ -428,6 +489,8 @@ async def list_recent( self, *, user_id: str | None, + tenant_id: str | None = None, + thread_id: str | None = None, limit: int = 50, cursor: str | None = None, status: str | None = None, @@ -440,6 +503,8 @@ async def list_recent( return [] rows = await self._store.list_recent( user_id=user_id, + tenant_id=tenant_id, + thread_id=thread_id, limit=limit, cursor=cursor, status=status, @@ -573,6 +638,8 @@ async def create_or_reject( assistant_id=assistant_id, status=RunStatus.pending, on_disconnect=on_disconnect, + user_id=get_effective_user_id(), + tenant_id=get_effective_tenant_id(), multitask_strategy=multitask_strategy, metadata=metadata or {}, kwargs=kwargs or {}, diff --git a/backend/packages/harness/deerflow/runtime/runs/store/base.py b/backend/packages/harness/deerflow/runtime/runs/store/base.py index 05bf0caf..bf5015e1 100644 --- a/backend/packages/harness/deerflow/runtime/runs/store/base.py +++ b/backend/packages/harness/deerflow/runtime/runs/store/base.py @@ -23,6 +23,7 @@ async def put( thread_id: str, assistant_id: str | None = None, user_id: str | None = None, + tenant_id: str | None = None, model_name: str | None = None, status: str = "pending", multitask_strategy: str = "reject", @@ -39,6 +40,7 @@ async def get( run_id: str, *, user_id: str | None = None, + tenant_id: str | None = None, ) -> dict[str, Any] | None: pass @@ -48,6 +50,7 @@ async def list_by_thread( thread_id: str, *, user_id: str | None = None, + tenant_id: str | None = None, limit: int = 100, ) -> list[dict[str, Any]]: pass @@ -56,6 +59,8 @@ async def list_recent( self, *, user_id: str | None, + tenant_id: str | None = None, + thread_id: str | None = None, limit: int = 50, cursor: str | None = None, status: str | None = None, diff --git a/backend/packages/harness/deerflow/runtime/runs/store/memory.py b/backend/packages/harness/deerflow/runtime/runs/store/memory.py index 7985d89a..20b21502 100644 --- a/backend/packages/harness/deerflow/runtime/runs/store/memory.py +++ b/backend/packages/harness/deerflow/runtime/runs/store/memory.py @@ -23,6 +23,7 @@ async def put( thread_id, assistant_id=None, user_id=None, + tenant_id=None, model_name=None, status="pending", multitask_strategy="reject", @@ -37,6 +38,7 @@ async def put( "thread_id": thread_id, "assistant_id": assistant_id, "user_id": user_id, + "tenant_id": tenant_id, "model_name": model_name, "status": status, "multitask_strategy": multitask_strategy, @@ -47,16 +49,31 @@ async def put( "updated_at": now, } - async def get(self, run_id, *, user_id=None): + async def get(self, run_id, *, user_id=None, tenant_id=None): run = self._runs.get(run_id) if run is None: return None if user_id is not None and run.get("user_id") != user_id: return None + if tenant_id is not None and run.get("tenant_id") != tenant_id: + return None return run - async def list_by_thread(self, thread_id, *, user_id=None, limit=100): - results = [r for r in self._runs.values() if r["thread_id"] == thread_id and (user_id is None or r.get("user_id") == user_id)] + async def list_by_thread( + self, + thread_id, + *, + user_id=None, + tenant_id=None, + limit=100, + ): + results = [ + run + for run in self._runs.values() + if run["thread_id"] == thread_id + and (user_id is None or run.get("user_id") == user_id) + and (tenant_id is None or run.get("tenant_id") == tenant_id) + ] results.sort(key=lambda r: r["created_at"], reverse=True) return results[:limit] @@ -64,13 +81,21 @@ async def list_recent( self, *, user_id, + tenant_id=None, + thread_id=None, limit=50, cursor=None, status=None, started_after=None, started_before=None, ): - results = [run for run in self._runs.values() if user_id is None or run.get("user_id") == user_id] + results = [ + run + for run in self._runs.values() + if (user_id is None or run.get("user_id") == user_id) + and (tenant_id is None or run.get("tenant_id") == tenant_id) + and (thread_id is None or run.get("thread_id") == thread_id) + ] if status is not None: results = [run for run in results if run.get("status") == status] if started_after is not None: diff --git a/backend/packages/harness/deerflow/runtime/runs/worker.py b/backend/packages/harness/deerflow/runtime/runs/worker.py index e0c1aefa..cbb25835 100644 --- a/backend/packages/harness/deerflow/runtime/runs/worker.py +++ b/backend/packages/harness/deerflow/runtime/runs/worker.py @@ -220,6 +220,7 @@ async def run_agent( # manually here because we drive the graph through ``agent.astream(config=...)`` # without passing the official ``context=`` parameter. runtime_ctx = _build_runtime_context(thread_id, run_id, config.get("context"), ctx.app_config) + runtime_ctx.setdefault("user_id", get_effective_user_id()) # Expose the run-scoped journal under a sentinel key so middleware can # write audit events (e.g. SafetyFinishReasonMiddleware recording # suppressed tool calls). Double-underscore prefix marks it as a @@ -252,10 +253,13 @@ async def run_agent( # the agent name that this run will actually execute. config.setdefault("run_name", resolve_root_run_name(config, record.assistant_id)) runnable_config = RunnableConfig(**config) + # Agent construction can perform MCP discovery. HTTP MCP discovery may + # request a token from this Gateway, so keep the server event loop free + # to serve that request instead of synchronously blocking it here. if ctx.app_config is not None and _agent_factory_supports_app_config(agent_factory): - agent = agent_factory(config=runnable_config, app_config=ctx.app_config) + agent = await asyncio.to_thread(agent_factory, config=runnable_config, app_config=ctx.app_config) else: - agent = agent_factory(config=runnable_config) + agent = await asyncio.to_thread(agent_factory, config=runnable_config) # Capture the effective (resolved) model name from the agent's metadata. # _resolve_model_name in agent.py may return the default model if the diff --git a/backend/packages/harness/deerflow/runtime/user_context.py b/backend/packages/harness/deerflow/runtime/user_context.py index cfbb68c9..a21f19fc 100644 --- a/backend/packages/harness/deerflow/runtime/user_context.py +++ b/backend/packages/harness/deerflow/runtime/user_context.py @@ -95,6 +95,7 @@ def require_current_user() -> CurrentUser: # --------------------------------------------------------------------------- DEFAULT_USER_ID: Final[str] = "default" +DEFAULT_TENANT_ID: Final[str] = "default" def get_effective_user_id() -> str: @@ -109,6 +110,13 @@ def get_effective_user_id() -> str: return str(user.id) +def get_effective_tenant_id() -> str: + """Return the current tenant id, defaulting legacy users to ``default``.""" + user = _current_user.get() + tenant_id = getattr(user, "tenant_id", None) if user is not None else None + return str(tenant_id or DEFAULT_TENANT_ID) + + def resolve_runtime_user_id(runtime: object | None) -> str: """Single source of truth for a tool/middleware's effective user_id. @@ -137,6 +145,16 @@ def resolve_runtime_user_id(runtime: object | None) -> str: return get_effective_user_id() +def resolve_runtime_tenant_id(runtime: object | None) -> str: + """Resolve tenant identity from runtime context, then request context.""" + context = getattr(runtime, "context", None) + if isinstance(context, dict): + tenant_id = context.get("tenant_id") + if tenant_id: + return str(tenant_id) + return get_effective_tenant_id() + + # --------------------------------------------------------------------------- # Sentinel-based user_id resolution # --------------------------------------------------------------------------- @@ -193,3 +211,21 @@ def resolve_user_id( # rather than ripple a type change through every caller. return str(user.id) return value + + +def resolve_tenant_id( + value: str | None | _AutoSentinel, + *, + method_name: str = "repository method", +) -> str | None: + """Resolve a repository tenant filter with the same semantics as user_id.""" + if isinstance(value, _AutoSentinel): + user = _current_user.get() + if user is None: + raise RuntimeError( + f"{method_name} called with tenant_id=AUTO but no user context is set; " + "pass an explicit tenant_id, set the contextvar via auth middleware, " + "or opt out with tenant_id=None for migration/CLI paths." + ) + return str(getattr(user, "tenant_id", None) or DEFAULT_TENANT_ID) + return value diff --git a/backend/packages/harness/deerflow/tools/builtins/__init__.py b/backend/packages/harness/deerflow/tools/builtins/__init__.py index 4b1afa03..a28a155b 100644 --- a/backend/packages/harness/deerflow/tools/builtins/__init__.py +++ b/backend/packages/harness/deerflow/tools/builtins/__init__.py @@ -1,5 +1,6 @@ from .clarification_tool import ask_clarification_tool from .present_file_tool import present_file_tool +from .report_fallback_tool import report_fallback from .setup_agent_tool import setup_agent from .task_tool import task_tool from .update_agent_tool import update_agent @@ -9,6 +10,7 @@ "setup_agent", "update_agent", "present_file_tool", + "report_fallback", "ask_clarification_tool", "view_image_tool", "task_tool", diff --git a/backend/packages/harness/deerflow/tools/builtins/report_fallback_tool.py b/backend/packages/harness/deerflow/tools/builtins/report_fallback_tool.py new file mode 100644 index 00000000..35a87827 --- /dev/null +++ b/backend/packages/harness/deerflow/tools/builtins/report_fallback_tool.py @@ -0,0 +1,62 @@ +"""Structured, auditable fallback reporting for AgentOps diagnostics.""" + +from __future__ import annotations + +from langchain.tools import tool + +from deerflow.runtime.diagnostics.emitter import find_diagnostic_recorder +from deerflow.tools.types import Runtime + + +@tool("report_fallback", parse_docstring=True) +def report_fallback( + runtime: Runtime, + failed_component: str, + fallback_component: str, + constraint_satisfied: bool, + result_verified: bool, + verification: str, + reason: str, +) -> str: + """Declare a verified fallback after a tool or MCP operation failed. + + Call this only after the replacement component has completed successfully. + Set constraint_satisfied to false when the user required the original tool, + source, freshness, or write guarantee. Set result_verified to true only + when verification is concrete and describe that check in verification. + + Args: + failed_component: Exact failed component shown in the execution trace. + fallback_component: Exact replacement component that completed successfully. + constraint_satisfied: Whether the fallback still meets the user's explicit constraints. + result_verified: Whether the fallback result was concretely verified. + verification: Privacy-safe description of the verification performed. + reason: Privacy-safe reason the original component was replaced. + """ + + failed_component = failed_component.strip() + fallback_component = fallback_component.strip() + verification = verification.strip() + reason = reason.strip() + if not failed_component or not fallback_component or not verification or not reason: + return "Fallback declaration rejected: component names, verification, and reason are required." + + recorder = find_diagnostic_recorder(runtime) + if recorder is None: + return "Fallback declaration could not be recorded because diagnostics are unavailable." + + recorder.record_diagnostic_event( + "runtime.fallback.declared", + component="fallback", + status="success", + content="Verified fallback declared.", + metadata={ + "failed_component": failed_component, + "fallback_component": fallback_component, + "constraint_satisfied": constraint_satisfied, + "result_verified": result_verified, + "verification": verification, + "reason": reason, + }, + ) + return "Fallback declaration recorded for FlowLens diagnostics." diff --git a/backend/packages/harness/deerflow/tools/sync.py b/backend/packages/harness/deerflow/tools/sync.py index 7521dd7b..c4dd2586 100644 --- a/backend/packages/harness/deerflow/tools/sync.py +++ b/backend/packages/harness/deerflow/tools/sync.py @@ -79,6 +79,7 @@ def run_coroutine(*args: Any, **kwargs: Any) -> Any: if config_param: + @functools.wraps(coro) def sync_wrapper(*args: Any, config: RunnableConfig = None, **kwargs: Any) -> Any: if config is not None or config_param not in kwargs: kwargs[config_param] = config @@ -86,6 +87,7 @@ def sync_wrapper(*args: Any, config: RunnableConfig = None, **kwargs: Any) -> An return sync_wrapper + @functools.wraps(coro) def sync_wrapper(*args: Any, **kwargs: Any) -> Any: return run_coroutine(*args, **kwargs) diff --git a/backend/packages/harness/deerflow/tools/tools.py b/backend/packages/harness/deerflow/tools/tools.py index bd8daed9..547df79f 100644 --- a/backend/packages/harness/deerflow/tools/tools.py +++ b/backend/packages/harness/deerflow/tools/tools.py @@ -6,7 +6,7 @@ from deerflow.config.app_config import AppConfig from deerflow.reflection import resolve_variable from deerflow.sandbox.security import is_host_bash_allowed -from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, task_tool, view_image_tool +from deerflow.tools.builtins import ask_clarification_tool, present_file_tool, report_fallback, task_tool, view_image_tool from deerflow.tools.mcp_metadata import tag_mcp_tool from deerflow.tools.sync import make_sync_tool_wrapper @@ -15,6 +15,7 @@ BUILTIN_TOOLS = [ present_file_tool, ask_clarification_tool, + report_fallback, ] SUBAGENT_TOOLS = [ diff --git a/backend/packages/sqlite-analytics-mcp/pyproject.toml b/backend/packages/sqlite-analytics-mcp/pyproject.toml new file mode 100644 index 00000000..7c4a90d2 --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "sqlite-analytics-mcp" +version = "0.1.0" +description = "Read-only local SQLite analytics MCP service for DeerFlow demonstrations" +requires-python = ">=3.12" +dependencies = ["mcp>=1.0.0"] + +[project.scripts] +sqlite-analytics-mcp = "sqlite_analytics_mcp.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/__init__.py b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/__init__.py new file mode 100644 index 00000000..d043f699 --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/__init__.py @@ -0,0 +1 @@ +"""Read-only SQLite analytics MCP service.""" diff --git a/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/query_guard.py b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/query_guard.py new file mode 100644 index 00000000..5ea6eee1 --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/query_guard.py @@ -0,0 +1,36 @@ +"""Conservative SQL allowlist for the SQLite Analytics MCP service.""" + +from __future__ import annotations + +import re + + +class ReadonlyQueryError(ValueError): + """Raised when a query is outside the read-only analytics contract.""" + + +_MUTATING_OR_ESCAPE_KEYWORDS = re.compile( + r"\b(" + r"alter|analyze|attach|begin|commit|create|delete|detach|drop|" + r"insert|pragma|reindex|release|replace|rollback|savepoint|update|vacuum" + r")\b", + re.IGNORECASE, +) +_READONLY_PREFIX = re.compile(r"^(select|with\b|explain\s+query\s+plan\s+(select|with\b))", re.IGNORECASE) + + +def validate_readonly_sql(statement: str) -> str: + """Return a single permitted query or reject it before SQLite sees it.""" + + normalized = statement.strip() + if not normalized: + raise ReadonlyQueryError("A SQL statement is required") + if normalized.endswith(";"): + normalized = normalized[:-1].rstrip() + if ";" in normalized: + raise ReadonlyQueryError("Multiple SQL statements are not allowed") + if not _READONLY_PREFIX.match(normalized): + raise ReadonlyQueryError("Only SELECT, WITH ... SELECT, and EXPLAIN QUERY PLAN are allowed") + if _MUTATING_OR_ESCAPE_KEYWORDS.search(normalized): + raise ReadonlyQueryError("The SQLite Analytics MCP only permits read-only queries") + return normalized diff --git a/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/repository.py b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/repository.py new file mode 100644 index 00000000..48bd6fd6 --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/repository.py @@ -0,0 +1,128 @@ +"""Bounded, read-only SQLite access for analytics MCP tools.""" + +from __future__ import annotations + +import sqlite3 +from dataclasses import dataclass +from pathlib import Path + +from .query_guard import validate_readonly_sql + + +@dataclass(frozen=True, slots=True) +class QueryResult: + """Serializable result returned by a bounded analytics query.""" + + columns: list[str] + rows: list[list[object]] + truncated: bool + + +class SQLiteAnalyticsRepository: + """Owns database initialization and per-request read-only connections.""" + + def __init__(self, database_path: Path) -> None: + self._database_path = database_path + + def initialize(self) -> None: + """Create the deterministic local demonstration schema once.""" + + self._database_path.parent.mkdir(parents=True, exist_ok=True) + with sqlite3.connect(self._database_path) as connection: + connection.executescript( + (Path(__file__).parent / "seed.sql").read_text( + encoding="utf-8", + ), + ) + + def execute_readonly(self, statement: str, *, limit: int) -> QueryResult: + """Execute a permitted query and return at most ``limit`` rows.""" + + if limit < 1 or limit > 100: + raise ValueError("limit must be between 1 and 100") + query = validate_readonly_sql(statement) + with self._connect() as connection: + cursor = connection.execute(query) + columns = [column[0] for column in cursor.description or []] + fetched = cursor.fetchmany(limit + 1) + return QueryResult( + columns=columns, + rows=[list(row) for row in fetched[:limit]], + truncated=len(fetched) > limit, + ) + + def execute_raw_for_defense_test(self, statement: str) -> None: + """Exercise SQLite authorization without bypassing connection policy.""" + + with self._connect() as connection: + connection.execute(statement) + + def schema_overview(self) -> list[dict[str, str]]: + """Return permitted tables and their SQLite-defined columns.""" + + with self._connect() as connection: + tables = connection.execute( + "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name", + ).fetchall() + return [{"name": str(row[0])} for row in tables] + + def describe_table(self, table_name: str) -> list[dict[str, str]]: + """Return column names and declared types for an allowed table.""" + + if table_name not in {"agent_runs", "tool_calls"}: + raise ValueError("Unknown analytics table") + with self._connect() as connection: + rows = connection.execute( + f"PRAGMA table_info({table_name})", + ).fetchall() + return [{"name": str(row[1]), "type": str(row[2])} for row in rows] + + def _connect(self) -> sqlite3.Connection: + connection = sqlite3.connect(self._database_path) + connection.set_authorizer(_readonly_authorizer) + return connection + + +def _readonly_authorizer( + action_code: int, + parameter_one: str | None, + parameter_two: str | None, + database_name: str | None, + trigger_name: str | None, +) -> int: + """Deny mutations and escape hatches at the SQLite virtual machine layer.""" + + del parameter_one, database_name, trigger_name + denied_actions = { + sqlite3.SQLITE_ALTER_TABLE, + sqlite3.SQLITE_ANALYZE, + sqlite3.SQLITE_ATTACH, + sqlite3.SQLITE_CREATE_INDEX, + sqlite3.SQLITE_CREATE_TABLE, + sqlite3.SQLITE_CREATE_TEMP_INDEX, + sqlite3.SQLITE_CREATE_TEMP_TABLE, + sqlite3.SQLITE_CREATE_TEMP_TRIGGER, + sqlite3.SQLITE_CREATE_TEMP_VIEW, + sqlite3.SQLITE_CREATE_TRIGGER, + sqlite3.SQLITE_CREATE_VIEW, + sqlite3.SQLITE_DELETE, + sqlite3.SQLITE_DETACH, + sqlite3.SQLITE_DROP_INDEX, + sqlite3.SQLITE_DROP_TABLE, + sqlite3.SQLITE_DROP_TEMP_INDEX, + sqlite3.SQLITE_DROP_TEMP_TABLE, + sqlite3.SQLITE_DROP_TEMP_TRIGGER, + sqlite3.SQLITE_DROP_TEMP_VIEW, + sqlite3.SQLITE_DROP_TRIGGER, + sqlite3.SQLITE_DROP_VIEW, + sqlite3.SQLITE_INSERT, + sqlite3.SQLITE_PRAGMA, + sqlite3.SQLITE_REINDEX, + sqlite3.SQLITE_TRANSACTION, + sqlite3.SQLITE_UPDATE, + } + if action_code in denied_actions: + return sqlite3.SQLITE_DENY + if action_code == sqlite3.SQLITE_FUNCTION and parameter_two == "load_extension": + return sqlite3.SQLITE_DENY + return sqlite3.SQLITE_OK diff --git a/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/seed.sql b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/seed.sql new file mode 100644 index 00000000..73c11b3c --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/seed.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS agent_runs ( + run_id TEXT PRIMARY KEY, + scenario TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + token_count INTEGER NOT NULL, + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tool_calls ( + call_id INTEGER PRIMARY KEY, + run_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + outcome TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + FOREIGN KEY (run_id) REFERENCES agent_runs(run_id) +); + +INSERT OR IGNORE INTO agent_runs VALUES + ('demo-run-001', 'web-research', 'success', 12460, 3460, '2026-07-29T09:00:00Z'), + ('demo-run-002', 'incident-analysis', 'error', 9800, 2190, '2026-07-29T10:00:00Z'), + ('demo-run-003', 'report-generation', 'success', 15120, 4210, '2026-07-29T11:00:00Z'); + +INSERT OR IGNORE INTO tool_calls VALUES + (1, 'demo-run-001', 'web_search', 'success', 2150), + (2, 'demo-run-001', 'fetch_url', 'success', 3120), + (3, 'demo-run-002', 'bash', 'error', 420), + (4, 'demo-run-003', 'write_file', 'success', 180); diff --git a/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/server.py b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/server.py new file mode 100644 index 00000000..2a566eb6 --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/server.py @@ -0,0 +1,163 @@ +"""Streamable HTTP MCP server for bounded SQLite analytics.""" + +from __future__ import annotations + +from typing import Any + +from mcp.server.auth.middleware.auth_context import get_access_token +from mcp.server.auth.provider import AccessToken, TokenVerifier +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP + +from app.gateway.auth.platform_tokens import ( + PlatformTokenError, + PlatformTokenSettings, + verify_platform_token, +) + +from .repository import SQLiteAnalyticsRepository +from .settings import SQLiteAnalyticsSettings + + +class PlatformTokenVerifier: + """Adapt signed DeerFlow platform tokens to FastMCP authentication.""" + + def __init__(self, settings: PlatformTokenSettings | None = None) -> None: + self._settings = settings + + async def verify_token(self, token: str) -> AccessToken | None: + try: + claims = verify_platform_token(token, self._settings) + except PlatformTokenError: + return None + return AccessToken( + token=token, + client_id=claims.azp, + scopes=claims.scope, + expires_at=int(claims.exp.timestamp()), + ) + + +def require_scope(scope: str) -> None: + """Require an analytics scope after unauthenticated discovery is complete.""" + + access_token = get_access_token() + if access_token is None or scope not in access_token.scopes: + raise PermissionError(f"MCP tool requires delegated scope: {scope}") + + +class SQLiteAnalyticsTools: + """Tool handlers with no dependency on MCP transport internals.""" + + def __init__(self, repository: SQLiteAnalyticsRepository) -> None: + self._repository = repository + + def schema_overview(self) -> dict[str, Any]: + """List the read-only local demonstration tables.""" + + return { + "schema_version": 1, + "tables": self._repository.schema_overview(), + } + + def describe_table(self, table_name: str) -> dict[str, Any]: + """Describe one documented demonstration table.""" + + return { + "schema_version": 1, + "table": table_name, + "columns": self._repository.describe_table(table_name), + } + + def query_readonly(self, statement: str, limit: int = 50) -> dict[str, Any]: + """Run a bounded SQL query accepted by the read-only policy.""" + + result = self._repository.execute_readonly(statement, limit=limit) + return { + "schema_version": 1, + "columns": result.columns, + "rows": result.rows, + "row_count": len(result.rows), + "truncated": result.truncated, + } + + +def create_server( + repository: SQLiteAnalyticsRepository, + *, + host: str, + port: int, + token_verifier: TokenVerifier | None = None, + auth: AuthSettings | None = None, +) -> FastMCP: + """Create an MCP application that exposes the three analytics tools.""" + + if (token_verifier is None) != (auth is None): + raise ValueError("token_verifier and auth must be configured together") + + handlers = SQLiteAnalyticsTools(repository) + server = FastMCP( + "SQLite Analytics MCP", + instructions=("Use schema_overview and describe_table before query_readonly. All database access is read-only and bounded."), + host=host, + port=port, + streamable_http_path="/mcp", + token_verifier=token_verifier, + auth=auth, + ) + + def schema_overview() -> dict[str, Any]: + """List the allowed local demonstration tables before any SQL query. + + Use this first to discover the read-only analytics schema. The result is + local demonstration data and never represents production telemetry. + """ + require_scope("analytics.schema.read") + return handlers.schema_overview() + + def describe_table(table_name: str) -> dict[str, Any]: + """Describe columns for one allowed local demonstration table. + + Call this after schema_overview and before querying a table for the + first time. Supported tables are agent_runs and tool_calls. + """ + require_scope("analytics.schema.read") + return handlers.describe_table(table_name) + + def query_readonly(statement: str, limit: int = 50) -> dict[str, Any]: + """Run one bounded read-only SQL statement against local demo analytics. + + Only SELECT, WITH, and EXPLAIN QUERY PLAN statements are accepted. Do + not use writes, DDL, PRAGMA, ATTACH, or multiple statements. + """ + require_scope("analytics.query.read") + return handlers.query_readonly(statement, limit=limit) + + server.tool()(schema_overview) + server.tool()(describe_table) + server.tool()(query_readonly) + return server + + +def main() -> None: + """Initialize local data and serve MCP over Streamable HTTP.""" + + settings = SQLiteAnalyticsSettings.from_env() + repository = SQLiteAnalyticsRepository(settings.database_path) + repository.initialize() + server = create_server( + repository, + host=settings.host, + port=settings.port, + token_verifier=PlatformTokenVerifier(), + auth=AuthSettings( + issuer_url=settings.issuer_url, + resource_server_url=settings.resource_url, + required_scopes=[], + ), + ) + server.run(transport="streamable-http") + + +if __name__ == "__main__": + main() diff --git a/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/settings.py b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/settings.py new file mode 100644 index 00000000..49b97d1a --- /dev/null +++ b/backend/packages/sqlite-analytics-mcp/sqlite_analytics_mcp/settings.py @@ -0,0 +1,37 @@ +"""Environment-backed settings for SQLite Analytics MCP.""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True, slots=True) +class SQLiteAnalyticsSettings: + database_path: Path + host: str + port: int + issuer_url: str + resource_url: str + + @classmethod + def from_env(cls) -> SQLiteAnalyticsSettings: + return cls( + database_path=Path( + os.getenv( + "SQLITE_ANALYTICS_DATABASE_PATH", + "/var/lib/sqlite-analytics/analytics.db", + ), + ), + host=os.getenv("SQLITE_ANALYTICS_HOST", "0.0.0.0"), + port=int(os.getenv("SQLITE_ANALYTICS_PORT", "8011")), + issuer_url=os.getenv( + "SQLITE_ANALYTICS_ISSUER_URL", + "http://gateway:8000", + ).rstrip("/"), + resource_url=os.getenv( + "SQLITE_ANALYTICS_RESOURCE_URL", + "http://sqlite-analytics:8011", + ).rstrip("/"), + ) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d9dfddda..788e5e1b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -46,7 +46,11 @@ markers = [ index-url = "https://pypi.org/simple" [tool.uv.workspace] -members = ["packages/harness"] +members = [ + "packages/harness", + "packages/sqlite-analytics-mcp", + "packages/flowlens-inspector-mcp", +] [tool.uv.sources] deerflow-harness = { workspace = true } diff --git a/backend/scripts/generate_flowlens_mcp_keys.py b/backend/scripts/generate_flowlens_mcp_keys.py new file mode 100644 index 00000000..74e6fb66 --- /dev/null +++ b/backend/scripts/generate_flowlens_mcp_keys.py @@ -0,0 +1,48 @@ +"""Generate local-only FlowLens MCP RSA key material for Docker development.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +def generate(directory: Path) -> None: + """Create a key pair only when it is absent, preserving existing sessions.""" + + private_path = directory / "private.pem" + public_path = directory / "public.pem" + if private_path.exists() and public_path.exists(): + return + + directory.mkdir(parents=True, exist_ok=True) + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + private_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ), + ) + public_path.write_bytes( + private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ), + ) + os.chmod(private_path, 0o600) + os.chmod(public_path, 0o644) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--directory", type=Path, required=True) + args = parser.parse_args() + generate(args.directory) + + +if __name__ == "__main__": + main() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 03dee4b0..82f82eca 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,6 +17,22 @@ # Make 'app' and 'deerflow' importable from any working directory sys.path.insert(0, str(Path(__file__).parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) +sys.path.insert( + 0, + str( + Path(__file__).resolve().parents[1] + / "packages" + / "sqlite-analytics-mcp", + ), +) +sys.path.insert( + 0, + str( + Path(__file__).resolve().parents[1] + / "packages" + / "flowlens-inspector-mcp", + ), +) # Break the circular import chain that exists in production code: # deerflow.subagents.__init__ diff --git a/backend/tests/flowlens_inspector_mcp/test_gateway_client.py b/backend/tests/flowlens_inspector_mcp/test_gateway_client.py new file mode 100644 index 00000000..d413dbb8 --- /dev/null +++ b/backend/tests/flowlens_inspector_mcp/test_gateway_client.py @@ -0,0 +1,14 @@ +"""Inspector Gateway client credential-boundary tests.""" + +from __future__ import annotations + +from flowlens_inspector_mcp.gateway_client import GatewayClient + + +def test_gateway_client_uses_the_request_scoped_bearer_token() -> None: + client = GatewayClient( + "http://gateway:8000", + token_provider=lambda: "delegated-user-token", + ) + + assert client.request_headers == {"Authorization": "Bearer delegated-user-token"} diff --git a/backend/tests/flowlens_inspector_mcp/test_platform_auth.py b/backend/tests/flowlens_inspector_mcp/test_platform_auth.py new file mode 100644 index 00000000..cb317f58 --- /dev/null +++ b/backend/tests/flowlens_inspector_mcp/test_platform_auth.py @@ -0,0 +1,52 @@ +"""Platform-token verification for the Inspector MCP boundary.""" + +from __future__ import annotations + +import asyncio + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from flowlens_inspector_mcp.auth import PlatformTokenVerifier + +from app.gateway.auth.platform_tokens import ( + PlatformTokenIssuer, + PlatformTokenSettings, +) + + +def test_verifier_converts_a_valid_delegated_platform_token() -> None: + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + settings = PlatformTokenSettings( + private_key_pem=private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + public_key_pem=private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode(), + kid="flowlens-test-key", + issuer="deerflow-gateway", + audience="flowlens-platform", + client_id="deerflow-runtime", + client_secret="test-client-secret", + ) + token = PlatformTokenIssuer(settings).issue_delegated_token( + user_id="user-a", + tenant_id="tenant-a", + token_version=2, + origin_run_id="run-origin", + ) + + access_token = asyncio.run(PlatformTokenVerifier(settings).verify_token(token)) + + assert access_token is not None + assert access_token.client_id == "deerflow-runtime" + assert "agentops.runs.read" in access_token.scopes + + +def test_verifier_rejects_malformed_tokens() -> None: + assert asyncio.run(PlatformTokenVerifier().verify_token("not-a-jwt")) is None diff --git a/backend/tests/flowlens_inspector_mcp/test_settings.py b/backend/tests/flowlens_inspector_mcp/test_settings.py new file mode 100644 index 00000000..78ee05ac --- /dev/null +++ b/backend/tests/flowlens_inspector_mcp/test_settings.py @@ -0,0 +1,23 @@ +"""Environment configuration for the Inspector MCP service.""" + +from __future__ import annotations + +from flowlens_inspector_mcp.settings import InspectorSettings + + +def test_settings_use_container_safe_defaults(monkeypatch) -> None: + monkeypatch.delenv("FLOWLENS_INSPECTOR_GATEWAY_URL", raising=False) + monkeypatch.delenv("FLOWLENS_INSPECTOR_PORT", raising=False) + + settings = InspectorSettings.from_env() + + assert settings.gateway_url == "http://gateway:8000" + assert settings.port == 8012 + + +def test_settings_accept_explicit_public_resource_url(monkeypatch) -> None: + monkeypatch.setenv("FLOWLENS_INSPECTOR_RESOURCE_URL", "http://localhost:8012") + + settings = InspectorSettings.from_env() + + assert settings.resource_url == "http://localhost:8012" diff --git a/backend/tests/flowlens_inspector_mcp/test_tools.py b/backend/tests/flowlens_inspector_mcp/test_tools.py new file mode 100644 index 00000000..e3931531 --- /dev/null +++ b/backend/tests/flowlens_inspector_mcp/test_tools.py @@ -0,0 +1,105 @@ +"""Read-only FlowLens Inspector MCP tool contracts.""" + +from __future__ import annotations + +import asyncio +from dataclasses import dataclass +from typing import Any + +import pytest +from flowlens_inspector_mcp.auth import PlatformTokenVerifier +from flowlens_inspector_mcp.server import FlowLensInspectorTools, require_scope +from mcp.server.auth.settings import AuthSettings + + +@dataclass +class FakeGatewayClient: + runs: list[dict[str, Any]] + + async def health_check(self) -> dict[str, Any]: + return {"gateway": "healthy", "data_source": "sqlite"} + + async def list_runs(self, **_: Any) -> dict[str, Any]: + return {"items": self.runs, "has_more": False, "next_cursor": None} + + async def get_diagnostics(self, thread_id: str, run_id: str) -> dict[str, Any]: + return { + "run_id": run_id, + "thread_id": thread_id, + "metrics": {"duration_ms": 1200 if run_id == "run-a" else 900, "total_tokens": 200 if run_id == "run-a" else 150}, + "failure_attribution": {"primary_cause": "NONE"}, + } + + async def get_timeline(self, thread_id: str, run_id: str, **_: Any) -> dict[str, Any]: + return {"run_id": run_id, "thread_id": thread_id, "items": [{"seq": 1, "event_type": "run.start"}], "has_more": False} + + +def test_health_check_returns_gateway_status() -> None: + tools = FlowLensInspectorTools(FakeGatewayClient([])) + + result = asyncio.run(tools.health_check()) + + assert result["schema_version"] == 1 + assert result["gateway"] == "healthy" + + +def test_list_runs_returns_only_gateway_authorized_results() -> None: + tools = FlowLensInspectorTools(FakeGatewayClient([{"run_id": "run-a", "thread_id": "thread-a"}])) + + result = asyncio.run(tools.list_runs(limit=20)) + + assert result["items"] == [{"run_id": "run-a", "thread_id": "thread-a"}] + + +def test_compare_runs_derives_deterministic_metric_delta() -> None: + tools = FlowLensInspectorTools(FakeGatewayClient([])) + + result = asyncio.run( + tools.compare_runs( + base_run_id="run-a", + base_thread_id="thread-a", + candidate_run_id="run-b", + candidate_thread_id="thread-b", + ), + ) + + assert result["duration_delta_ms"] == -300 + assert result["token_delta"] == -50 + assert result["base_primary_cause"] == "NONE" + + +@pytest.mark.parametrize("limit", [0, 101]) +def test_list_runs_rejects_unbounded_windows(limit: int) -> None: + tools = FlowLensInspectorTools(FakeGatewayClient([])) + + with pytest.raises(ValueError, match="limit"): + asyncio.run(tools.list_runs(limit=limit)) + + +def test_server_requires_a_platform_token_verifier() -> None: + verifier = PlatformTokenVerifier() + auth = AuthSettings( + issuer_url="http://gateway:8000", + resource_server_url="http://flowlens-inspector:8012", + required_scopes=["agentops.runs.read"], + ) + + from flowlens_inspector_mcp.server import create_server + + server = create_server( + FakeGatewayClient([]), + host="0.0.0.0", + port=8012, + token_verifier=verifier, + auth=auth, + ) + + assert server._token_verifier is verifier + assert server.settings.auth.required_scopes == ["agentops.runs.read"] + + +def test_read_tools_require_delegated_read_scope(monkeypatch) -> None: + monkeypatch.setattr("flowlens_inspector_mcp.server.get_access_token", lambda: None) + + with pytest.raises(PermissionError, match="agentops.runs.read"): + require_scope("agentops.runs.read") diff --git a/backend/tests/sqlite_analytics_mcp/test_query_guard.py b/backend/tests/sqlite_analytics_mcp/test_query_guard.py new file mode 100644 index 00000000..0012328c --- /dev/null +++ b/backend/tests/sqlite_analytics_mcp/test_query_guard.py @@ -0,0 +1,34 @@ +"""Read-only SQL policy tests for SQLite Analytics MCP.""" + +from __future__ import annotations + +import pytest +from sqlite_analytics_mcp.query_guard import ReadonlyQueryError, validate_readonly_sql + + +@pytest.mark.parametrize( + "statement", + [ + "SELECT category, COUNT(*) FROM incidents GROUP BY category", + "WITH recent AS (SELECT * FROM incidents) SELECT * FROM recent", + "EXPLAIN QUERY PLAN SELECT * FROM incidents", + ], +) +def test_allows_readonly_query_forms(statement: str) -> None: + assert validate_readonly_sql(statement) == statement + + +@pytest.mark.parametrize( + "statement", + [ + "INSERT INTO incidents VALUES (1, 'bad')", + "UPDATE incidents SET severity = 'high'", + "DELETE FROM incidents", + "CREATE TABLE hidden (id INTEGER)", + "ATTACH DATABASE 'other.db' AS other", + "PRAGMA writable_schema = 1", + ], +) +def test_rejects_mutating_or_escape_sql(statement: str) -> None: + with pytest.raises(ReadonlyQueryError): + validate_readonly_sql(statement) diff --git a/backend/tests/sqlite_analytics_mcp/test_repository.py b/backend/tests/sqlite_analytics_mcp/test_repository.py new file mode 100644 index 00000000..1dabb226 --- /dev/null +++ b/backend/tests/sqlite_analytics_mcp/test_repository.py @@ -0,0 +1,37 @@ +"""SQLite execution boundary tests for SQLite Analytics MCP.""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest +from sqlite_analytics_mcp.repository import SQLiteAnalyticsRepository + + +@pytest.fixture() +def repository(tmp_path: Path) -> SQLiteAnalyticsRepository: + database_path = tmp_path / "analytics.db" + repository = SQLiteAnalyticsRepository(database_path) + repository.initialize() + return repository + + +def test_executes_readonly_query_with_bounded_rows(repository: SQLiteAnalyticsRepository) -> None: + result = repository.execute_readonly( + "SELECT run_id, status FROM agent_runs ORDER BY run_id", + limit=1, + ) + + assert result.columns == ["run_id", "status"] + assert len(result.rows) == 1 + assert result.truncated is True + + +def test_sqlite_authorizer_rejects_write_even_when_called_directly( + repository: SQLiteAnalyticsRepository, +) -> None: + with pytest.raises(sqlite3.DatabaseError, match="not authorized"): + repository.execute_raw_for_defense_test( + "DELETE FROM agent_runs", + ) diff --git a/backend/tests/sqlite_analytics_mcp/test_server_tools.py b/backend/tests/sqlite_analytics_mcp/test_server_tools.py new file mode 100644 index 00000000..27921dcd --- /dev/null +++ b/backend/tests/sqlite_analytics_mcp/test_server_tools.py @@ -0,0 +1,100 @@ +"""Public tool contract tests for SQLite Analytics MCP.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +from mcp.server.auth.settings import AuthSettings +from sqlite_analytics_mcp.repository import SQLiteAnalyticsRepository +from sqlite_analytics_mcp.server import ( + PlatformTokenVerifier, + SQLiteAnalyticsTools, + create_server, + require_scope, +) + + +@pytest.fixture() +def tools(tmp_path: Path) -> SQLiteAnalyticsTools: + repository = SQLiteAnalyticsRepository(tmp_path / "analytics.db") + repository.initialize() + return SQLiteAnalyticsTools(repository) + + +def test_schema_overview_exposes_only_documented_tables( + tools: SQLiteAnalyticsTools, +) -> None: + result = tools.schema_overview() + + assert result["schema_version"] == 1 + assert [table["name"] for table in result["tables"]] == [ + "agent_runs", + "tool_calls", + ] + + +def test_query_readonly_returns_bounded_structured_rows( + tools: SQLiteAnalyticsTools, +) -> None: + result = tools.query_readonly( + "SELECT status, COUNT(*) AS run_count FROM agent_runs GROUP BY status", + limit=10, + ) + + assert result["columns"] == ["status", "run_count"] + assert result["row_count"] == 2 + assert result["truncated"] is False + + +def test_describe_table_rejects_unknown_table(tools: SQLiteAnalyticsTools) -> None: + with pytest.raises(ValueError, match="Unknown analytics table"): + tools.describe_table("sqlite_master") + + +def test_server_registers_tools_on_the_configured_http_endpoint( + tmp_path: Path, +) -> None: + repository = SQLiteAnalyticsRepository(tmp_path / "analytics.db") + repository.initialize() + + server = create_server(repository, host="127.0.0.1", port=8013) + + registered_tools = asyncio.run(server.list_tools()) + assert {tool.name for tool in registered_tools} == { + "schema_overview", + "describe_table", + "query_readonly", + } + assert all(tool.description for tool in registered_tools) + assert server.settings.host == "127.0.0.1" + assert server.settings.port == 8013 + + +def test_server_can_require_platform_token_authentication(tmp_path: Path) -> None: + repository = SQLiteAnalyticsRepository(tmp_path / "analytics.db") + repository.initialize() + verifier = PlatformTokenVerifier() + auth = AuthSettings( + issuer_url="http://gateway:8000", + resource_server_url="http://sqlite-analytics:8011", + required_scopes=[], + ) + + server = create_server( + repository, + host="127.0.0.1", + port=8013, + token_verifier=verifier, + auth=auth, + ) + + assert server._token_verifier is verifier + + +def test_sql_tools_require_a_delegated_query_scope(monkeypatch) -> None: + monkeypatch.setattr("sqlite_analytics_mcp.server.get_access_token", lambda: None) + + with pytest.raises(PermissionError, match="analytics.query.read"): + require_scope("analytics.query.read") diff --git a/backend/tests/test_agentops_run_lookup_api.py b/backend/tests/test_agentops_run_lookup_api.py new file mode 100644 index 00000000..b92e4ce0 --- /dev/null +++ b/backend/tests/test_agentops_run_lookup_api.py @@ -0,0 +1,79 @@ +"""Owner-safe AgentOps run lookup contract tests.""" + +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +from _router_auth_helpers import make_authed_test_app +from fastapi.testclient import TestClient + +from app.gateway.routers import agentops + + +def _record(): + return SimpleNamespace( + run_id="run-a", + thread_id="thread-a", + status=SimpleNamespace(value="error"), + created_at="2026-01-01T00:00:00+00:00", + updated_at="2026-01-01T00:00:02+00:00", + model_name="test-model", + total_tokens=120, + llm_call_count=2, + message_count=4, + ) + + +def _make_client(manager): + app = make_authed_test_app() + app.state.run_manager = manager + app.include_router(agentops.router) + return TestClient(app) + + +def test_run_lookup_returns_stable_mcp_summary(): + manager = SimpleNamespace(get=AsyncMock(return_value=_record())) + + with patch.object( + agentops, + "get_current_user", + AsyncMock(return_value="user-a"), + ): + with _make_client(manager) as client: + response = client.get("/api/agentops/runs/run-a") + + assert response.status_code == 200 + assert response.json() == { + "run_id": "run-a", + "thread_id": "thread-a", + "status": "error", + "created_at": "2026-01-01T00:00:00+00:00", + "updated_at": "2026-01-01T00:00:02+00:00", + "model_name": "test-model", + "total_tokens": 120, + "llm_call_count": 2, + "message_count": 4, + } + manager.get.assert_awaited_once_with( + "run-a", + user_id="user-a", + tenant_id="default", + ) + + +def test_run_lookup_hides_inaccessible_run_as_not_found(): + manager = SimpleNamespace(get=AsyncMock(return_value=None)) + + with patch.object( + agentops, + "get_current_user", + AsyncMock(return_value="user-a"), + ): + with _make_client(manager) as client: + response = client.get( + "/api/agentops/runs/run-owned-by-tenant-b", + ) + + assert response.status_code == 404 + assert response.json()["detail"] == "Run not found" diff --git a/backend/tests/test_agentops_runs_api.py b/backend/tests/test_agentops_runs_api.py index 1b0b02d2..747ffec3 100644 --- a/backend/tests/test_agentops_runs_api.py +++ b/backend/tests/test_agentops_runs_api.py @@ -16,11 +16,11 @@ def _agentops_module(): pytest.fail("AgentOps run index router is not implemented") -def _record(run_id: str, created_at: str): +def _record(run_id: str, created_at: str, *, status: str = "error"): return SimpleNamespace( run_id=run_id, thread_id=f"thread-{run_id}", - status=SimpleNamespace(value="error"), + status=SimpleNamespace(value=status), created_at=created_at, updated_at=created_at, model_name="test-model", @@ -42,19 +42,26 @@ def test_agentops_runs_returns_current_user_rows_and_cursor(): ) app = make_authed_test_app() app.state.run_manager = manager + app.state.run_event_store = SimpleNamespace(list_events=AsyncMock(return_value=[])) app.include_router(agentops.router) with patch.object(agentops, "get_current_user", AsyncMock(return_value="user-1")): with TestClient(app) as client: - response = client.get("/api/agentops/runs?limit=1&status=error") + response = client.get( + "/api/agentops/runs" + "?limit=1&status=error&thread_id=thread-r2" + ) assert response.status_code == 200 body = response.json() assert [row["run_id"] for row in body["items"]] == ["r2"] assert body["has_more"] is True assert body["next_cursor"] + assert body["items"][0]["diagnostic_category"] == "UNKNOWN_RUNTIME_ERROR" manager.list_recent.assert_awaited_once_with( user_id="user-1", + tenant_id="default", + thread_id="thread-r2", limit=2, cursor=None, status="error", @@ -76,3 +83,46 @@ def test_agentops_runs_rejects_malformed_cursor_before_store_access(): assert response.status_code == 422 manager.list_recent.assert_not_awaited() + + +def test_agentops_runs_exposes_unrecovered_diagnostic_incidents(): + agentops = _agentops_module() + manager = SimpleNamespace( + list_recent=AsyncMock( + return_value=[ + _record( + "run-incident", + "2026-01-02T00:00:00+00:00", + status="success", + ) + ] + ) + ) + event_store = SimpleNamespace( + list_events=AsyncMock( + return_value=[ + { + "seq": 6, + "event_type": "mcp.tool.error", + "category": "error", + "content": "ValueError", + "metadata": { + "server_name": "sqlite_analytics", + "tool_name": "schema_overview", + }, + "created_at": "2026-01-02T00:00:01+00:00", + } + ] + ) + ) + app = make_authed_test_app() + app.state.run_manager = manager + app.state.run_event_store = event_store + app.include_router(agentops.router) + + with patch.object(agentops, "get_current_user", AsyncMock(return_value="user-1")): + with TestClient(app) as client: + response = client.get("/api/agentops/runs") + + assert response.status_code == 200 + assert response.json()["items"][0]["diagnostic_category"] == "MCP_TOOL_ERROR" diff --git a/backend/tests/test_cancel_run_idempotent.py b/backend/tests/test_cancel_run_idempotent.py index 0bf2548d..058b6d50 100644 --- a/backend/tests/test_cancel_run_idempotent.py +++ b/backend/tests/test_cancel_run_idempotent.py @@ -9,14 +9,24 @@ from __future__ import annotations import asyncio +from uuid import uuid4 from _router_auth_helpers import make_authed_test_app from fastapi.testclient import TestClient +from app.gateway.auth.models import User from app.gateway.routers import thread_runs from deerflow.runtime import RunManager, RunStatus +from deerflow.runtime.user_context import ( + reset_current_user, + set_current_user, +) THREAD_ID = "thread-cancel-test" +ROUTER_USER = User( + id=uuid4(), + email="cancel-route@example.com", +) # --------------------------------------------------------------------------- @@ -25,7 +35,9 @@ def _make_app(mgr: RunManager) -> TestClient: - app = make_authed_test_app() + app = make_authed_test_app( + user_factory=lambda: ROUTER_USER, + ) app.include_router(thread_runs.router) app.state.run_manager = mgr return TestClient(app, raise_server_exceptions=False) @@ -40,7 +52,11 @@ async def _setup(): await mgr.cancel(record.run_id) return record.run_id - return asyncio.run(_setup()) + token = set_current_user(ROUTER_USER) + try: + return asyncio.run(_setup()) + finally: + reset_current_user(token) # --------------------------------------------------------------------------- @@ -116,7 +132,11 @@ async def _setup(): await mgr.set_status(record.run_id, RunStatus.success) return mgr, record.run_id - mgr, run_id = asyncio.run(_setup()) + token = set_current_user(ROUTER_USER) + try: + mgr, run_id = asyncio.run(_setup()) + finally: + reset_current_user(token) client = _make_app(mgr) resp = client.post(f"/api/threads/{THREAD_ID}/runs/{run_id}/cancel") assert resp.status_code == 409 diff --git a/backend/tests/test_fallback_reporting_tool.py b/backend/tests/test_fallback_reporting_tool.py new file mode 100644 index 00000000..cadabbe4 --- /dev/null +++ b/backend/tests/test_fallback_reporting_tool.py @@ -0,0 +1,41 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +from deerflow.tools.builtins.report_fallback_tool import report_fallback +from deerflow.tools.tools import BUILTIN_TOOLS + + +def test_fallback_reporting_is_exposed_as_a_builtin_tool(): + assert any(tool.name == "report_fallback" for tool in BUILTIN_TOOLS) + + +def test_fallback_reporting_records_a_structured_diagnostic_event(): + recorder = MagicMock() + recorder.record_diagnostic_event_threadsafe = MagicMock() + runtime = SimpleNamespace(config={"callbacks": [recorder]}) + + result = report_fallback.func( + runtime=runtime, + failed_component="sqlite_analytics.schema_overview", + fallback_component="analytics_snapshot", + constraint_satisfied=True, + result_verified=True, + verification="Snapshot version matched the requested dataset.", + reason="The primary MCP transport timed out.", + ) + + assert result == "Fallback declaration recorded for FlowLens diagnostics." + recorder.record_diagnostic_event.assert_called_once_with( + "runtime.fallback.declared", + component="fallback", + status="success", + content="Verified fallback declared.", + metadata={ + "failed_component": "sqlite_analytics.schema_overview", + "fallback_component": "analytics_snapshot", + "constraint_satisfied": True, + "result_verified": True, + "verification": "Snapshot version matched the requested dataset.", + "reason": "The primary MCP transport timed out.", + }, + ) diff --git a/backend/tests/test_flowlens_attribution.py b/backend/tests/test_flowlens_attribution.py index 81c97206..eda5934b 100644 --- a/backend/tests/test_flowlens_attribution.py +++ b/backend/tests/test_flowlens_attribution.py @@ -26,6 +26,72 @@ def test_successful_run_has_no_failure(): assert result["evidence"] == [] +def test_successful_run_with_mcp_tool_error_keeps_the_mcp_incident_as_root_cause(): + timeline = [ + _item( + 6, + "mcp", + "mcp.tool.error", + summary="SQLite Analytics MCP rejected the tool call: ValueError", + ) + ] + + result = attribute_failure(_record(status="success", error=None), timeline) + + assert result["primary_cause"] == "MCP_TOOL_ERROR" + assert result["evidence"][0]["seq"] == 6 + + +def test_verified_constraint_satisfying_fallback_clears_mcp_root_cause(): + timeline = [ + _item(6, "mcp", "mcp.tool.error", summary="SQLite MCP failed", metadata={"server_name": "sqlite_analytics", "tool_name": "schema_overview"}), + _item(7, "tool", "tool.end", status="success", summary="Read snapshot", metadata={"tool_name": "analytics_snapshot"}), + _item( + 8, + "runtime", + "runtime.fallback.declared", + status="success", + summary="Verified analytics fallback", + metadata={ + "failed_component": "sqlite_analytics.schema_overview", + "fallback_component": "analytics_snapshot", + "constraint_satisfied": True, + "result_verified": True, + "verification": "Snapshot version matched the requested dataset.", + }, + ), + ] + + result = attribute_failure(_record(status="success", error=None), timeline) + + assert result["primary_cause"] == "NONE" + + +def test_constraint_violating_fallback_does_not_clear_mcp_root_cause(): + timeline = [ + _item(6, "mcp", "mcp.tool.error", summary="SQLite MCP failed", metadata={"server_name": "sqlite_analytics", "tool_name": "schema_overview"}), + _item(7, "tool", "tool.end", status="success", summary="Read snapshot", metadata={"tool_name": "analytics_snapshot"}), + _item( + 8, + "runtime", + "runtime.fallback.declared", + status="success", + summary="Fallback cannot satisfy the request", + metadata={ + "failed_component": "sqlite_analytics.schema_overview", + "fallback_component": "analytics_snapshot", + "constraint_satisfied": False, + "result_verified": True, + "verification": "Snapshot version matched the requested dataset.", + }, + ), + ] + + result = attribute_failure(_record(status="success", error=None), timeline) + + assert result["primary_cause"] == "MCP_TOOL_ERROR" + + def test_rollback_takes_priority_over_tool_error(): timeline = [_item(2, "tool", "tool.error", summary="bash failed")] diff --git a/backend/tests/test_flowlens_tenant_migration.py b/backend/tests/test_flowlens_tenant_migration.py new file mode 100644 index 00000000..4896e674 --- /dev/null +++ b/backend/tests/test_flowlens_tenant_migration.py @@ -0,0 +1,75 @@ +"""Integration test for the idempotent FlowLens tenant migration.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + + +def _load_migration(): + migration_path = ( + Path(__file__).parents[1] + / "packages" + / "harness" + / "deerflow" + / "persistence" + / "migrations" + / "versions" + / "20260728_0001_flowlens_mcp_tenant.py" + ) + spec = importlib.util.spec_from_file_location( + "flowlens_tenant_migration", + migration_path, + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_tenant_migration_upgrades_legacy_schema_idempotently(tmp_path): + engine = sa.create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") + metadata = sa.MetaData() + sa.Table( + "users", + metadata, + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("email", sa.String(320), nullable=False), + ) + sa.Table( + "runs", + metadata, + sa.Column("run_id", sa.String(64), primary_key=True), + sa.Column("user_id", sa.String(64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=True), + ) + metadata.create_all(engine) + + migration = _load_migration() + with engine.begin() as connection: + operations = Operations(MigrationContext.configure(connection)) + migration.op = operations + migration.upgrade() + migration.upgrade() + + inspector = sa.inspect(connection) + user_columns = { + column["name"]: column + for column in inspector.get_columns("users") + } + run_columns = { + column["name"]: column + for column in inspector.get_columns("runs") + } + run_indexes = { + index["name"] + for index in inspector.get_indexes("runs") + } + + assert user_columns["tenant_id"]["nullable"] is False + assert run_columns["tenant_id"]["nullable"] is False + assert "ix_runs_tenant_user_created" in run_indexes diff --git a/backend/tests/test_mcp_bearer_auth.py b/backend/tests/test_mcp_bearer_auth.py new file mode 100644 index 00000000..2397aea2 --- /dev/null +++ b/backend/tests/test_mcp_bearer_auth.py @@ -0,0 +1,203 @@ +"""Bearer authentication tests for FlowLens platform tokens.""" + +from __future__ import annotations + +from dataclasses import replace +from uuid import uuid4 + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI, Request +from starlette.testclient import TestClient + +from app.gateway.auth.models import User +from app.gateway.auth.platform_tokens import ( + PlatformTokenIssuer, + PlatformTokenSettings, +) +from app.gateway.auth_middleware import AuthMiddleware + + +@pytest.fixture +def rsa_settings(): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + return PlatformTokenSettings( + private_key_pem=private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + public_key_pem=private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode(), + kid="flowlens-test-key", + issuer="deerflow-gateway", + audience="flowlens-platform", + client_id="deerflow-runtime", + client_secret="test-client-secret", + token_ttl_seconds=300, + ) + + +class _FakeProvider: + def __init__(self, user: User | None) -> None: + self._user = user + + async def get_user(self, user_id: str) -> User | None: + if self._user is None or str(self._user.id) != user_id: + return None + return self._user + + +def _make_client( + monkeypatch, + *, + settings: PlatformTokenSettings, + user: User, +) -> TestClient: + from app.gateway import deps + + monkeypatch.setattr( + PlatformTokenSettings, + "from_env", + classmethod(lambda cls, **kwargs: settings), + ) + monkeypatch.setattr( + deps, + "get_local_provider", + lambda: _FakeProvider(user), + ) + + app = FastAPI() + app.add_middleware(AuthMiddleware) + + @app.get("/api/agentops/runs") + async def list_runs(request: Request): + return { + "user_id": await deps.get_current_user(request), + "permissions": request.state.auth.permissions, + "platform_scopes": sorted(request.state.platform_scopes), + } + + return TestClient(app) + + +def _delegated_token( + settings: PlatformTokenSettings, + user: User, + *, + token_version: int | None = None, + tenant_id: str | None = None, +) -> str: + return PlatformTokenIssuer(settings).issue_delegated_token( + user_id=str(user.id), + tenant_id=tenant_id or user.tenant_id, + token_version=( + user.token_version + if token_version is None + else token_version + ), + origin_run_id="origin-run", + ) + + +def test_gateway_accepts_platform_bearer_and_maps_read_permission( + monkeypatch, + rsa_settings, +): + user = User( + id=uuid4(), + email="user-a@example.com", + tenant_id="tenant-a", + token_version=3, + ) + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=user, + ) + token = _delegated_token(rsa_settings, user) + + response = client.get( + "/api/agentops/runs", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 200 + assert response.json()["user_id"] == str(user.id) + assert "runs:read" in response.json()["permissions"] + assert "agentops.timeline.read" in response.json()["platform_scopes"] + + +def test_gateway_rejects_wrong_audience_bearer( + monkeypatch, + rsa_settings, +): + user = User( + id=uuid4(), + email="user-a@example.com", + tenant_id="tenant-a", + ) + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=user, + ) + wrong_audience_settings = replace( + rsa_settings, + audience="wrong-platform", + ) + token = _delegated_token(wrong_audience_settings, user) + + response = client.get( + "/api/agentops/runs", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 401 + + +@pytest.mark.parametrize( + ("token_version", "tenant_id"), + [ + (2, "tenant-a"), + (3, "tenant-b"), + ], +) +def test_gateway_rejects_stale_or_cross_tenant_bearer( + monkeypatch, + rsa_settings, + token_version, + tenant_id, +): + user = User( + id=uuid4(), + email="user-a@example.com", + tenant_id="tenant-a", + token_version=3, + ) + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=user, + ) + token = _delegated_token( + rsa_settings, + user, + token_version=token_version, + tenant_id=tenant_id, + ) + + response = client.get( + "/api/agentops/runs", + headers={"Authorization": f"Bearer {token}"}, + ) + + assert response.status_code == 401 diff --git a/backend/tests/test_mcp_oauth.py b/backend/tests/test_mcp_oauth.py index 27facd46..56652a58 100644 --- a/backend/tests/test_mcp_oauth.py +++ b/backend/tests/test_mcp_oauth.py @@ -6,7 +6,12 @@ from typing import Any from deerflow.config.extensions_config import ExtensionsConfig -from deerflow.mcp.oauth import OAuthTokenManager, build_oauth_tool_interceptor, get_initial_oauth_headers +from deerflow.mcp.oauth import ( + RUNTIME_DELEGATION_GRANT, + OAuthTokenManager, + build_oauth_tool_interceptor, + get_initial_oauth_headers, +) class _MockResponse: @@ -189,3 +194,55 @@ def _client_factory(*args, **kwargs): assert headers == {"secure-http": "Bearer token-initial"} assert len(post_calls) == 1 + + +def test_runtime_delegation_binds_the_token_to_one_user_and_run(monkeypatch): + post_calls: list[dict[str, Any]] = [] + + def _client_factory(*args, **kwargs): + return _MockAsyncClient( + payload={"access_token": "delegated-token", "expires_in": 300}, + post_calls=post_calls, + **kwargs, + ) + + monkeypatch.setattr("httpx.AsyncClient", _client_factory) + config = ExtensionsConfig.model_validate( + { + "mcpServers": { + "flowlens-inspector": { + "enabled": True, + "type": "http", + "url": "http://flowlens-inspector:8012/mcp", + "oauth": { + "enabled": True, + "token_url": "http://gateway:8000/api/v1/auth/mcp/token", + "grant_type": RUNTIME_DELEGATION_GRANT, + "client_id": "runtime", + "client_secret": "runtime-secret", + }, + } + } + } + ) + runtime = type( + "Runtime", + (), + {"context": {"user_id": "user-a", "run_id": "run-a"}}, + )() + + header = asyncio.run( + OAuthTokenManager.from_extensions_config(config).get_authorization_header( + "flowlens-inspector", + runtime=runtime, + ) + ) + + assert header == "Bearer delegated-token" + assert post_calls[0]["data"] == { + "grant_type": RUNTIME_DELEGATION_GRANT, + "client_id": "runtime", + "client_secret": "runtime-secret", + "subject_user_id": "user-a", + "origin_run_id": "run-a", + } diff --git a/backend/tests/test_mcp_platform_tokens.py b/backend/tests/test_mcp_platform_tokens.py new file mode 100644 index 00000000..c19392ce --- /dev/null +++ b/backend/tests/test_mcp_platform_tokens.py @@ -0,0 +1,152 @@ +"""Unit tests for FlowLens RS256 platform tokens.""" + +from __future__ import annotations + +import importlib +from dataclasses import replace + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa + + +def _token_module(): + return importlib.import_module("app.gateway.auth.platform_tokens") + + +@pytest.fixture +def rsa_settings(): + token_module = _token_module() + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + public_pem = private_key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode() + return token_module.PlatformTokenSettings( + private_key_pem=private_pem, + public_key_pem=public_pem, + kid="flowlens-test-key", + issuer="deerflow-gateway", + audience="flowlens-platform", + client_id="deerflow-runtime", + client_secret="test-client-secret", + token_ttl_seconds=300, + ) + + +def test_delegated_token_contains_required_claims(rsa_settings): + token_module = _token_module() + token = token_module.PlatformTokenIssuer( + rsa_settings, + ).issue_delegated_token( + user_id="user-a", + tenant_id="tenant-a", + token_version=3, + origin_run_id="run-origin", + ) + + claims = token_module.verify_platform_token(token, rsa_settings) + + assert claims.sub == "user-a" + assert claims.tenant_id == "tenant-a" + assert claims.aud == "flowlens-platform" + assert claims.origin_run_id == "run-origin" + assert claims.azp == "deerflow-runtime" + assert claims.ver == 3 + assert "agentops.timeline.read" in claims.scope + + +def test_service_token_is_limited_to_tool_discovery(rsa_settings): + token_module = _token_module() + token = token_module.PlatformTokenIssuer( + rsa_settings, + ).issue_service_token() + + claims = token_module.verify_platform_token(token, rsa_settings) + + assert claims.sub == "deerflow-runtime" + assert claims.scope == ["agentops.tools.discover"] + assert claims.origin_run_id is None + + +def test_verifier_rejects_wrong_audience(rsa_settings): + token_module = _token_module() + token = token_module.PlatformTokenIssuer( + rsa_settings, + ).issue_delegated_token( + user_id="user-a", + tenant_id="tenant-a", + token_version=0, + origin_run_id="run-origin", + ) + wrong_audience = replace( + rsa_settings, + audience="another-platform", + ) + + with pytest.raises(token_module.PlatformTokenError): + token_module.verify_platform_token(token, wrong_audience) + + +def test_verifier_rejects_wrong_authorized_party(rsa_settings): + token_module = _token_module() + wrong_client = replace( + rsa_settings, + client_id="untrusted-runtime", + ) + token = token_module.PlatformTokenIssuer( + wrong_client, + ).issue_delegated_token( + user_id="user-a", + tenant_id="tenant-a", + token_version=0, + origin_run_id="run-origin", + ) + + with pytest.raises(token_module.PlatformTokenError): + token_module.verify_platform_token(token, rsa_settings) + + +def test_jwks_exposes_only_the_public_rsa_key(rsa_settings): + token_module = _token_module() + + jwks = token_module.build_platform_jwks(rsa_settings) + + assert len(jwks["keys"]) == 1 + key = jwks["keys"][0] + assert key["kid"] == "flowlens-test-key" + assert key["kty"] == "RSA" + assert key["alg"] == "RS256" + assert key["use"] == "sig" + assert key["n"] + assert key["e"] + assert "private" not in str(jwks).lower() + + +def test_enabled_mcp_fails_fast_without_key_material(monkeypatch): + token_module = _token_module() + monkeypatch.setenv("FLOWLENS_MCP_ENABLED", "true") + for name in ( + "FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH", + "FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH", + "FLOWLENS_MCP_CLIENT_SECRET", + ): + monkeypatch.delenv(name, raising=False) + + with pytest.raises(token_module.PlatformTokenError): + token_module.ensure_platform_tokens_configured_if_enabled() + + +def test_disabled_mcp_does_not_require_key_material(monkeypatch): + token_module = _token_module() + monkeypatch.setenv("FLOWLENS_MCP_ENABLED", "false") + + token_module.ensure_platform_tokens_configured_if_enabled() diff --git a/backend/tests/test_mcp_sync_wrapper.py b/backend/tests/test_mcp_sync_wrapper.py index c66662bb..f92a910c 100644 --- a/backend/tests/test_mcp_sync_wrapper.py +++ b/backend/tests/test_mcp_sync_wrapper.py @@ -3,8 +3,11 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from langchain_core.messages import AIMessage from langchain_core.runnables import RunnableConfig from langchain_core.tools import StructuredTool +from langgraph.prebuilt import ToolNode, ToolRuntime +from langgraph.runtime import Runtime from pydantic import BaseModel, Field from deerflow.mcp.tools import get_mcp_tools @@ -112,6 +115,51 @@ async def mock_coro(x: int, config: RunnableConfig = None): assert captured["thread_id"] == "thread-123" +def test_sync_wrapper_preserves_tool_runtime_injection(): + """Wrapped MCP tools must still receive the run-scoped ToolRuntime.""" + + class RuntimeArgs(BaseModel): + value: str + + async def mock_coro(value: str, runtime: ToolRuntime) -> str: + del value + return str(runtime.context["run_id"]) + + tool = StructuredTool( + name="runtime_probe", + description="Verifies injected runtime propagation.", + args_schema=RuntimeArgs, + func=make_sync_tool_wrapper(mock_coro, "runtime_probe"), + coroutine=mock_coro, + ) + node = ToolNode([tool]) + message = AIMessage( + content="", + tool_calls=[ + { + "name": "runtime_probe", + "args": {"value": "probe"}, + "id": "call-runtime-probe", + "type": "tool_call", + } + ], + ) + + result = asyncio.run( + node.ainvoke( + {"messages": [message]}, + config={ + "configurable": { + "thread_id": "thread-probe", + "__pregel_runtime": Runtime(context={"run_id": "run-probe"}), + } + }, + ) + ) + + assert result["messages"][-1].content == "run-probe" + + def test_sync_wrapper_preserves_regular_config_argument(): """Only RunnableConfig-annotated coroutine params get special config injection.""" diff --git a/backend/tests/test_mcp_token_api.py b/backend/tests/test_mcp_token_api.py new file mode 100644 index 00000000..c5eac881 --- /dev/null +++ b/backend/tests/test_mcp_token_api.py @@ -0,0 +1,215 @@ +"""HTTP contract tests for FlowLens token issuance and JWKS.""" + +from __future__ import annotations + +import importlib +from uuid import uuid4 + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from fastapi import FastAPI +from starlette.testclient import TestClient + +from app.gateway.auth.models import User +from app.gateway.auth.platform_tokens import ( + PlatformTokenSettings, + verify_platform_token, +) +from app.gateway.auth_middleware import AuthMiddleware +from app.gateway.csrf_middleware import CSRFMiddleware + + +@pytest.fixture +def rsa_settings(): + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + return PlatformTokenSettings( + private_key_pem=private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode(), + public_key_pem=private_key.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode(), + kid="flowlens-test-key", + issuer="deerflow-gateway", + audience="flowlens-platform", + client_id="deerflow-runtime", + client_secret="test-client-secret", + token_ttl_seconds=300, + ) + + +class _FakeProvider: + def __init__(self, user: User) -> None: + self._user = user + + async def get_user(self, user_id: str) -> User | None: + if str(self._user.id) == user_id: + return self._user + return None + + +def _make_client( + monkeypatch, + *, + settings: PlatformTokenSettings, + user: User, +) -> TestClient: + token_router = importlib.import_module( + "app.gateway.routers.mcp_tokens", + ) + monkeypatch.setattr( + PlatformTokenSettings, + "from_env", + classmethod(lambda cls, **kwargs: settings), + ) + monkeypatch.setattr( + token_router, + "get_local_provider", + lambda: _FakeProvider(user), + ) + + app = FastAPI() + app.add_middleware(AuthMiddleware) + app.add_middleware(CSRFMiddleware) + app.include_router(token_router.router) + return TestClient(app) + + +def test_client_credentials_issues_discovery_only_token( + monkeypatch, + rsa_settings, +): + user = User(email="user-a@example.com") + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=user, + ) + + response = client.post( + "/api/v1/auth/mcp/token", + data={ + "grant_type": "client_credentials", + "client_id": "deerflow-runtime", + "client_secret": "test-client-secret", + }, + ) + + assert response.status_code == 200 + claims = verify_platform_token( + response.json()["access_token"], + rsa_settings, + ) + assert claims.scope == ["agentops.tools.discover"] + assert response.json()["token_type"] == "Bearer" + assert response.json()["expires_in"] == 300 + + +def test_runtime_delegation_derives_tenant_and_version_from_user( + monkeypatch, + rsa_settings, +): + user = User( + id=uuid4(), + email="user-a@example.com", + tenant_id="tenant-a", + token_version=7, + ) + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=user, + ) + + response = client.post( + "/api/v1/auth/mcp/token", + data={ + "grant_type": ("urn:deerflow:params:oauth:grant-type:runtime-delegation"), + "client_id": "deerflow-runtime", + "client_secret": "test-client-secret", + "subject_user_id": str(user.id), + "origin_run_id": "origin-run", + }, + ) + + assert response.status_code == 200 + claims = verify_platform_token( + response.json()["access_token"], + rsa_settings, + ) + assert claims.sub == str(user.id) + assert claims.tenant_id == "tenant-a" + assert claims.ver == 7 + assert claims.origin_run_id == "origin-run" + assert "agentops.diagnostics.read" in claims.scope + + +def test_runtime_delegation_can_issue_sqlite_read_scopes( + monkeypatch, + rsa_settings, +): + user = User(id=uuid4(), email="user-a@example.com", tenant_id="tenant-a") + client = _make_client(monkeypatch, settings=rsa_settings, user=user) + + response = client.post( + "/api/v1/auth/mcp/token", + data={ + "grant_type": "urn:deerflow:params:oauth:grant-type:runtime-delegation", + "client_id": "deerflow-runtime", + "client_secret": "test-client-secret", + "subject_user_id": str(user.id), + "origin_run_id": "origin-run", + "resource": "sqlite-analytics", + }, + ) + + assert response.status_code == 200 + claims = verify_platform_token(response.json()["access_token"], rsa_settings) + assert claims.scope == ["analytics.schema.read", "analytics.query.read"] + + +def test_token_endpoint_rejects_wrong_client_secret( + monkeypatch, + rsa_settings, +): + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=User(email="user-a@example.com"), + ) + + response = client.post( + "/api/v1/auth/mcp/token", + data={ + "grant_type": "client_credentials", + "client_id": "deerflow-runtime", + "client_secret": "wrong-secret", + }, + ) + + assert response.status_code == 401 + + +def test_jwks_is_public_and_contains_configured_key( + monkeypatch, + rsa_settings, +): + client = _make_client( + monkeypatch, + settings=rsa_settings, + user=User(email="user-a@example.com"), + ) + + response = client.get("/.well-known/jwks.json") + + assert response.status_code == 200 + assert response.json()["keys"][0]["kid"] == "flowlens-test-key" diff --git a/backend/tests/test_persistence_scaffold.py b/backend/tests/test_persistence_scaffold.py index e0bca3ee..4c836871 100644 --- a/backend/tests/test_persistence_scaffold.py +++ b/backend/tests/test_persistence_scaffold.py @@ -34,6 +34,14 @@ def test_sqlite_paths_unified(self): assert c.checkpointer_sqlite_path == c.sqlite_path assert c.app_sqlite_path == c.sqlite_path + def test_sqlite_dir_can_be_overridden_for_an_isolated_runtime(self, monkeypatch, tmp_path): + isolated_dir = tmp_path / "flowlens-state" / "data" + monkeypatch.setenv("DEER_FLOW_SQLITE_DIR", str(isolated_dir)) + + config = DatabaseConfig(backend="sqlite", sqlite_dir=".deer-flow/data") + + assert config.sqlite_path == str(isolated_dir / "deerflow.db") + def test_app_sqlalchemy_url_sqlite(self): c = DatabaseConfig(backend="sqlite", sqlite_dir="./data") url = c.app_sqlalchemy_url diff --git a/backend/tests/test_run_repository.py b/backend/tests/test_run_repository.py index 23bc4a41..fa1f7193 100644 --- a/backend/tests/test_run_repository.py +++ b/backend/tests/test_run_repository.py @@ -68,6 +68,34 @@ async def test_memory_run_store_list_recent_filters_status_and_time_window(): assert [row["run_id"] for row in rows] == ["r3"] +@pytest.mark.anyio +async def test_memory_run_store_list_recent_filters_thread_before_paging(): + store = MemoryRunStore() + await store.put( + "thread-a-old", + thread_id="thread-a", + user_id="u1", + tenant_id="tenant-a", + created_at="2026-01-01T00:00:00+00:00", + ) + await store.put( + "thread-b-new", + thread_id="thread-b", + user_id="u1", + tenant_id="tenant-a", + created_at="2026-01-03T00:00:00+00:00", + ) + + rows = await store.list_recent( + user_id="u1", + tenant_id="tenant-a", + thread_id="thread-a", + limit=1, + ) + + assert [row["run_id"] for row in rows] == ["thread-a-old"] + + async def _make_repo(tmp_path): from deerflow.persistence.engine import get_session_factory, init_engine @@ -220,6 +248,41 @@ async def test_list_recent_orders_by_created_at_and_run_id(self, tmp_path): assert [row["run_id"] for row in rows] == ["run-b", "run-a"] await _cleanup() + @pytest.mark.anyio + async def test_list_recent_filters_by_thread_and_tenant(self, tmp_path): + repo = await _make_repo(tmp_path) + await repo.put( + "run-a", + thread_id="thread-a", + user_id="alice", + tenant_id="tenant-a", + created_at="2026-01-01T00:00:00+00:00", + ) + await repo.put( + "run-b", + thread_id="thread-b", + user_id="alice", + tenant_id="tenant-a", + created_at="2026-01-02T00:00:00+00:00", + ) + await repo.put( + "run-c", + thread_id="thread-a", + user_id="alice", + tenant_id="tenant-b", + created_at="2026-01-03T00:00:00+00:00", + ) + + rows = await repo.list_recent( + user_id="alice", + tenant_id="tenant-a", + thread_id="thread-a", + limit=20, + ) + + assert [row["run_id"] for row in rows] == ["run-a"] + await _cleanup() + @pytest.mark.anyio async def test_delete(self, tmp_path): repo = await _make_repo(tmp_path) diff --git a/backend/tests/test_run_worker_rollback.py b/backend/tests/test_run_worker_rollback.py index 00844d88..a40b9f85 100644 --- a/backend/tests/test_run_worker_rollback.py +++ b/backend/tests/test_run_worker_rollback.py @@ -1,4 +1,5 @@ import asyncio +import threading from types import SimpleNamespace from unittest.mock import AsyncMock, call @@ -105,6 +106,41 @@ def factory(*, config): bridge.cleanup.assert_awaited_once_with(record.run_id, delay=60) +@pytest.mark.anyio +async def test_run_agent_builds_agent_off_the_gateway_event_loop(): + """Agent construction may perform MCP discovery back to this Gateway.""" + + run_manager = RunManager() + record = await run_manager.create("thread-1") + bridge = SimpleNamespace( + publish=AsyncMock(), + publish_end=AsyncMock(), + cleanup=AsyncMock(), + ) + main_thread_id = threading.get_ident() + captured: dict[str, int] = {} + + class DummyAgent: + async def astream(self, graph_input, config=None, stream_mode=None, subgraphs=False): + yield {"messages": []} + + def factory(*, config): + captured["factory_thread_id"] = threading.get_ident() + return DummyAgent() + + await run_agent( + bridge, + run_manager, + record, + ctx=RunContext(checkpointer=None), + agent_factory=factory, + graph_input={}, + config={}, + ) + + assert captured["factory_thread_id"] != main_thread_id + + @pytest.mark.anyio async def test_run_agent_marks_llm_error_fallback_as_error_status(): run_manager = RunManager() diff --git a/backend/tests/test_tenant_run_isolation.py b/backend/tests/test_tenant_run_isolation.py new file mode 100644 index 00000000..14964ba1 --- /dev/null +++ b/backend/tests/test_tenant_run_isolation.py @@ -0,0 +1,103 @@ +"""Tenant-aware ownership tests for users, runtime context, and runs.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import pytest + +from app.gateway.auth.models import User +from app.gateway.services import inject_authenticated_user_context +from deerflow.persistence.run import RunRepository +from deerflow.runtime import RunManager +from deerflow.runtime.runs.store.memory import MemoryRunStore +from deerflow.runtime.user_context import reset_current_user, set_current_user + + +async def _make_repo(tmp_path) -> RunRepository: + from deerflow.persistence.engine import get_session_factory, init_engine + + url = f"sqlite+aiosqlite:///{tmp_path / 'tenant-runs.db'}" + await init_engine("sqlite", url=url, sqlite_dir=str(tmp_path)) + return RunRepository(get_session_factory()) + + +async def _cleanup_repo() -> None: + from deerflow.persistence.engine import close_engine + + await close_engine() + + +@pytest.mark.anyio +async def test_run_repository_requires_matching_tenant_and_user(tmp_path): + repo = await _make_repo(tmp_path) + try: + await repo.put( + "run-a", + thread_id="thread-a", + user_id="user-a", + tenant_id="tenant-a", + ) + + assert await repo.get("run-a", user_id="user-a", tenant_id="tenant-a") is not None + assert await repo.get("run-a", user_id="user-a", tenant_id="tenant-b") is None + assert await repo.get("run-a", user_id="user-b", tenant_id="tenant-a") is None + finally: + await _cleanup_repo() + + +@pytest.mark.anyio +async def test_memory_store_applies_the_same_tenant_and_user_filter(): + store = MemoryRunStore() + await store.put( + "run-a", + thread_id="thread-a", + user_id="user-a", + tenant_id="tenant-a", + ) + + assert await store.get("run-a", user_id="user-a", tenant_id="tenant-a") is not None + assert await store.get("run-a", user_id="user-a", tenant_id="tenant-b") is None + assert await store.get("run-a", user_id="user-b", tenant_id="tenant-a") is None + + +@pytest.mark.anyio +async def test_run_manager_captures_immutable_owner_and_filters_memory(): + manager = RunManager(store=MemoryRunStore()) + token = set_current_user( + SimpleNamespace(id="user-a", tenant_id="tenant-a"), + ) + try: + record = await manager.create("thread-a") + finally: + reset_current_user(token) + + assert record.user_id == "user-a" + assert record.tenant_id == "tenant-a" + assert await manager.get(record.run_id, user_id="user-a", tenant_id="tenant-a") is record + assert await manager.get(record.run_id, user_id="user-a", tenant_id="tenant-b") is None + assert await manager.get(record.run_id, user_id="user-b", tenant_id="tenant-a") is None + + +def test_authenticated_context_includes_server_owned_tenant(): + config: dict = {} + request = SimpleNamespace( + state=SimpleNamespace( + user=SimpleNamespace( + id="user-a", + tenant_id="tenant-a", + system_role="user", + ), + ), + ) + + inject_authenticated_user_context(config, request) + + assert config["context"]["user_id"] == "user-a" + assert config["context"]["tenant_id"] == "tenant-a" + + +def test_legacy_user_defaults_to_default_tenant(): + user = User(email="legacy@example.com") + + assert user.tenant_id == "default" diff --git a/backend/tests/test_thread_run_diagnostics_api.py b/backend/tests/test_thread_run_diagnostics_api.py index 5763b577..4e57a126 100644 --- a/backend/tests/test_thread_run_diagnostics_api.py +++ b/backend/tests/test_thread_run_diagnostics_api.py @@ -3,7 +3,7 @@ from __future__ import annotations from types import SimpleNamespace -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock from _router_auth_helpers import make_authed_test_app from fastapi.testclient import TestClient @@ -70,7 +70,11 @@ def _tool_error_events() -> list[dict]: def test_run_timeline_returns_normalized_events(): event_store = _make_event_store(_tool_error_events()) - app = _make_app(event_store=event_store, run_manager=_make_run_manager()) + run_manager = _make_run_manager() + app = _make_app( + event_store=event_store, + run_manager=run_manager, + ) with TestClient(app) as client: response = client.get("/api/threads/thread-diag/runs/run-diag/timeline") @@ -82,6 +86,11 @@ def test_run_timeline_returns_normalized_events(): assert body["timeline"][0]["phase"] == "tool" assert body["timeline"][1]["status"] == "error" event_store.list_events.assert_awaited_once_with("thread-diag", "run-diag", limit=201) + run_manager.get.assert_awaited_once_with( + "run-diag", + user_id=ANY, + tenant_id="default", + ) def test_run_diagnostics_returns_metrics_and_failure_attribution(): diff --git a/backend/tests/test_thread_run_messages_pagination.py b/backend/tests/test_thread_run_messages_pagination.py index 6d36001a..7f7dcb59 100644 --- a/backend/tests/test_thread_run_messages_pagination.py +++ b/backend/tests/test_thread_run_messages_pagination.py @@ -4,11 +4,13 @@ import asyncio from unittest.mock import AsyncMock, MagicMock +from uuid import uuid4 from _router_auth_helpers import make_authed_test_app from _run_message_pagination_helpers import assert_run_message_page from fastapi.testclient import TestClient +from app.gateway.auth.models import User from app.gateway.routers import thread_runs from deerflow.runtime import RunManager from deerflow.runtime.runs.store.memory import MemoryRunStore @@ -17,10 +19,17 @@ # Helpers # --------------------------------------------------------------------------- +ROUTER_USER = User( + id=uuid4(), + email="run-messages-route@example.com", +) + def _make_app(event_store=None, run_manager=None): """Build a test FastAPI app with stub auth and mocked state.""" - app = make_authed_test_app() + app = make_authed_test_app( + user_factory=lambda: ROUTER_USER, + ) app.include_router(thread_runs.router) if event_store is not None: @@ -49,6 +58,8 @@ def _make_store_only_run_manager() -> RunManager: "store-only-run", thread_id="thread-store", assistant_id="lead_agent", + user_id=str(ROUTER_USER.id), + tenant_id=ROUTER_USER.tenant_id, status="running", multitask_strategy="reject", metadata={}, diff --git a/backend/tests/test_tool_args_schema_no_pydantic_warning.py b/backend/tests/test_tool_args_schema_no_pydantic_warning.py index 6da56347..5e1cddc1 100644 --- a/backend/tests/test_tool_args_schema_no_pydantic_warning.py +++ b/backend/tests/test_tool_args_schema_no_pydantic_warning.py @@ -28,6 +28,7 @@ write_file_tool, ) from deerflow.tools.builtins.present_file_tool import present_file_tool +from deerflow.tools.builtins.report_fallback_tool import report_fallback from deerflow.tools.builtins.setup_agent_tool import setup_agent from deerflow.tools.builtins.task_tool import task_tool from deerflow.tools.builtins.update_agent_tool import update_agent @@ -56,6 +57,7 @@ def _make_runtime(context: dict) -> ToolRuntime: (write_file_tool, {"description": "write", "path": "/tmp/x", "content": "hi"}), (str_replace_tool, {"description": "replace", "path": "/tmp/x", "old_str": "a", "new_str": "b"}), (present_file_tool, {"filepaths": ["/tmp/x"], "tool_call_id": "call-1"}), + (report_fallback, {"failed_component": "mcp.tool", "fallback_component": "cached_data", "constraint_satisfied": True, "result_verified": True, "verification": "version matched", "reason": "transport timeout"}), (view_image_tool, {"image_path": "/tmp/img.png", "tool_call_id": "call-1"}), (task_tool, {"description": "do", "prompt": "go", "subagent_type": "general-purpose", "tool_call_id": "call-1"}), (skill_manage_tool, {"action": "list", "name": "demo"}), diff --git a/backend/uv.lock b/backend/uv.lock index f4008b9a..1113f585 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -14,6 +14,8 @@ resolution-markers = [ members = [ "deer-flow", "deerflow-harness", + "flowlens-inspector-mcp", + "sqlite-analytics-mcp", ] [[package]] @@ -1109,6 +1111,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] +[[package]] +name = "flowlens-inspector-mcp" +version = "0.1.0" +source = { editable = "packages/flowlens-inspector-mcp" } +dependencies = [ + { name = "httpx" }, + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.28.0" }, + { name = "mcp", specifier = ">=1.0.0" }, +] + [[package]] name = "forbiddenfruit" version = "0.1.4" @@ -4030,6 +4047,17 @@ asyncio = [ { name = "greenlet" }, ] +[[package]] +name = "sqlite-analytics-mcp" +version = "0.1.0" +source = { editable = "packages/sqlite-analytics-mcp" } +dependencies = [ + { name = "mcp" }, +] + +[package.metadata] +requires-dist = [{ name = "mcp", specifier = ">=1.0.0" }] + [[package]] name = "sqlite-vec" version = "0.1.9" diff --git a/docker/docker-compose-agentops-isolated.yaml b/docker/docker-compose-agentops-isolated.yaml new file mode 100644 index 00000000..6f94bf35 --- /dev/null +++ b/docker/docker-compose-agentops-isolated.yaml @@ -0,0 +1,14 @@ +# Isolated runtime state for the FlowLens dual-MCP acceptance stack. +# +# This override deliberately keeps the regular development stack untouched. It +# gives only the `flowlens-agentops` Compose project a clean, ignored local +# DeerFlow home so an older SQLite schema cannot affect the demo. A bind mount +# retains sandbox file-tool compatibility during live Agent testing. +services: + gateway: + environment: + - DEER_FLOW_HOME=/app/.deer-flow + - DEER_FLOW_SQLITE_DIR=/app/.deer-flow/data + - DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.flowlens-agentops + volumes: + - ${DEER_FLOW_ROOT}/backend/.flowlens-agentops:/app/.deer-flow diff --git a/docker/docker-compose-dev.yaml b/docker/docker-compose-dev.yaml index 233d22c5..b740c3f6 100644 --- a/docker/docker-compose-dev.yaml +++ b/docker/docker-compose-dev.yaml @@ -23,7 +23,7 @@ services: dockerfile: Dockerfile args: APT_MIRROR: ${APT_MIRROR:-} - container_name: deer-flow-provisioner + container_name: ${DEER_FLOW_CONTAINER_PREFIX:-deer-flow}-provisioner volumes: - ~/.kube/config:/root/.kube/config:ro environment: @@ -59,13 +59,101 @@ services: retries: 6 start_period: 15s + # Generates ephemeral development keys in a named volume. Production must + # provide externally managed key material instead of this helper. + flowlens-mcp-bootstrap: + build: + context: ../ + dockerfile: backend/Dockerfile + target: dev + args: + APT_MIRROR: ${APT_MIRROR:-} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} + UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} + command: ["sh", "-c", "cd /app/backend && uv run --no-sync python scripts/generate_flowlens_mcp_keys.py --directory /var/run/flowlens-mcp"] + volumes: + - flowlens-mcp-keys:/var/run/flowlens-mcp + networks: + - deer-flow-dev + restart: "no" + + # Read-only local demonstration database exposed through Streamable HTTP MCP. + sqlite-analytics: + build: + context: ../ + dockerfile: backend/Dockerfile + target: dev + args: + APT_MIRROR: ${APT_MIRROR:-} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} + UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} + command: ["sh", "-c", "cd /app/backend && uv sync --all-packages && PYTHONPATH=. uv run sqlite-analytics-mcp"] + volumes: + - ../backend/:/app/backend/ + - sqlite-analytics-venv:/app/backend/.venv + - sqlite-analytics-data:/var/lib/sqlite-analytics + - flowlens-mcp-keys:/var/run/flowlens-mcp:ro + - ../logs:/app/logs + environment: + - FLOWLENS_MCP_ENABLED=true + - FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH=/var/run/flowlens-mcp/private.pem + - FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH=/var/run/flowlens-mcp/public.pem + - FLOWLENS_MCP_CLIENT_ID=${FLOWLENS_MCP_CLIENT_ID:-deerflow-runtime} + - FLOWLENS_MCP_CLIENT_SECRET=${FLOWLENS_MCP_CLIENT_SECRET:-flowlens-local-dev-only} + depends_on: + flowlens-mcp-bootstrap: + condition: service_completed_successfully + networks: + - deer-flow-dev + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c 'import socket; socket.create_connection((\"127.0.0.1\", 8011), 2).close()'"] + interval: 5s + timeout: 3s + retries: 12 + + # Gateway-backed read-only AgentOps investigation MCP. + flowlens-inspector: + build: + context: ../ + dockerfile: backend/Dockerfile + target: dev + args: + APT_MIRROR: ${APT_MIRROR:-} + UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} + UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} + command: ["sh", "-c", "cd /app/backend && uv sync --all-packages && PYTHONPATH=. uv run flowlens-inspector-mcp"] + volumes: + - ../backend/:/app/backend/ + - flowlens-inspector-venv:/app/backend/.venv + - flowlens-mcp-keys:/var/run/flowlens-mcp:ro + - ../logs:/app/logs + environment: + - FLOWLENS_MCP_ENABLED=true + - FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH=/var/run/flowlens-mcp/private.pem + - FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH=/var/run/flowlens-mcp/public.pem + - FLOWLENS_MCP_CLIENT_ID=${FLOWLENS_MCP_CLIENT_ID:-deerflow-runtime} + - FLOWLENS_MCP_CLIENT_SECRET=${FLOWLENS_MCP_CLIENT_SECRET:-flowlens-local-dev-only} + - FLOWLENS_INSPECTOR_GATEWAY_URL=http://gateway:8001 + depends_on: + flowlens-mcp-bootstrap: + condition: service_completed_successfully + networks: + - deer-flow-dev + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "python -c 'import socket; socket.create_connection((\"127.0.0.1\", 8012), 2).close()'"] + interval: 5s + timeout: 3s + retries: 12 + # ── Reverse Proxy ────────────────────────────────────────────────────── # Routes API traffic to gateway and (optionally) provisioner. nginx: image: nginx:alpine - container_name: deer-flow-nginx + container_name: ${DEER_FLOW_CONTAINER_PREFIX:-deer-flow}-nginx ports: - - "2026:2026" + - "${DEER_FLOW_HTTP_PORT:-2026}:2026" volumes: - ./nginx/nginx.conf:/etc/nginx/nginx.conf.template:ro command: @@ -92,7 +180,7 @@ services: args: PNPM_STORE_PATH: ${PNPM_STORE_PATH:-/root/.local/share/pnpm/store} NPM_REGISTRY: ${NPM_REGISTRY:-} - container_name: deer-flow-frontend + container_name: ${DEER_FLOW_CONTAINER_PREFIX:-deer-flow}-frontend command: sh -c "cd frontend && pnpm run dev > /app/logs/frontend.log 2>&1" volumes: - ../frontend/src:/app/frontend/src @@ -124,7 +212,7 @@ services: APT_MIRROR: ${APT_MIRROR:-} UV_IMAGE: ${UV_IMAGE:-ghcr.io/astral-sh/uv:0.7.20} UV_INDEX_URL: ${UV_INDEX_URL:-https://pypi.org/simple} - container_name: deer-flow-gateway + container_name: ${DEER_FLOW_CONTAINER_PREFIX:-deer-flow}-gateway # Startup logic lives in docker/dev-entrypoint.sh — UV_EXTRAS validation, # `uv sync --all-packages`, .venv self-heal, and uvicorn handoff. Keeps # this file readable and lets the script be linted (shellcheck-clean). @@ -142,6 +230,7 @@ services: - ../extensions_config.json:/app/extensions_config.json - ../skills:/app/skills - ../logs:/app/logs + - flowlens-mcp-keys:/var/run/flowlens-mcp:ro # Use a Docker-managed uv cache volume instead of a host bind mount. # On macOS/Docker Desktop, uv may fail to create symlinks inside shared # host directories, which causes startup-time `uv sync` to crash. @@ -172,11 +261,23 @@ services: - DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow - DEER_FLOW_HOST_SKILLS_PATH=${DEER_FLOW_ROOT}/skills - DEER_FLOW_SANDBOX_HOST=host.docker.internal + - FLOWLENS_MCP_ENABLED=true + - FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH=/var/run/flowlens-mcp/private.pem + - FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH=/var/run/flowlens-mcp/public.pem + - FLOWLENS_MCP_CLIENT_ID=${FLOWLENS_MCP_CLIENT_ID:-deerflow-runtime} + - FLOWLENS_MCP_CLIENT_SECRET=${FLOWLENS_MCP_CLIENT_SECRET:-flowlens-local-dev-only} env_file: - ../.env extra_hosts: # For Linux: map host.docker.internal to host gateway - "host.docker.internal:host-gateway" + depends_on: + flowlens-mcp-bootstrap: + condition: service_completed_successfully + sqlite-analytics: + condition: service_healthy + flowlens-inspector: + condition: service_healthy networks: - deer-flow-dev restart: unless-stopped @@ -186,10 +287,11 @@ volumes: # image build are not shadowed by the host backend/ directory mount. gateway-venv: gateway-uv-cache: + flowlens-mcp-keys: + sqlite-analytics-venv: + flowlens-inspector-venv: + sqlite-analytics-data: networks: deer-flow-dev: driver: bridge - ipam: - config: - - subnet: 192.168.200.0/24 diff --git a/docs/architecture.md b/docs/architecture.md index 0fb776c7..90eaee21 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,6 +17,10 @@ flowchart TB Memory["Memory inject and update"] Checkpoint["Checkpoint and rollback"] end + Runtime --> MCPClient["MCP client + delegated token interceptor"] + MCPClient --> AnalyticsMCP["SQLite Analytics MCP\nread-only local demo data"] + MCPClient --> InspectorMCP["FlowLens Inspector MCP\nread-only investigation tools"] + InspectorMCP --> Gateway Runtime --> Journal["RunJournal diagnostic adapters"] Journal --> Store["RunEventStore"] Store --> Redact["Recursive redaction"] @@ -75,6 +79,17 @@ Both sources implement `DiagnosticsSource` and validate responses with the same Zod schemas. Replay never polls or calls a model; Connected running runs poll at two-second intervals and terminal runs stop polling. +## Dual MCP Boundary + +MCP tool schemas may be discovered with a service token that has only +`agentops.tools.discover`. A real HTTP MCP tool invocation is intercepted by +the Runtime, which exchanges the current `user_id` and `run_id` for a short +delegated token. The server validates the token and Scope; FlowLens Inspector +then forwards the same token to Gateway, where tenant, user, and run ownership +are verified again. SQLite Analytics uses separate `analytics.*` scopes and a +SQLite authorizer in addition to SQL allowlisting. Neither service exposes a +public host port in the Docker development stack. + ## Failure Isolation - EventStore failure does not change Run state. diff --git a/docs/assets/flowlens-agentops-dashboard.png b/docs/assets/flowlens-agentops-dashboard.png index 73babbbf..00a448eb 100644 Binary files a/docs/assets/flowlens-agentops-dashboard.png and b/docs/assets/flowlens-agentops-dashboard.png differ diff --git a/docs/assets/flowlens-agentops-mobile.png b/docs/assets/flowlens-agentops-mobile.png index 29fbdc75..eed4e8c5 100644 Binary files a/docs/assets/flowlens-agentops-mobile.png and b/docs/assets/flowlens-agentops-mobile.png differ diff --git a/docs/flowlens-dual-mcp-test-runbook.zh-CN.md b/docs/flowlens-dual-mcp-test-runbook.zh-CN.md new file mode 100644 index 00000000..9bbc092c --- /dev/null +++ b/docs/flowlens-dual-mcp-test-runbook.zh-CN.md @@ -0,0 +1,149 @@ +# FlowLens 双 MCP 完整验收手册 + +本手册验证隔离 Docker 环境的真实 Runtime 调用,而不是 Replay 样例。所有操作都在 +`flowlens-agentops` 项目和 `2027` 端口中进行,不影响你日常使用的 `2026` DeerFlow。 + +## 0. 启动与登录 + +在仓库根目录的 PowerShell 执行: + +```powershell +$env:DEER_FLOW_ROOT = (Get-Location).Path +$env:HOME = $env:USERPROFILE +$env:DEER_FLOW_CONTAINER_PREFIX = "flowlens-agentops" +$env:DEER_FLOW_HTTP_PORT = "2027" + +docker compose -p flowlens-agentops ` + -f docker/docker-compose-dev.yaml ` + -f docker/docker-compose-agentops-isolated.yaml ` + up -d --build --remove-orphans frontend gateway nginx sqlite-analytics flowlens-inspector + +Invoke-RestMethod http://localhost:2027/health + +powershell -NoProfile -ExecutionPolicy Bypass ` + -File .\scripts\verify_flowlens-dual-mcp.ps1 +``` + +预期:返回 `status: healthy`。打开 `http://localhost:2027/` 后,首次运行需创建 +本地管理员账号。该账号、线程和运行记录存放在 Git 忽略的 +`backend/.flowlens-agentops/`,不会写入原有 `backend/.deer-flow/`。 + +在第二个标签打开 `http://localhost:2027/agentops`。每次发送任务都会产生新的 +`run_id`;同一聊天页面则共享一个 `thread_id`。 + +## 1. Runtime、Tool 与 Timeline 基线 + +新对话发送: + +```text +在工作区创建 flowlens_baseline.txt,写入三行: +Runtime captures execution +Timeline restores the path +Diagnostics identifies the cause +然后重新读取该文件并统计总行数,最后只汇报文件路径和行数。 +``` + +预期:聊天侧完成文件创建、读取和统计;AgentOps 出现成功 Run,Timeline 至少有 +`run.start`、模型响应、`tool.start`、`tool.end`、`run.end`。点开 `tool.end` 可查看 +工具名、span、parent span、状态和时长。这是 RunJournal 采集后归一化出的真实 +Timeline,而非模拟数据。 + +讲解:Runtime 管 Run 生命周期,模型决定下一步,Tool 执行动作。Timeline 是事件 +录像,Checkpoint 是状态存档,因此二者不能互相替代。 + +## 2. SQLite Analytics MCP 成功路径 + +新对话发送: + +```text +使用 SQLite Analytics MCP 完成本地演示数据分析:先查看 schema, +再描述 agent_runs 表,最后按 status 统计运行数量。只执行只读 SQL, +写出使用的 SQL,并明确标记结果为“本地演示数据”。 +``` + +预期:Agent 通常依次调用 `schema_overview`、`describe_table`、 +`query_readonly`。AgentOps 的 MCP 筛选项可看到 MCP 生命周期,顶部 MCP 指标大于 +0,Root Cause 是 `NONE`,Run 状态为 Success。 + +原理:发现工具 schema 时 Runtime 使用仅能 `tools/list` 的服务令牌;真正执行工具 +时才根据当前 `user_id + run_id` 换取短时委托令牌。模型只看见 schema 和结果,拿 +不到令牌。 + +## 3. SQLite 只读策略失败路径 + +新对话发送: + +```text +使用 SQLite Analytics MCP 执行 DELETE FROM agent_runs,并告诉我删除了多少数据。 +``` + +预期:服务器在执行 SQL 前拒绝;演示库没有删除。AgentOps 出现 MCP/Tool 错误证据, +详情包含拒绝类别和安全摘要,不应包含令牌、完整提示词或数据库连接串。Diagnostics +应把策略拒绝作为主要归因,而不是把模型最终错误文本当根因。 + +## 4. FlowLens Inspector MCP 二次诊断 + +从步骤 2 或 3 的 Run Explorer 复制一个 `run_id` 及对应 `thread_id`,新对话发送: + +```text +使用 FlowLens Inspector MCP 列出我最近的运行记录,然后检查 +thread_id 中 run_id 的诊断结果和一段 Timeline。 +只根据工具返回的证据总结状态、Token、工具/MCP 调用和确定性根因; +没有证据时请明确说明没有证据。 +``` + +预期:检查任务本身会成为新的 Run,其 Timeline 有 Inspector MCP 调用。Inspector +把当前委托令牌转发给 Gateway,Gateway 再独立核验当前用户、租户和目标 Run 所有权, +所以不是仅信任 MCP 参数的薄封装。 + +## 5. Memory 与 Subagent 观察 + +Memory 先后发送: + +```text +请记住:我的 FlowLens 演示主题是“运行时可观测与故障归因”。只回复已记住。 +``` + +```text +根据我刚才的 FlowLens 演示主题,给我一句 20 字以内的介绍。 +``` + +预期:第二轮可引用主题;是否出现具体 Memory 事件取决于中间件策略,因此应以实际 +Timeline 为准,不要编造。 + +Subagent 测试: + +```text +把“Runtime、Timeline、Metrics、Diagnostics”的职责拆成四个并行小任务, +交给子 Agent 分别分析,最后由 Lead Agent 汇总为四点说明。不要修改任何文件。 +``` + +预期:Timeline 出现 Subagent 阶段、父子 span 关联与 Lead Agent 汇总。Lead Agent +负责规划、分派和汇总,Subagent 只在自己的局部上下文与执行边界内完成工作。 + +## 6. 聊天与 AgentOps 的对应关系 + +1. `thread_id` 是会话级标识,一次会话可生成多个 `run_id`。 +2. AgentOps 左侧每一行对应一个 `run_id`,选中后顶部显示完整 Run ID。 +3. 聊天记录与运行诊断是两种数据;删除聊天后诊断是否保留取决于运行存储保留策略。 +4. 演示时应在任务结束后立即在 AgentOps 选择更新时间最新的 Run,再复制 Run ID。 + +## 7. 停止与完全重置 + +```powershell +docker compose -p flowlens-agentops ` + -f docker/docker-compose-dev.yaml ` + -f docker/docker-compose-agentops-isolated.yaml down +``` + +完全重置时:先停止栈,删除 `backend/.flowlens-agentops/`,并按需在 `down` 后加 +`-v` 清除 MCP 密钥与演示数据库卷。不要删除原有的 `backend/.deer-flow/`。 + +## 演示收尾检查表 + +- [ ] `/health` healthy,两个 MCP 容器 healthy。 +- [ ] 成功路径:Tool 与 SQLite MCP 事件可在 Timeline 看到。 +- [ ] 拒绝路径:`DELETE` 未改变演示数据,Diagnostics 有确定性证据。 +- [ ] Inspector MCP 读取的是受限的真实 AgentOps 数据。 +- [ ] Memory 与 Subagent 按实际 Timeline 讲解。 +- [ ] Export 下载的是脱敏 JSON 快照。 diff --git a/docs/mcp-validation-guide.md b/docs/mcp-validation-guide.md new file mode 100644 index 00000000..7cb2a574 --- /dev/null +++ b/docs/mcp-validation-guide.md @@ -0,0 +1,112 @@ +# Dual MCP Validation Guide + +This guide validates real local FlowLens MCP calls. It does not use Replay +fixtures as a substitute for Connected runtime evidence. + +## What Is Running + +The development stack contains two internal-only Streamable HTTP MCP services: + +| Service | MCP tools | Data boundary | +| --- | --- | --- | +| SQLite Analytics | `schema_overview`, `describe_table`, `query_readonly` | A seeded local demonstration SQLite database; SQL is allowlisted and read-only. | +| FlowLens Inspector | `health_check`, `list_runs`, `get_run_diagnostics`, `get_timeline_window`, `compare_runs` | Gateway AgentOps APIs, with Gateway retaining user, tenant, and run-owner checks. | + +The services have no host-port mappings. The Runtime discovers their schemas with +a service token, then exchanges the current `user_id` and `run_id` for a short +delegated token when executing an MCP tool. The model never receives a token. + +## Isolated Docker Start + +From the repository root in PowerShell: + +```powershell +$env:DEER_FLOW_ROOT = (Get-Location).Path +$env:HOME = $env:USERPROFILE +$env:DEER_FLOW_CONTAINER_PREFIX = "flowlens-agentops" +$env:DEER_FLOW_HTTP_PORT = "2027" + +docker compose -p flowlens-agentops ` + -f docker/docker-compose-dev.yaml ` + -f docker/docker-compose-agentops-isolated.yaml ` + up -d --build --remove-orphans frontend gateway nginx sqlite-analytics flowlens-inspector +``` + +The bootstrap container generates a local-only RSA keypair in a Docker named +volume. The isolated DeerFlow runtime state is stored under the ignored +`backend/.flowlens-agentops/` directory, so an older local schema cannot affect +this stack. `2027` avoids interrupting an existing DeerFlow environment on +`2026`. + +Check the stack: + +```powershell +docker compose -p flowlens-agentops ` + -f docker/docker-compose-dev.yaml ` + -f docker/docker-compose-agentops-isolated.yaml ps +Invoke-RestMethod http://localhost:2027/health +``` + +Open `http://localhost:2027/`, sign in or create a local account, then open +`http://localhost:2027/agentops` in another tab. + +## Demo 1: Real SQLite MCP Call + +In DeerFlow, send this prompt: + +```text +Use the SQLite Analytics MCP to inspect the available schema, then count runs by status. +State the SQL you used and label the result as local demonstration data. +``` + +Expected behavior: + +1. The Lead Agent selects `sqlite_analytics_schema_overview`. +2. It may call `sqlite_analytics_describe_table` for `agent_runs`. +3. It calls `sqlite_analytics_query_readonly` with a single `SELECT`. +4. The MCP result returns as a `ToolMessage`; the Lead Agent summarizes it. +5. AgentOps shows the new `run_id` with `MCP` Timeline items and `mcp.tool.start` / `mcp.tool.end` events. + +For a negative case, ask for `DELETE FROM agent_runs`. The server rejects it +before database execution. This verifies both the SQL policy and the failure +evidence path; it is not a production security benchmark. + +## Demo 2: Inspect a Prior Run Through MCP + +After Demo 1 finishes, use its `run_id` and `thread_id` from AgentOps. Start a +new DeerFlow chat and send: + +```text +Use FlowLens Inspector MCP to list my recent runs. Inspect the diagnostics and a Timeline window for run_id in thread_id . Summarize the status, token count, tool/MCP evidence, and deterministic root cause. Do not invent evidence not returned by the tools. +``` + +Expected behavior: + +1. The Runtime discovers the Inspector schema with a discovery-only token. +2. For the selected MCP tool call, it obtains a short delegated token bound to the current user and new run. +3. Inspector validates that token and forwards it to Gateway. +4. Gateway independently checks the authenticated user, tenant, and target run ownership. +5. The returned diagnostics become the new Agent run's ToolMessage, so the second run contains its own MCP evidence. + +## AgentOps Walkthrough + +For each completed run: + +1. Select the run in **Run Explorer**. +2. Confirm the summary bar: duration, tokens, Tool/Error count, MCP count, and terminal status. +3. Select the **MCP** Timeline filter. Open an item to inspect safe metadata such as server name, tool name, transport, span, parent span, and duration. +4. Use **Root Cause**. It is deterministic: the optional explanation can describe the result but cannot change it. +5. Use **Export** to download the redacted JSON snapshot. + +## Stop the Isolated Stack + +```powershell +docker compose -p flowlens-agentops ` + -f docker/docker-compose-dev.yaml ` + -f docker/docker-compose-agentops-isolated.yaml down +``` + +The MCP key volume and the ignored `backend/.flowlens-agentops/` runtime state +retain local demonstration data between restarts. Add `-v` to remove the Docker +volumes; remove `backend/.flowlens-agentops/` separately when a fully fresh +local identity is required. diff --git a/docs/superpowers/plans/2026-07-28-flowlens-inspector-mcp.md b/docs/superpowers/plans/2026-07-28-flowlens-inspector-mcp.md new file mode 100644 index 00000000..49c5b110 --- /dev/null +++ b/docs/superpowers/plans/2026-07-28-flowlens-inspector-mcp.md @@ -0,0 +1,1314 @@ +# FlowLens Inspector MCP Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an authenticated, tenant-aware Streamable HTTP MCP service that lets DeerFlow agents inspect FlowLens runs through five read-only tools and a real incident-response Skill. + +**Architecture:** Keep Gateway as the owner of AgentOps query authorization and expose FlowLens Inspector as a separate FastMCP resource server. DeerFlow discovers tools with a service credential, exchanges runtime identity for a short-lived delegated JWT at tool-call time, and forwards that JWT through MCP to Gateway; client-side RunJournal events and server-side audit records make the entire call observable. + +**Tech Stack:** Python 3.12, FastMCP/MCP Python SDK 1.27+, Streamable HTTP, FastAPI, PyJWT with RSA/JWKS, Pydantic 2, HTTPX, Tenacity, SQLAlchemy/Alembic, Redis, Prometheus client, Pytest, Ruff, Docker Compose + +## Global Constraints + +- Public product naming remains `FlowLens AgentOps`; the MCP service is `FlowLens Inspector MCP`. +- The five MCP tools are read-only and must never mutate Run, Timeline, Checkpoint, Memory, Prompt, or historical events. +- Tool results never include complete prompts, memory text, attachment bodies, credentials, cookies, authorization headers, or database URLs. +- Core diagnostics and run comparison are deterministic; no MCP tool invokes an LLM. +- Streamable HTTP is the primary transport; `stdio` is not the acceptance path. +- Access tokens use `RS256`, `kid`, JWKS, `iss`, `aud`, `sub`, `tenant_id`, `scope`, `iat`, `exp`, `jti`, and `ver`. +- Delegated access tokens default to 300 seconds and are refreshed before expiry. +- MCP and Gateway both verify the token; Gateway always performs final Run owner and tenant checks. +- Timeline pages default to 20 and cap at 100 MCP items even though the model plan is not cost-constrained. +- HTTP connection timeout is 2 seconds and total MCP tool timeout is capped at 12 seconds. +- Only idempotent dependency failures are retried, at most twice, with backoff and jitter. +- Local mode uses SQLite and an in-memory limiter; enterprise verification uses PostgreSQL and Redis. +- Every behavior change follows RED, GREEN, focused regression, Ruff, then an independently reviewable commit. +- Performance and test-count claims are published only from fresh command output. +- Execution uses Inline Execution with a user-facing checkpoint after each of the four stages; do not dispatch one subagent per task. + +--- + +## File Structure + +### New MCP package + +- Create: `backend/packages/flowlens-inspector-mcp/pyproject.toml` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/__init__.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/__main__.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/settings.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/schemas.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/server.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/gateway_client.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/comparison.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/resilience.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/audit.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/telemetry.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth/__init__.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth/principal.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth/verifier.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/auth/scopes.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/tools/__init__.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/tools/health.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/tools/runs.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/tools/diagnostics.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/tools/timeline.py` +- Create: `backend/packages/flowlens-inspector-mcp/flowlens_inspector_mcp/tools/comparison.py` + +### Gateway, persistence, and runtime integration + +- Modify: `backend/pyproject.toml` +- Modify: `backend/app/gateway/app.py` +- Modify: `backend/app/gateway/auth/models.py` +- Modify: `backend/app/gateway/auth_middleware.py` +- Modify: `backend/app/gateway/authz.py` +- Create: `backend/app/gateway/auth/platform_tokens.py` +- Create: `backend/app/gateway/routers/mcp_tokens.py` +- Modify: `backend/app/gateway/routers/agentops.py` +- Modify: `backend/app/gateway/routers/thread_runs.py` +- Modify: `backend/app/gateway/services.py` +- Modify: `backend/packages/harness/deerflow/config/extensions_config.py` +- Modify: `backend/packages/harness/deerflow/config/app_config.py` +- Create: `backend/packages/harness/deerflow/mcp/delegation.py` +- Modify: `backend/packages/harness/deerflow/mcp/tools.py` +- Modify: `backend/packages/harness/deerflow/mcp/oauth.py` +- Modify: `backend/packages/harness/deerflow/runtime/user_context.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/manager.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/store/base.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/store/memory.py` +- Modify: `backend/packages/harness/deerflow/persistence/run/model.py` +- Modify: `backend/packages/harness/deerflow/persistence/run/sql.py` +- Modify: `backend/packages/harness/deerflow/persistence/user/model.py` +- Modify: `backend/app/gateway/auth/repositories/sqlite.py` +- Create: `backend/packages/harness/deerflow/persistence/mcp_audit/__init__.py` +- Create: `backend/packages/harness/deerflow/persistence/mcp_audit/model.py` +- Create: `backend/packages/harness/deerflow/persistence/mcp_audit/sql.py` +- Modify: `backend/packages/harness/deerflow/persistence/models/__init__.py` +- Create: `backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0001_flowlens_mcp_tenant.py` +- Create: `backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0002_flowlens_mcp_audit.py` + +### Skill, deployment, tests, and documentation + +- Create: `skills/public/flowlens-incident-response/SKILL.md` +- Create: `skills/public/flowlens-incident-response/references/diagnostic-playbook.md` +- Create: `skills/public/flowlens-incident-response/references/output-format.md` +- Create: `skills/public/flowlens-incident-response/evals/trigger_eval_set.json` +- Create: `skills/public/flowlens-incident-response/evals/evals.json` +- Create: `backend/Dockerfile.mcp` +- Modify: `docker/docker-compose-dev.yaml` +- Modify: `docker/docker-compose.yaml` +- Create: `docker/docker-compose-flowlens-mcp-enterprise.yaml` +- Modify: `docker/nginx/nginx.conf` +- Modify: `.env.example` +- Modify: `extensions_config.example.json` +- Modify: `extensions_config.json` +- Create: `backend/scripts/generate_mcp_keys.py` +- Create: `backend/scripts/migrate_flowlens_mcp.py` +- Create: `backend/scripts/seed_agentops_runs.py` +- Create: `backend/scripts/benchmark_inspector_mcp.py` +- Create: `scripts/verify_flowlens_mcp.ps1` +- Modify: `README.md` +- Modify: `docs/architecture.md` +- Create: `docs/flowlens-inspector-mcp.md` +- Create: `backend/tests/flowlens_inspector_mcp/` test package and focused test modules named in the tasks below. + +Command convention: run `uv run ...` and Ruff commands from `backend/`; run `git ...`, Docker Compose, and PowerShell script commands from the repository root. + +--- + +## Stage 1: Identity, Persistence, And Gateway Contracts + +### Task 1: Persist Tenant-Aware User And Run Identity + +**Files:** +- Modify: `backend/app/gateway/auth/models.py:User` +- Modify: `backend/packages/harness/deerflow/persistence/user/model.py:UserRow` +- Modify: `backend/app/gateway/auth/repositories/sqlite.py` +- Modify: `backend/packages/harness/deerflow/runtime/user_context.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/manager.py:RunRecord` +- Modify: `backend/packages/harness/deerflow/runtime/runs/store/base.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/store/memory.py` +- Modify: `backend/packages/harness/deerflow/persistence/run/model.py:RunRow` +- Modify: `backend/packages/harness/deerflow/persistence/run/sql.py:RunRepository` +- Modify: `backend/app/gateway/services.py:inject_authenticated_user_context` +- Create: `backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0001_flowlens_mcp_tenant.py` +- Create: `backend/tests/test_tenant_run_isolation.py` +- Modify: `backend/tests/test_run_repository.py` +- Modify: `backend/tests/test_auth.py` + +**Interfaces:** +- Produces: `DEFAULT_TENANT_ID = "default"`. +- Produces: `get_effective_tenant_id() -> str`. +- Produces: `resolve_runtime_tenant_id(runtime: object | None) -> str`. +- Extends: `User.tenant_id: str`, `RunRecord.user_id: str`, and `RunRecord.tenant_id: str`. +- Extends: RunStore read methods with keyword-only `tenant_id`. +- Invariant: authorized Run lookup always matches `(tenant_id, user_id, run_id)`. + +- [ ] **Step 1: Write failing tenant isolation tests** + +```python +@pytest.mark.anyio +async def test_run_repository_requires_matching_tenant_and_user(session_factory): + repo = RunRepository(session_factory) + await repo.put("run-a", thread_id="thread-a", user_id="user-a", tenant_id="tenant-a") + + assert await repo.get("run-a", user_id="user-a", tenant_id="tenant-a") is not None + assert await repo.get("run-a", user_id="user-a", tenant_id="tenant-b") is None + assert await repo.get("run-a", user_id="user-b", tenant_id="tenant-a") is None + + +def test_authenticated_context_includes_server_owned_tenant(): + config: dict = {} + request = SimpleNamespace( + state=SimpleNamespace(user=SimpleNamespace(id="user-a", tenant_id="tenant-a", system_role="user")) + ) + inject_authenticated_user_context(config, request) + assert config["context"]["user_id"] == "user-a" + assert config["context"]["tenant_id"] == "tenant-a" +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +cd backend +$env:PYTHONPATH='.' +uv run pytest tests/test_tenant_run_isolation.py tests/test_run_repository.py tests/test_auth.py -q +``` + +Expected: fail because `tenant_id` fields and repository parameters do not exist. + +- [ ] **Step 3: Add tenant identity helpers and persisted fields** + +Implement the shared defaults: + +```python +DEFAULT_TENANT_ID: Final[str] = "default" + + +def get_effective_tenant_id() -> str: + user = get_current_user() + value = getattr(user, "tenant_id", None) if user is not None else None + return str(value or DEFAULT_TENANT_ID) + + +def resolve_runtime_tenant_id(runtime: object | None) -> str: + context = getattr(runtime, "context", None) + if isinstance(context, dict) and context.get("tenant_id"): + return str(context["tenant_id"]) + return get_effective_tenant_id() +``` + +Add `tenant_id` to `User`, `UserRow`, `RunRow`, and `RunRecord`. New users and legacy rows default to `"default"`. + +Capture immutable ownership when RunManager creates a record and include it in every persistence payload: + +```python +record = RunRecord( + ..., + user_id=get_effective_user_id(), + tenant_id=get_effective_tenant_id(), +) +``` + +- [ ] **Step 4: Enforce tenant and owner filters in stores** + +Update `RunRepository.get`, `list_by_thread`, and `list_recent` so a non-admin read includes both: + +```python +stmt = stmt.where( + RunRow.tenant_id == resolved_tenant_id, + RunRow.user_id == resolved_user_id, +) +``` + +Update `MemoryRunStore` and in-memory `RunManager.get` with the same semantics. Explicit `user_id=None, tenant_id=None` remains the migration/admin bypass; no ordinary request may use it. + +- [ ] **Step 5: Add the idempotent schema migration** + +The Alembic upgrade must: + +1. Add `users.tenant_id` and `runs.tenant_id` with server default `"default"` when absent. +2. Backfill null values. +3. Make both columns non-null. +4. Add `ix_runs_tenant_user_created` over `(tenant_id, user_id, created_at)`. +5. Skip an operation when inspection shows the column/index already exists, because local `create_all()` may have created the current schema. + +- [ ] **Step 6: Verify GREEN and isolation regressions** + +Run: + +```powershell +uv run pytest tests/test_tenant_run_isolation.py tests/test_run_repository.py tests/test_auth.py tests/test_agentops_runs_api.py tests/test_migration_user_isolation.py -q +uv run ruff check app/gateway/auth app/gateway/services.py packages/harness/deerflow/runtime/user_context.py packages/harness/deerflow/runtime/runs packages/harness/deerflow/persistence +``` + +Expected: all selected tests pass and Ruff reports no errors. + +- [ ] **Step 7: Commit** + +```powershell +git add backend/app/gateway/auth backend/app/gateway/services.py backend/packages/harness/deerflow/runtime backend/packages/harness/deerflow/persistence backend/tests/test_tenant_run_isolation.py backend/tests/test_run_repository.py backend/tests/test_auth.py +git commit -m "feat(agentops): add tenant-aware run identity" +``` + +### Task 2: Issue And Verify Scoped MCP Delegation Tokens + +**Files:** +- Create: `backend/app/gateway/auth/platform_tokens.py` +- Create: `backend/app/gateway/routers/mcp_tokens.py` +- Modify: `backend/app/gateway/auth_middleware.py` +- Modify: `backend/app/gateway/authz.py` +- Modify: `backend/app/gateway/app.py` +- Create: `backend/tests/test_mcp_platform_tokens.py` +- Create: `backend/tests/test_mcp_bearer_auth.py` + +**Interfaces:** +- Produces: `PlatformTokenSettings.from_env()`. +- Produces: `PlatformTokenClaims`. +- Produces: `PlatformTokenIssuer.issue_service_token(...)`. +- Produces: `PlatformTokenIssuer.issue_delegated_token(...)`. +- Produces: `verify_platform_token(token: str) -> PlatformTokenClaims`. +- Produces: `GET /.well-known/jwks.json`. +- Produces: `POST /api/v1/auth/mcp/token`. + +- [ ] **Step 1: Write failing token and Bearer authentication tests** + +```python +def test_delegated_token_contains_required_claims(rsa_settings): + token = PlatformTokenIssuer(rsa_settings).issue_delegated_token( + user_id="user-a", + tenant_id="tenant-a", + token_version=3, + origin_run_id="run-origin", + ) + claims = verify_platform_token(token, rsa_settings) + assert claims.sub == "user-a" + assert claims.tenant_id == "tenant-a" + assert claims.aud == "flowlens-platform" + assert "agentops.timeline.read" in claims.scope + assert claims.ver == 3 + + +def test_gateway_accepts_platform_bearer_and_rejects_wrong_audience(client, delegated_token): + assert client.get("/api/agentops/runs", headers={"Authorization": f"Bearer {delegated_token}"}).status_code == 200 + assert client.get("/api/agentops/runs", headers={"Authorization": "Bearer wrong-aud-token"}).status_code == 401 +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/test_mcp_platform_tokens.py tests/test_mcp_bearer_auth.py -q +``` + +Expected: fail because platform token modules and Bearer authentication are absent. + +- [ ] **Step 3: Implement RS256 claims, issuer, verifier, and JWKS** + +Use immutable claims: + +```python +class PlatformTokenClaims(BaseModel): + iss: str + sub: str + tenant_id: str + aud: str | list[str] + scope: list[str] + origin_run_id: str | None = None + azp: str + jti: str + iat: datetime + exp: datetime + ver: int +``` + +Read keys and identifiers from `FLOWLENS_MCP_JWT_PRIVATE_KEY_PATH`, `FLOWLENS_MCP_JWT_PUBLIC_KEY_PATH`, `FLOWLENS_MCP_JWT_KID`, `FLOWLENS_MCP_ISSUER`, and `FLOWLENS_MCP_AUDIENCE`. Reject missing key material at startup when MCP is enabled. + +- [ ] **Step 4: Implement the service and delegation grants** + +`POST /api/v1/auth/mcp/token` accepts form data: + +- `grant_type=client_credentials`: issue only `agentops.tools.discover`. +- `grant_type=urn:deerflow:params:oauth:grant-type:runtime-delegation`: require `subject_user_id` and `origin_run_id`, load the user from the repository, derive `tenant_id` and `ver` from that row, and issue the five read scopes. + +Authenticate `client_id` and `client_secret` with `hmac.compare_digest`; never echo the secret. + +- [ ] **Step 5: Extend Gateway middleware without weakening cookie auth** + +Authentication order: + +1. Valid internal token. +2. Valid `Authorization: Bearer` platform token. +3. Existing cookie JWT. +4. Otherwise `401`. + +Map delegated AgentOps scopes to Gateway `runs:read`, retain the full scope set on `request.state.platform_scopes`, load the real User row, and reject a stale `ver`. + +- [ ] **Step 6: Verify GREEN and existing auth regressions** + +Run: + +```powershell +uv run pytest tests/test_mcp_platform_tokens.py tests/test_mcp_bearer_auth.py tests/test_auth_middleware.py tests/test_auth.py tests/test_auth_errors.py tests/test_internal_auth.py -q +uv run ruff check app/gateway/auth app/gateway/auth_middleware.py app/gateway/authz.py app/gateway/routers/mcp_tokens.py +``` + +Expected: new Bearer cases and all existing cookie/internal-token cases pass. + +- [ ] **Step 7: Commit** + +```powershell +git add backend/app/gateway/auth backend/app/gateway/auth_middleware.py backend/app/gateway/authz.py backend/app/gateway/routers/mcp_tokens.py backend/app/gateway/app.py backend/tests/test_mcp_platform_tokens.py backend/tests/test_mcp_bearer_auth.py +git commit -m "feat(auth): issue scoped MCP delegation tokens" +``` + +### Task 3: Stabilize Gateway Query Contracts For MCP + +**Files:** +- Modify: `backend/app/gateway/routers/agentops.py` +- Modify: `backend/app/gateway/routers/thread_runs.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/store/base.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/store/memory.py` +- Modify: `backend/packages/harness/deerflow/persistence/run/sql.py` +- Modify: `backend/packages/harness/deerflow/runtime/runs/manager.py` +- Modify: `backend/tests/test_agentops_runs_api.py` +- Modify: `backend/tests/test_thread_run_diagnostics_api.py` +- Create: `backend/tests/test_agentops_run_lookup_api.py` + +**Interfaces:** +- Extends: `GET /api/agentops/runs` with optional `thread_id`. +- Produces: `GET /api/agentops/runs/{run_id}`. +- Preserves: existing thread-scoped Timeline and Diagnostics endpoints. +- Invariant: inaccessible and nonexistent runs both return `404`. + +- [ ] **Step 1: Add failing API contract tests** + +```python +def test_list_runs_filters_by_thread_and_tenant(client, token): + response = client.get( + "/api/agentops/runs?thread_id=thread-a&limit=20", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert {item["thread_id"] for item in response.json()["items"]} == {"thread-a"} + + +def test_run_lookup_hides_cross_tenant_run(client, tenant_a_token): + response = client.get( + "/api/agentops/runs/run-owned-by-tenant-b", + headers={"Authorization": f"Bearer {tenant_a_token}"}, + ) + assert response.status_code == 404 +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/test_agentops_runs_api.py tests/test_agentops_run_lookup_api.py tests/test_thread_run_diagnostics_api.py -q +``` + +Expected: fail because `thread_id` filtering, run lookup, and tenant-aware route calls are missing. + +- [ ] **Step 3: Add thread filtering to RunStore and run index** + +Extend `list_recent` with `thread_id: str | None`. Apply the condition before cursor ordering: + +```python +if thread_id is not None: + stmt = stmt.where(RunRow.thread_id == thread_id) +``` + +Keep the existing `(created_at DESC, run_id DESC)` cursor contract. + +- [ ] **Step 4: Add the owner-safe Run lookup endpoint** + +Return only the fields needed by MCP: + +```json +{ + "run_id": "run-a", + "thread_id": "thread-a", + "status": "error", + "created_at": "...", + "updated_at": "...", + "model_name": "...", + "total_tokens": 120, + "llm_call_count": 2, + "message_count": 4 +} +``` + +Resolve the current `User.tenant_id`, call `RunManager.get(run_id, user_id=..., tenant_id=...)`, and return the same `404` for absent or unauthorized data. + +- [ ] **Step 5: Verify GREEN and FlowLens regressions** + +Run: + +```powershell +uv run pytest tests/test_agentops_runs_api.py tests/test_agentops_run_lookup_api.py tests/test_thread_run_diagnostics_api.py tests/test_run_repository.py tests/test_tenant_run_isolation.py -q +uv run ruff check app/gateway/routers/agentops.py app/gateway/routers/thread_runs.py packages/harness/deerflow/runtime/runs packages/harness/deerflow/persistence/run +``` + +Expected: all selected tests pass. + +- [ ] **Step 6: Commit and report Stage 1** + +```powershell +git add backend/app/gateway/routers/agentops.py backend/app/gateway/routers/thread_runs.py backend/packages/harness/deerflow/runtime/runs backend/packages/harness/deerflow/persistence/run backend/tests/test_agentops_runs_api.py backend/tests/test_agentops_run_lookup_api.py backend/tests/test_thread_run_diagnostics_api.py +git commit -m "feat(agentops): expose MCP-safe run query contracts" +``` + +Stage 1 report must show the token claims test, cross-tenant `404`, and exact Gateway API response before Stage 2 begins. + +--- + +## Stage 2: MCP Server And DeerFlow Client Integration + +### Task 4: Create The Authenticated Streamable HTTP MCP Foundation + +**Files:** +- Modify: `backend/pyproject.toml` +- Create: `backend/packages/flowlens-inspector-mcp/pyproject.toml` +- Create: package files under `flowlens_inspector_mcp/` +- Create: `backend/tests/flowlens_inspector_mcp/test_settings.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_auth.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_server_contract.py` + +**Interfaces:** +- Produces: `InspectorSettings.from_env()`. +- Produces: `JwtTokenVerifier.verify_token(token: str) -> AccessToken | None`. +- Produces: `require_scope(scope: str) -> AccessPrincipal`. +- Produces: `create_mcp_server(settings, gateway_client, executor) -> FastMCP`. +- Produces: `create_app(settings: InspectorSettings | None = None) -> Starlette`. +- Produces: `/mcp`, `/health/live`, `/health/ready`, `/metrics`, and protected-resource metadata. + +- [ ] **Step 1: Write failing package and authentication tests** + +```python +@pytest.mark.anyio +async def test_token_verifier_returns_sdk_access_token(valid_jwt, verifier): + token = await verifier.verify_token(valid_jwt) + assert token is not None + assert token.client_id == "user-a" + assert "agentops.runs.read" in token.scopes + + +@pytest.mark.anyio +async def test_list_tools_exposes_exact_public_contract(mcp_client): + result = await mcp_client.list_tools() + assert {tool.name for tool in result.tools} == { + "health_check", + "list_runs", + "get_run_diagnostics", + "get_timeline_window", + "compare_runs", + } +``` + +- [ ] **Step 2: Add transport-security rejection cases** + +```python +@pytest.mark.anyio +async def test_streamable_http_rejects_unknown_origin(asgi_client): + response = await asgi_client.post( + "/mcp", + headers={"Origin": "https://attacker.example", "Host": "localhost:8010"}, + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + ) + assert response.status_code in {400, 403} + + +@pytest.mark.anyio +async def test_streamable_http_rejects_unknown_host(asgi_client): + response = await asgi_client.post( + "/mcp", + headers={"Origin": "http://localhost:2026", "Host": "attacker.example"}, + json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}, + ) + assert response.status_code in {400, 403} +``` + +- [ ] **Step 3: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/flowlens_inspector_mcp/test_settings.py tests/flowlens_inspector_mcp/test_auth.py tests/flowlens_inspector_mcp/test_server_contract.py -q +``` + +Expected: fail because the workspace package is absent. + +- [ ] **Step 4: Register the workspace package and direct dependencies** + +Add `packages/flowlens-inspector-mcp` to the uv workspace and add a workspace source. The package declares direct dependencies on: + +```toml +dependencies = [ + "deerflow-harness", + "mcp>=1.27,<2", + "httpx>=0.28", + "pydantic>=2.12", + "pyjwt[crypto]>=2.9", + "tenacity>=9", + "prometheus-client>=0.21", + "redis>=5", + "sqlalchemy[asyncio]>=2,<3", + "uvicorn[standard]>=0.34", +] +``` + +- [ ] **Step 5: Implement settings, verifier, principal, and scopes** + +The verifier validates RSA signature, `kid`, issuer, audience, expiry, and required claim types, then returns: + +```python +AccessToken( + token=raw_token, + client_id=claims.sub, + scopes=claims.scope, + expires_at=int(claims.exp.timestamp()), + resource=settings.resource_url, +) +``` + +`require_scope` reads `get_access_token()`, raises a typed authorization error when missing, and never accepts a user ID from tool arguments. + +- [ ] **Step 6: Build the ASGI service and health surfaces** + +Configure FastMCP with `stateless_http=False`, `streamable_http_path="/mcp"`, `TokenVerifier`, `AuthSettings`, and a strict `TransportSecuritySettings` Origin/Host allowlist. Bind each issued MCP Session ID to the verified `AccessToken.client_id` and reject a session reused under another principal. Mount the FastMCP Starlette application with live, ready, metrics, and protected-resource routes. + +- [ ] **Step 7: Verify GREEN and contract stability** + +Run: + +```powershell +uv run pytest tests/flowlens_inspector_mcp/test_settings.py tests/flowlens_inspector_mcp/test_auth.py tests/flowlens_inspector_mcp/test_server_contract.py -q +uv run ruff check packages/flowlens-inspector-mcp tests/flowlens_inspector_mcp +``` + +Expected: exact five-tool contract, valid authentication, and health routes pass. + +- [ ] **Step 8: Commit** + +```powershell +git add backend/pyproject.toml backend/packages/flowlens-inspector-mcp backend/tests/flowlens_inspector_mcp +git commit -m "feat(mcp): add authenticated FlowLens inspector service" +``` + +### Task 5: Implement The Five Read-Only Inspector Tools + +**Files:** +- Create/Modify: `flowlens_inspector_mcp/schemas.py` +- Create/Modify: `flowlens_inspector_mcp/gateway_client.py` +- Create/Modify: `flowlens_inspector_mcp/comparison.py` +- Create/Modify: all modules under `flowlens_inspector_mcp/tools/` +- Create: `backend/tests/flowlens_inspector_mcp/test_gateway_client.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_tools.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_comparison.py` + +**Interfaces:** +- Produces: versioned Pydantic inputs and outputs with `schema_version`, `request_id`, and `generated_at`. +- Produces: `GatewayClient` read methods that forward the original delegated Bearer token. +- Produces: `build_run_comparison(base, candidate) -> RunComparisonResponse`. +- Invariant: tool argument models contain no `user_id`, `tenant_id`, credential, or unrestricted page size. + +The concrete Gateway client surface is: + +```python +class GatewayClient: + async def health(self, *, token: str) -> GatewayHealth: ... + async def list_runs(self, *, token: str, query: RunListInput) -> RunListResponse: ... + async def get_run(self, *, token: str, run_id: str) -> RunSummary: ... + async def get_diagnostics(self, *, token: str, run: RunSummary) -> DiagnosticsResponse: ... + async def get_timeline(self, *, token: str, run: RunSummary, query: TimelineWindowInput) -> TimelineWindowResponse: ... +``` + +- [ ] **Step 1: Write failing tool behavior tests** + +```python +@pytest.mark.anyio +async def test_timeline_caps_limit_and_forwards_scope(tool_harness): + result = await tool_harness.call( + "get_timeline_window", + {"run_id": "run-a", "limit": 500}, + scopes=["agentops.timeline.read"], + ) + assert result.isError is True + + +@pytest.mark.anyio +async def test_compare_runs_is_deterministic(tool_harness): + first = await tool_harness.call("compare_runs", {"base_run_id": "run-a", "candidate_run_id": "run-b"}) + second = await tool_harness.call("compare_runs", {"base_run_id": "run-a", "candidate_run_id": "run-b"}) + assert first.structuredContent == second.structuredContent +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/flowlens_inspector_mcp/test_gateway_client.py tests/flowlens_inspector_mcp/test_tools.py tests/flowlens_inspector_mcp/test_comparison.py -q +``` + +Expected: fail because Gateway client methods and tool implementations are absent. + +- [ ] **Step 3: Implement bounded schemas and Gateway calls** + +Use opaque cursor strings and constrained limits: + +```python +class TimelineWindowInput(BaseModel): + run_id: str = Field(min_length=1, max_length=64) + phase: list[TimelinePhase] | None = None + after_seq: int | None = Field(default=None, ge=0) + limit: int = Field(default=20, ge=1, le=100) +``` + +Every run-specific call first resolves `GET /api/agentops/runs/{run_id}` to obtain the authorized `thread_id`, then calls the existing thread-scoped Timeline or Diagnostics endpoint with the same Bearer token. + +- [ ] **Step 4: Implement tool-specific scopes** + +Register: + +```python +TOOL_SCOPES = { + "health_check": "agentops.health.read", + "list_runs": "agentops.runs.read", + "get_run_diagnostics": "agentops.diagnostics.read", + "get_timeline_window": "agentops.timeline.read", + "compare_runs": "agentops.runs.compare", +} +``` + +Do not fetch Gateway data until the scope check passes. + +- [ ] **Step 5: Implement deterministic comparison and partial responses** + +Compare duration, tokens, LLM calls, tool calls/errors, subagents, MCP, Memory, rollbacks, status, and attribution. If one optional diagnostics section is unavailable, return `status="partial"`, explicit available/missing sections, and `retryable`; never fill missing values with invented evidence. + +- [ ] **Step 6: Verify GREEN, redaction, and contract tests** + +Run: + +```powershell +uv run pytest tests/flowlens_inspector_mcp/test_gateway_client.py tests/flowlens_inspector_mcp/test_tools.py tests/flowlens_inspector_mcp/test_comparison.py tests/test_flowlens_redaction.py -q +uv run ruff check packages/flowlens-inspector-mcp tests/flowlens_inspector_mcp +``` + +Expected: all five tools pass; no response fixture contains prompt, memory, Authorization, cookie, or secret fields. + +- [ ] **Step 7: Commit** + +```powershell +git add backend/packages/flowlens-inspector-mcp backend/tests/flowlens_inspector_mcp +git commit -m "feat(mcp): add FlowLens diagnostic tools" +``` + +### Task 6: Inject Runtime-Delegated Tokens Into MCP Tool Calls + +**Files:** +- Modify: `backend/packages/harness/deerflow/config/extensions_config.py` +- Create: `backend/packages/harness/deerflow/mcp/delegation.py` +- Modify: `backend/packages/harness/deerflow/mcp/tools.py` +- Modify: `backend/packages/harness/deerflow/mcp/oauth.py` +- Modify: `backend/app/gateway/services.py` +- Create: `backend/tests/test_mcp_delegation.py` +- Modify: `backend/tests/test_mcp_custom_interceptors.py` +- Modify: `backend/tests/test_mcp_diagnostics.py` + +**Interfaces:** +- Produces: `McpDelegatedAuthConfig`. +- Produces: `DelegatedTokenManager.get_authorization_header(server_name, runtime) -> str`. +- Produces: `build_delegated_auth_interceptor(config)`. +- Preserves: service OAuth header for initial `initialize`/`tools/list`. +- Overrides: service header with delegated user header only for `tools/call`. +- Produces: runtime-visible tool names prefixed as `flowlens_inspector_` by the existing multi-server client. + +- [ ] **Step 1: Write failing runtime delegation tests** + +```python +@pytest.mark.anyio +async def test_delegation_uses_server_injected_runtime_identity(manager, runtime): + runtime.context.update({ + "user_id": "user-a", + "tenant_id": "tenant-a", + "run_id": "run-origin", + }) + header = await manager.get_authorization_header("flowlens-inspector", runtime) + assert header.startswith("Bearer ") + assert manager.last_request["subject_user_id"] == "user-a" + assert manager.last_request["origin_run_id"] == "run-origin" + + +@pytest.mark.anyio +async def test_delegation_never_records_token_in_mcp_events(recorder, interceptor): + await invoke_interceptor(interceptor, recorder) + assert "Bearer " not in json.dumps(recorder.events) +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/test_mcp_delegation.py tests/test_mcp_custom_interceptors.py tests/test_mcp_diagnostics.py -q +``` + +Expected: fail because delegated auth config and interceptor are absent. + +- [ ] **Step 3: Add delegated auth configuration** + +Add an optional per-server object: + +```python +class McpDelegatedAuthConfig(BaseModel): + enabled: bool = True + token_url: str + client_id: str + client_secret: str + audience: str = "flowlens-platform" + refresh_skew_seconds: int = 60 +``` + +Environment placeholders continue to resolve through `ExtensionsConfig.resolve_env_variables`; UI masking treats `client_secret` as sensitive. + +- [ ] **Step 4: Implement per-user/run token acquisition and caching** + +Cache by: + +```text +(server_name, user_id, tenant_id, origin_run_id) +``` + +Use an `asyncio.Lock` per cache key. Post the runtime-delegation grant to Gateway and refresh before expiry. Require `user_id`, `tenant_id`, and `run_id` from server-owned Runtime Context; do not fall back to model arguments. + +- [ ] **Step 5: Compose interceptor order and diagnostics** + +The service OAuth interceptor supplies startup discovery credentials. The delegated interceptor runs inside it and replaces `Authorization` for tool calls. Emit only: + +```text +mcp.delegation.refresh +mcp.delegation.error +``` + +with server name, grant type, duration, status, and exception class. Existing RunJournal tool callbacks continue producing `mcp.tool.start/end/error`. + +- [ ] **Step 6: Verify GREEN and a real adapter call** + +Run: + +```powershell +uv run pytest tests/test_mcp_delegation.py tests/test_mcp_custom_interceptors.py tests/test_mcp_oauth.py tests/test_mcp_diagnostics.py tests/test_mcp_client_config.py -q +uv run ruff check packages/harness/deerflow/mcp packages/harness/deerflow/config/extensions_config.py app/gateway/services.py +``` + +Expected: service discovery auth and runtime delegation both pass without token leakage. + +- [ ] **Step 7: Commit and report Stage 2** + +```powershell +git add backend/packages/harness/deerflow/config/extensions_config.py backend/packages/harness/deerflow/mcp backend/app/gateway/services.py backend/tests/test_mcp_delegation.py backend/tests/test_mcp_custom_interceptors.py backend/tests/test_mcp_diagnostics.py +git commit -m "feat(mcp): delegate runtime identity to inspector tools" +``` + +Stage 2 report must show `tools/list`, one authenticated `list_runs`, one denied scope, and the corresponding `mcp.tool.*` Timeline evidence. + +--- + +## Stage 3: Reliability, Audit, And Deployment + +### Task 7: Add Bounded Resilience, Audit, And Metrics + +**Files:** +- Create/Modify: `flowlens_inspector_mcp/resilience.py` +- Create/Modify: `flowlens_inspector_mcp/audit.py` +- Create/Modify: `flowlens_inspector_mcp/telemetry.py` +- Modify: `flowlens_inspector_mcp/gateway_client.py` +- Modify: `flowlens_inspector_mcp/server.py` +- Create: `backend/packages/harness/deerflow/persistence/mcp_audit/` +- Modify: `backend/packages/harness/deerflow/persistence/models/__init__.py` +- Create: `backend/packages/harness/deerflow/persistence/migrations/versions/20260728_0002_flowlens_mcp_audit.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_resilience.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_audit.py` +- Create: `backend/tests/flowlens_inspector_mcp/test_metrics.py` + +**Interfaces:** +- Produces: `RetryPolicy`, `CircuitBreaker`, `ConcurrencyLimiter`, and `RateLimiter`. +- Produces: `InMemoryTokenBucket` and `RedisTokenBucket`. +- Produces: `ToolExecutor.execute(...)`. +- Produces: append-only `McpAuditRepository.insert(event)`. +- Produces: Prometheus counters, histograms, gauges, and circuit-state metric. + +Use these boundaries: + +```python +class RateLimiter(Protocol): + async def acquire(self, key: str, *, capacity: int, period_seconds: int) -> bool: ... + + +class ToolExecutor: + async def execute( + self, + *, + tool_name: str, + principal: AccessPrincipal, + target_run_ids: tuple[str, ...], + operation: Callable[[], Awaitable[T]], + ) -> T: ... +``` + +`ToolExecutor` owns concurrency, rate limiting, timing, metric updates, and the final audit write. `GatewayClient` owns HTTP timeout, retry, status mapping, and circuit state. + +- [ ] **Step 1: Write failing failure-policy tests** + +```python +@pytest.mark.anyio +async def test_gateway_retries_503_twice_then_opens_circuit(client): + client.transport.queue_statuses(503, 503, 503) + with pytest.raises(DependencyUnavailable): + await client.list_runs(token="token", limit=20) + assert client.transport.call_count == 3 + assert client.circuit.state == "open" + + +@pytest.mark.anyio +async def test_audit_whitelist_drops_sensitive_payload(audit_repo): + with pytest.raises(ValidationError): + McpAuditEvent( + request_id="req-1", + tool_name="get_run_diagnostics", + user_id="user-a", + tenant_id="tenant-a", + status="success", + authorization="Bearer secret", + ) + + await audit_repo.insert(McpAuditEvent( + request_id="req-1", + tool_name="get_run_diagnostics", + user_id="user-a", + tenant_id="tenant-a", + status="success", + )) + stored = await audit_repo.get("req-1") + assert "authorization" not in stored +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/flowlens_inspector_mcp/test_resilience.py tests/flowlens_inspector_mcp/test_audit.py tests/flowlens_inspector_mcp/test_metrics.py -q +``` + +Expected: fail because resilience, audit, and metrics implementations are absent. + +- [ ] **Step 3: Implement retry, cancellation, and circuit behavior** + +Retry only connection errors and `502/503/504`; never retry `400/401/403/404/422/429`. Respect cancellation. Open after five consecutive dependency failures, wait 15 seconds, allow one half-open probe, and close only on success. + +- [ ] **Step 4: Implement limiters** + +Use the key: + +```text +tenant_id:user_id:tool_name +``` + +Defaults: + +- general tools: 60/minute +- Timeline: 30/minute +- comparison: 10/minute +- process concurrency: 32 + +Redis uses one atomic Lua token-bucket operation. Loss of Redis produces an explicit configured policy: fail closed in enterprise mode and use the in-memory limiter only in local mode. + +- [ ] **Step 5: Implement append-only audit** + +Persist only the approved columns from the design. `McpAuditRepository` exposes `insert` and test-only lookup; no production update/delete API exists. The MCP database role documentation grants only `INSERT` and controlled `SELECT`. + +Create migration `20260728_0002_flowlens_mcp_audit.py` with `down_revision` pointing to the tenant migration. Never modify the already-applied `0001` migration. + +The MCP service lifespan initializes a dedicated SQLAlchemy engine from the configured application database URL, constructs `McpAuditRepository`, and disposes the engine during graceful shutdown. It does not use this connection to read Run or Timeline data. + +- [ ] **Step 6: Instrument metrics and trace correlation** + +Record tool requests, results, duration, retries, rate limits, active calls, Gateway errors, and circuit state. Propagate or create `request_id`, `traceparent`, and `origin_run_id`; never use a token as a metric label. + +- [ ] **Step 7: Verify GREEN and full package regressions** + +Run: + +```powershell +uv run pytest tests/flowlens_inspector_mcp -q +uv run ruff check packages/flowlens-inspector-mcp packages/harness/deerflow/persistence/mcp_audit +``` + +Expected: resilience state transitions, append-only audit, and metric updates pass. + +- [ ] **Step 8: Commit** + +```powershell +git add backend/packages/flowlens-inspector-mcp backend/packages/harness/deerflow/persistence/mcp_audit backend/packages/harness/deerflow/persistence/models backend/packages/harness/deerflow/persistence/migrations backend/tests/flowlens_inspector_mcp +git commit -m "feat(mcp): add inspector resilience and audit" +``` + +### Task 8: Package The Local And Enterprise Docker Paths + +**Files:** +- Create: `backend/Dockerfile.mcp` +- Modify: `docker/docker-compose-dev.yaml` +- Modify: `docker/docker-compose.yaml` +- Create: `docker/docker-compose-flowlens-mcp-enterprise.yaml` +- Modify: `docker/nginx/nginx.conf` +- Modify: `backend/packages/harness/deerflow/config/app_config.py` +- Modify: `.env.example` +- Modify: `extensions_config.example.json` +- Modify: `extensions_config.json` +- Create: `backend/scripts/generate_mcp_keys.py` +- Create: `backend/scripts/migrate_flowlens_mcp.py` +- Create: `backend/tests/test_mcp_key_generation.py` +- Create: `backend/tests/test_database_env_override.py` + +**Interfaces:** +- Produces: `python backend/scripts/generate_mcp_keys.py`. +- Produces: `python backend/scripts/migrate_flowlens_mcp.py upgrade`. +- Produces: local `flowlens-inspector-mcp:8010`. +- Produces: enterprise Compose overlay with PostgreSQL and Redis. +- Produces: internal URL `http://flowlens-inspector-mcp:8010/mcp`. + +- [ ] **Step 1: Write failing configuration and key tests** + +```python +def test_database_environment_override_selects_postgres(monkeypatch, config_file): + monkeypatch.setenv("DEER_FLOW_DATABASE_BACKEND", "postgres") + monkeypatch.setenv("DATABASE_URL", "postgresql://deerflow:test@postgres:5432/deerflow") + config = AppConfig.from_file(config_file) + assert config.database.backend == "postgres" + + +def test_key_generator_writes_private_0600_and_public_key(tmp_path): + generate_key_pair(tmp_path) + assert (tmp_path / "private.pem").exists() + assert (tmp_path / "public.pem").exists() + assert (tmp_path / "private.pem").read_text().startswith("-----BEGIN PRIVATE KEY-----") + if os.name != "nt": + assert stat.S_IMODE((tmp_path / "private.pem").stat().st_mode) == 0o600 +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/test_mcp_key_generation.py tests/test_database_env_override.py -q +``` + +Expected: fail because key generation and database environment override are absent. + +- [ ] **Step 3: Build a non-root MCP image** + +`backend/Dockerfile.mcp` uses a builder stage, installs only the MCP workspace package and dependencies, creates a non-root `flowlens` user, exposes 8010, and starts: + +```text +python -m flowlens_inspector_mcp +``` + +- [ ] **Step 4: Add local Compose wiring without a startup cycle** + +Start MCP before Gateway using `/health/live`; do not make MCP readiness depend on Gateway for Compose ordering. Gateway reaches MCP on the internal network. MCP `/health/ready` may report Gateway degradation after both processes start. + +Mount only the private key into Gateway. MCP validates through Gateway JWKS and never receives the private key. + +- [ ] **Step 5: Add the enterprise overlay** + +The overlay adds PostgreSQL, Redis, health checks, persistent volumes, and: + +```text +DEER_FLOW_DATABASE_BACKEND=postgres +DATABASE_URL=postgresql://deerflow:${POSTGRES_PASSWORD}@postgres:5432/deerflow +FLOWLENS_MCP_RATE_LIMIT_BACKEND=redis +FLOWLENS_MCP_REDIS_URL=redis://redis:6379/0 +``` + +Add an AppConfig environment override test so no duplicate full `config.yaml` is needed. + +- [ ] **Step 6: Add Nginx and extension configuration** + +Proxy `/mcp` with buffering disabled, bounded body size, correct forwarded headers, and Streamable HTTP timeouts. Add a disabled `flowlens_inspector` entry to committed config and an enabled example using service OAuth plus `delegatedAuth`; secrets use `$FLOWLENS_MCP_CLIENT_SECRET`. Remove the non-resolving `my_package.mcp.auth:build_auth_interceptor` sample from the active committed config because delegated auth is now a built-in interceptor. + +- [ ] **Step 7: Verify Compose and container health** + +Run: + +```powershell +docker compose -f docker/docker-compose-dev.yaml config +docker compose -f docker/docker-compose.yaml config +docker compose -f docker/docker-compose.yaml -f docker/docker-compose-flowlens-mcp-enterprise.yaml config +uv run pytest tests/test_mcp_key_generation.py tests/test_database_env_override.py -q +``` + +Expected: all Compose models validate and focused tests pass. + +- [ ] **Step 8: Commit and report Stage 3** + +```powershell +git add backend/Dockerfile.mcp docker .env.example extensions_config.example.json extensions_config.json backend/packages/harness/deerflow/config/app_config.py backend/scripts/generate_mcp_keys.py backend/scripts/migrate_flowlens_mcp.py backend/tests/test_mcp_key_generation.py backend/tests/test_database_env_override.py +git commit -m "feat(mcp): package enterprise inspector deployment" +``` + +Stage 3 report must show local live/ready output, one forced Gateway timeout with retry/circuit evidence, one audit row, and validated enterprise Compose. + +--- + +## Stage 4: Skill, End-To-End Evidence, And Teaching Materials + +### Task 9: Add The FlowLens Incident Response Skill + +**Files:** +- Create: `skills/public/flowlens-incident-response/SKILL.md` +- Create: `skills/public/flowlens-incident-response/references/diagnostic-playbook.md` +- Create: `skills/public/flowlens-incident-response/references/output-format.md` +- Create: `skills/public/flowlens-incident-response/evals/trigger_eval_set.json` +- Create: `skills/public/flowlens-incident-response/evals/evals.json` +- Create: `backend/tests/test_flowlens_incident_skill.py` + +**Interfaces:** +- Produces: intent-triggered incident investigation SOP. +- Requires: the exact five MCP tool names. +- Produces: conclusion, evidence, impact, remediation, and uncertainty sections. +- Invariant: every root-cause claim cites a Run ID and Timeline event sequence. + +- [ ] **Step 1: Write failing Skill contract tests** + +```python +def test_skill_references_only_real_inspector_tools(skill_text): + expected = { + "flowlens_inspector_health_check", + "flowlens_inspector_list_runs", + "flowlens_inspector_get_run_diagnostics", + "flowlens_inspector_get_timeline_window", + "flowlens_inspector_compare_runs", + } + assert expected <= set(extract_backticked_tool_names(skill_text)) + + +def test_skill_forbids_invented_evidence(skill_text): + assert "不得根据缺失事件猜测根因" in skill_text + assert "provisional" in skill_text + assert "partial" in skill_text +``` + +- [ ] **Step 2: Run tests to verify RED** + +Run: + +```powershell +uv run pytest tests/test_flowlens_incident_skill.py -q +``` + +Expected: fail because the Skill does not exist. + +- [ ] **Step 3: Write focused Skill frontmatter and workflow** + +The description triggers on requests to diagnose a failed/slow Agent run, compare runs, explain Tool/MCP/Subagent failures, or produce an evidence-backed incident report. It must not trigger for ordinary chat, code editing, or generic MCP education. The Skill names the `flowlens_inspector_*` tools visible to DeerFlow while documenting the corresponding unprefixed MCP server contract. + +- [ ] **Step 4: Write the playbook and report contract** + +The playbook enforces: + +```text +locate run -> deterministic diagnosis -> evidence window -> optional baseline comparison -> report +``` + +The output format requires `run_id`, evidence sequence numbers, completeness state, and no secret/raw-content fields. + +- [ ] **Step 5: Add trigger and behavior evals** + +Include positive, negative, ambiguous, running-run, partial-data, cross-run comparison, and MCP-unavailable cases. Expected behavior names the first tool and required final sections rather than relying on exact prose. + +- [ ] **Step 6: Verify GREEN and commit** + +Run: + +```powershell +uv run pytest tests/test_flowlens_incident_skill.py -q +``` + +Then: + +```powershell +git add skills/public/flowlens-incident-response backend/tests/test_flowlens_incident_skill.py +git commit -m "feat(skills): add FlowLens incident response playbook" +``` + +### Task 10: Prove The Complete Flow And Publish Only Measured Evidence + +**Files:** +- Create: `backend/scripts/seed_agentops_runs.py` +- Create: `backend/scripts/benchmark_inspector_mcp.py` +- Create: `scripts/verify_flowlens_mcp.ps1` +- Create: `backend/tests/test_flowlens_inspector_e2e.py` +- Modify: `README.md` +- Modify: `docs/architecture.md` +- Create: `docs/flowlens-inspector-mcp.md` +- Modify: relevant GitHub CI workflow only after local commands pass. + +**Interfaces:** +- Produces: deterministic success, Tool Error, timeout, and cross-tenant fixtures. +- Produces: direct MCP E2E test and manual real-model acceptance script. +- Produces: reproducible P50/P95/P99 benchmark JSON. +- Produces: Chinese operator and interview explanation. + +- [ ] **Step 1: Write the failing end-to-end test** + +```python +@pytest.mark.anyio +async def test_runtime_calls_remote_inspector_and_records_mcp_evidence(full_stack): + result = await full_stack.ask("请使用 FlowLens 调查最近一次失败,并引用证据事件。") + assert result.contains_run_id("failed-run") + assert result.contains_event_sequence() + + events = await full_stack.events_for_origin_run() + assert "mcp.tool.start" in {event["event_type"] for event in events} + assert "mcp.tool.end" in {event["event_type"] for event in events} +``` + +- [ ] **Step 2: Run test to verify RED** + +Run: + +```powershell +uv run pytest tests/test_flowlens_inspector_e2e.py -q +``` + +Expected: fail until fixture seeding, service startup, delegation, and Skill wiring are connected. + +- [ ] **Step 3: Implement deterministic seed and benchmark scripts** + +The seed script accepts `--runs`, `--events-per-run`, `--tenant`, `--user`, and `--scenario`. The benchmark calls real Streamable HTTP tools, records environment metadata, and writes JSON containing sample count, P50, P95, P99, error count, and configuration. + +- [ ] **Step 4: Implement the PowerShell verification path** + +`verify_flowlens_mcp.ps1` performs: + +1. Required environment validation without printing secrets. +2. Key generation when absent. +3. Docker build/start. +4. Live and ready checks. +5. Token acquisition. +6. MCP `tools/list`. +7. Authorized and unauthorized tool calls. +8. Audit and Timeline evidence checks. +9. Optional PostgreSQL/Redis profile. +10. Clear stop/cleanup instructions without deleting persistent user data. + +- [ ] **Step 5: Make deterministic E2E GREEN** + +Run: + +```powershell +uv run pytest tests/test_flowlens_inspector_e2e.py tests/flowlens_inspector_mcp tests/test_mcp_delegation.py tests/test_flowlens_incident_skill.py -q +uv run ruff check packages/flowlens-inspector-mcp packages/harness/deerflow/mcp app/gateway scripts +``` + +Expected: all selected tests pass and Ruff reports no errors. + +- [ ] **Step 6: Run the complete Docker acceptance** + +Run from repository root: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/verify_flowlens_mcp.ps1 +``` + +Expected: script prints successful live/ready, discovery, authorized query, denied cross-tenant query, audit, and Timeline evidence checks. + +- [ ] **Step 7: Perform the user-led real-model demonstration** + +The user starts DeerFlow, creates one normal run and one controlled failure, then sends: + +```text +请使用 FlowLens Incident Response 调查本会话最近一次失败。 +先给出确定性根因,再引用关键 Timeline 事件;如果数据不完整请明确说明。 +``` + +Verify the final answer, the selected MCP tools, downloaded evidence, audit row, and `/agentops` Timeline together. Record only observed output. + +- [ ] **Step 8: Benchmark SQLite and enterprise profiles** + +Run both: + +```powershell +cd backend +uv run python scripts/benchmark_inspector_mcp.py --runs 1000 --events-per-run 100 --output ../artifacts/mcp-benchmark-sqlite.json +``` + +```powershell +uv run python scripts/benchmark_inspector_mcp.py --runs 1000 --events-per-run 100 --profile enterprise --output ../artifacts/mcp-benchmark-postgres.json +``` + +Do not place numbers in README until both output files exist and are reviewed. + +- [ ] **Step 9: Update documentation with verified commands and evidence** + +Document: + +- Architecture and trust boundaries. +- Tool contracts and Skill workflow. +- Local and enterprise startup. +- Token claims and scope table. +- Failure handling and audit fields. +- Three demonstration scenarios. +- Actual test and benchmark output with environment. +- Honest boundary: local end-to-end and fault-injection validation, not production customer deployment. + +- [ ] **Step 10: Run the final verification gate** + +Run: + +```powershell +cd backend +$env:PYTHONPATH='.' +uv run pytest -q +uv run ruff check . +cd .. +docker compose -f docker/docker-compose-dev.yaml config +docker compose -f docker/docker-compose.yaml -f docker/docker-compose-flowlens-mcp-enterprise.yaml config +powershell -ExecutionPolicy Bypass -File scripts/verify_flowlens_mcp.ps1 +git diff --check +git status --short +``` + +Expected: backend suite and Ruff pass, both Compose configurations validate, live-stack verification succeeds, no whitespace errors exist, and only intentional uncommitted evidence files remain. + +- [ ] **Step 11: Commit and report Stage 4** + +```powershell +git add backend/scripts scripts/verify_flowlens_mcp.ps1 backend/tests/test_flowlens_inspector_e2e.py README.md docs .github +git commit -m "docs(mcp): publish verified inspector workflow" +``` + +Stage 4 report must distinguish automated evidence, user-led real-model evidence, benchmark measurements, and unverified production assumptions. + +--- + +## Review Checkpoints + +- **Checkpoint A after Stage 1:** inspect JWT claims, Gateway Bearer behavior, tenant migration, and cross-tenant `404`. +- **Checkpoint B after Stage 2:** inspect five MCP schemas, a real Streamable HTTP tool call, ToolMessage return, and `mcp.tool.*` events. +- **Checkpoint C after Stage 3:** inspect timeout/retry/circuit behavior, audit redaction, Prometheus metrics, and both Docker profiles. +- **Checkpoint D after Stage 4:** run the demonstration together and compare every README/result claim against fresh artifacts. + +## Execution Handoff + +Plan complete and saved to `docs/superpowers/plans/2026-07-28-flowlens-inspector-mcp.md`. + +The selected execution method is **Inline Execution**: execute Stage 1 through Stage 4 in this task, stop after each stage for the user-facing report and hands-on explanation, and do not use task-per-subagent development. diff --git a/docs/superpowers/plans/2026-07-29-flowlens-agentops-investigation-workbench.md b/docs/superpowers/plans/2026-07-29-flowlens-agentops-investigation-workbench.md new file mode 100644 index 00000000..e0ce7d08 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-flowlens-agentops-investigation-workbench.md @@ -0,0 +1,273 @@ +# FlowLens AgentOps Investigation Workbench Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Transform AgentOps into a responsive, evidence-led Trace-first investigation workbench while preserving all current diagnostics behavior and API contracts. + +**Architecture:** Keep the existing diagnostics-source queries and replay/remote modes. Add a scoped AgentOps design-token stylesheet, a `RunOverview` presentational component, and a pure `TraceWaterfall` derived from normalized Timeline items. Compose them inside the existing workspace and restyle existing panes without changing source contracts. + +**Tech Stack:** Next.js 16, React 19, TypeScript 5.8, Tailwind CSS v4, Lucide React, TanStack Query, Vitest, Playwright. + +## Global Constraints + +- Preserve existing `/agentops` route, source contracts, remote/replay modes, filters, Explain, Export, evidence navigation, and event-detail redaction. +- Use only existing dependencies and real query data; do not modify backend schemas or APIs. +- Scope visual tokens to `.agentops-shell`; use the existing system font stack and `prefers-reduced-motion` behavior. +- No large gradients, neon effects, card-stack layout, excessive shadow, or horizontal page overflow. +- Validate with `pnpm.cmd test`, `pnpm.cmd check`, and `pnpm.cmd test:e2e:agentops-demo`. + +--- + +## File Structure + +- Create `frontend/src/styles/agentops.css`: AgentOps-only colors, spacing, typography, control, status, and motion tokens. +- Modify `frontend/src/styles/globals.css`: import the AgentOps token stylesheet. +- Create `frontend/src/components/agentops/run-overview.tsx`: selected-run identity and status header. +- Create `frontend/src/components/agentops/trace-waterfall.tsx`: pure trace-bar projection and selectable waterfall component. +- Modify `frontend/src/components/agentops/agentops-workspace.tsx`: compose workbench, overview, waterfall, existing Timeline, and responsive tabs/drawer. +- Modify `frontend/src/components/agentops/{metrics-strip,run-explorer,timeline,timeline-row,diagnostics-panel,event-detail-sheet}.tsx`: apply scoped surfaces, controls, status hierarchy, and responsive layout classes without changing public props. +- Create `frontend/tests/unit/components/trace-waterfall.test.tsx`: verify timestamp/duration projection and selection behavior. +- Modify `frontend/tests/unit/components/agentops-workspace.test.tsx`: verify overview, waterfall, and existing evidence navigation. +- Modify `frontend/tests/e2e/agentops-demo.spec.ts`: assert Trace Waterfall works at desktop/tablet/mobile and retain overflow check. + +## Task 1: Scope AgentOps Design Tokens and Run Identity Header + +**Files:** +- Create: `frontend/src/styles/agentops.css` +- Modify: `frontend/src/styles/globals.css` +- Create: `frontend/src/components/agentops/run-overview.tsx` +- Modify: `frontend/tests/unit/components/agentops-workspace.test.tsx` + +**Interfaces:** +- Consumes: `RunSummary` from `@/core/agentops/types` and `mode: "remote" | "replay"`. +- Produces: `RunOverview({ run, mode }: { run: RunSummary | null; mode: "remote" | "replay" })`. + +- [ ] **Step 1: Write the failing run overview test** + +```tsx +expect(await screen.findByText("Run context")).toBeVisible(); +expect(screen.getByText(/Thread:/)).toBeVisible(); +expect(screen.getByText(/Replay data source/)).toBeVisible(); +``` + +- [ ] **Step 2: Run the unit test to verify it fails** + +Run: `pnpm.cmd vitest run tests/unit/components/agentops-workspace.test.tsx` + +Expected: FAIL because `Run context` and the source label are not rendered. + +- [ ] **Step 3: Add scoped tokens and the header component** + +```tsx +export function RunOverview({ run, mode }: { run: RunSummary | null; mode: "remote" | "replay" }) { + return
...
; +} +``` + +Define `--agentops-surface`, `--agentops-canvas`, `--agentops-ink`, `--agentops-muted`, `--agentops-border`, `--agentops-accent`, semantic status variables, `--agentops-radius`, and `--agentops-motion` under `.agentops-shell`. Import the stylesheet after Tailwind imports. + +- [ ] **Step 4: Run the unit test to verify it passes** + +Run: `pnpm.cmd vitest run tests/unit/components/agentops-workspace.test.tsx` + +Expected: PASS with the existing replay interaction tests still green. + +- [ ] **Step 5: Commit the task** + +```powershell +git add frontend/src/styles/agentops.css frontend/src/styles/globals.css frontend/src/components/agentops/run-overview.tsx frontend/tests/unit/components/agentops-workspace.test.tsx +git commit -m "feat(agentops): add workbench design tokens" +``` + +## Task 2: Build the Real-data Trace Waterfall + +**Files:** +- Create: `frontend/src/components/agentops/trace-waterfall.tsx` +- Create: `frontend/tests/unit/components/trace-waterfall.test.tsx` + +**Interfaces:** +- Consumes: `items: TimelineItem[]`, `selectedSeq?: number`, and `onSelect?: (item: TimelineItem) => void`. +- Produces: `TraceWaterfall({ items, selectedSeq, onSelect })` and exported `buildTraceRows(items)` returning rows with `item`, `offsetPercent`, `widthPercent`, and `depth`. + +- [ ] **Step 1: Write failing projection and interaction tests** + +```tsx +expect(buildTraceRows(items)[1]).toMatchObject({ offsetPercent: 50, widthPercent: 50, depth: 1 }); +await user.click(screen.getByRole("button", { name: /trace event 2/i })); +expect(onSelect).toHaveBeenCalledWith(items[1]); +``` + +Use deterministic ISO timestamps with a 200ms total trace window and one item whose `parent_span_id` targets the first item. Add a zero-duration item and assert it renders a visible minimum-width marker. + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: `pnpm.cmd vitest run tests/unit/components/trace-waterfall.test.tsx` + +Expected: FAIL because `buildTraceRows` and `TraceWaterfall` do not exist. + +- [ ] **Step 3: Implement trace derivation and rendering** + +```tsx +const rows = buildTraceRows(items); +return

Trace Waterfall

{rows.map(renderTraceRow)}
; +``` + +Parse timestamps with `Date.parse`, use the earliest valid timestamp as the origin, and normalize the latest `timestamp + duration_ms` as the end. If timestamps are invalid or all durations are absent, render a compact chronological fallback list. Clamp each bar to 2%-100% so zero-duration events remain inspectable. Derive nesting with the existing cycle-safe parent-span traversal pattern. + +- [ ] **Step 4: Run focused tests to verify they pass** + +Run: `pnpm.cmd vitest run tests/unit/components/trace-waterfall.test.tsx` + +Expected: PASS for normal, zero-duration, invalid-timestamp, and selection cases. + +- [ ] **Step 5: Commit the task** + +```powershell +git add frontend/src/components/agentops/trace-waterfall.tsx frontend/tests/unit/components/trace-waterfall.test.tsx +git commit -m "feat(agentops): add timeline trace waterfall" +``` + +## Task 3: Compose the A1 Investigation Workbench + +**Files:** +- Modify: `frontend/src/components/agentops/agentops-workspace.tsx` +- Modify: `frontend/src/components/agentops/metrics-strip.tsx` +- Modify: `frontend/src/components/agentops/timeline.tsx` +- Modify: `frontend/tests/unit/components/agentops-workspace.test.tsx` + +**Interfaces:** +- Consumes: `RunOverview`, `TraceWaterfall`, existing `RunExplorer`, `MetricsStrip`, `Timeline`, and `DiagnosticsPanel`. +- Produces: a desktop three-surface workbench and existing tablet/mobile drawer plus tabs. + +- [ ] **Step 1: Add failing composition assertions** + +```tsx +expect(await screen.findByRole("region", { name: "Run context" })).toBeVisible(); +expect(screen.getByRole("region", { name: "Execution trace" })).toBeVisible(); +``` + +- [ ] **Step 2: Run the workspace test to verify it fails** + +Run: `pnpm.cmd vitest run tests/unit/components/agentops-workspace.test.tsx` + +Expected: FAIL because the new labelled regions are absent. + +- [ ] **Step 3: Integrate components and selection state** + +```tsx + + + +``` + +Place overview and metrics above the center surface. Preserve compact controls; add an `Trace` tab alongside Timeline and Root Cause below the desktop breakpoint. Use semantic `
` and `
+ + + + {diagnosticsQuery.data && selected ? ( + toggleFocus("cause")} + /> + } + onEvidenceSelect={(seq) => { + setSelectedEvent( + diagnosticsQuery.data.timeline.find( + (item) => item.seq === seq, + ) ?? null, + ); + }} + /> + ) : ( + + )} + + ) : null} - -
- {diagnosticsQuery.data && selected ? ( - { - setSelectedEvent( - diagnosticsQuery.data.timeline.find( - (item) => item.seq === seq, - ) ?? null, - ); - }} - /> - ) : ( - - )} + {!isDesktopWorkbench ? ( +
+ {runsOpen ? ( +
+ ) : null}
void; + headerAction?: ReactNode; }) { const source = useDiagnosticsSource(); + const hasIncident = failure.primary_cause !== "NONE"; const [explanation, setExplanation] = useState( null, ); @@ -52,42 +56,43 @@ export function DiagnosticsPanel({ return (