"""All MCP tools registered with the singleton FastMCP instance. Each tool is a thin adapter from the MCP JSON-RPC call into the existing Helix domain services. Tools intentionally do NOT replicate business rules — they call the same service method the corresponding HTTP endpoint calls, so behaviour stays consistent. The only differences are: 1. Authentication is the PAT contextvars set by ``app.mcp.middleware`` rather than a FastAPI Bearer dependency. 2. The DB session is opened freshly per tool call (via ``tool_runtime``) rather than injected from FastAPI's ``Depends``. 3. Audit logs use ``action="mcp."`` so admins can filter AI-initiated actions in the audit-logs page. Volume: ~30 read tools and ~12 write tools, organised top-to-bottom by domain. Each tool is small (one or two service calls) and uses the ``serialize`` helper to produce JSON-safe output. """ from __future__ import annotations import logging from datetime import UTC, datetime from datetime import date as date_type from datetime import time as _time from typing import Annotated, Any, cast from uuid import UUID from pydantic import Field from sqlalchemy import func, select from app.mcp.context import ( get_or_init_trace_id, mcp_client_ip, mcp_user_agent, ) from app.mcp.runtime import _safe_rollback, serialize, tool_runtime from app.mcp.server import mcp_server def _trace_str() -> str: """Per-MCP-request trace id as a string. Audit logs and downstream services that use ``trace_id`` (Feishu approval submission, etc.) accept either a UUID-shape string or any tag — passing the real trace lets the admin reconstruct an MCP request from the audit log alone. """ return str(get_or_init_trace_id()) def _catalogue_person_payload( person_id: Any, people: dict[str, Any], ) -> dict[str, Any] | None: """Serialize the canonical person link used by software catalog rows.""" if not person_id: return None person = people.get(str(person_id)) if person is None: return None label = person.display_name or person.work_email or str(person_id) return { "person_id": str(person_id), "display_name": person.display_name, "work_email": person.work_email, "employment_status": person.employment_status, "mention": f"[[helix_mention:{person_id}:{label}]]", } def _catalogue_enriched_person_payload( data: dict[str, Any], role: str, ) -> dict[str, Any] | None: """Build the same person contract from the HTTP enrichment payload.""" person_id = data.get(f"{role}_person_id") if not person_id: return None label = data.get(f"{role}_person_name") or data.get(f"{role}_person_email") return { "person_id": str(person_id), "display_name": data.get(f"{role}_person_name"), "work_email": data.get(f"{role}_person_email"), "employment_status": data.get(f"{role}_person_employment_status"), "mention": f"[[helix_mention:{person_id}:{label or person_id}]]", } logger = logging.getLogger(__name__) #: Date parameters, annotated so the constraint travels WITH the field. #: #: ``x_helix_date`` survives into ``tool.parameters`` (verified against #: FastMCP's generated schema), which is the schema the assistant derives its #: proposal validator from — so a flow declares "this date is in the future" #: once, here, and both surfaces enforce it. That matters because an LLM with no #: sense of today falls back on its training year: production, 2026-08-18, an #: employee said 「有效期到 9 月 20 号」 and the model wrote ``2024-09-20``, which #: the domain schema then rejects — AFTER the employee has already tapped #: confirm. A hand-maintained list of "date fields" in the assistant was the #: other option and would rot on the first new flow. #: #: Deliberately NOT applied to every date field. ``feed_update_my_preferences`` #: takes a birthday, which must be in the past, and #: ``update_my_software_catalog_entry.expiry_date`` records a licence that may #: already have lapsed — a blanket "no past dates" rule would break both. FutureDate = Annotated[ str, Field(description=( "An ISO date, YYYY-MM-DD, that is TODAY or later — resolve what the " "employee said against today's real date, which the system prompt " "states. Never guess a year." ), json_schema_extra={"x_helix_date": "future"}), ] PastDate = Annotated[ str | None, Field(default=None, description=( "An ISO date, YYYY-MM-DD, in the past — a date of birth, not a " "future event." ), json_schema_extra={"x_helix_date": "past"}), ] def _short_repr(value, *, max_len: int = 40) -> str: """Bounded, control-stripped repr for echoing into validation error messages — keeps prompt-injection surface narrow. Without truncation a 100KB string or a string carrying adversarial text (e.g. "ignore previous instructions and ...") would round-trip verbatim from validation error → tool error → LLM context. We coerce to ``str``, strip C0 / RTL-override chars, and cap length. """ s = str(value) s = "".join(ch for ch in s if (ord(ch) >= 32 or ch in "\t\n") and ord(ch) not in ( 0x202A, 0x202B, 0x202C, 0x202D, 0x202E, 0x2066, 0x2067, 0x2068, 0x2069, )) if len(s) > max_len: s = s[:max_len] + f"...[{len(s) - max_len} more chars]" return repr(s) def _u(value: str) -> UUID: """Parse a string to a UUID with a friendlier error than the raw Python TypeError. Used at the boundary where MCP input arrives as JSON strings. """ try: return UUID(str(value)) except (TypeError, ValueError) as exc: from app.core.exceptions import ValidationError raise ValidationError( f"Invalid UUID: {_short_repr(value)}", ) from exc def _parse_iso_date(value: str, *, field: str): """Parse an LLM-supplied ISO date string with a typed VALIDATION_ERROR. Bare ``date.fromisoformat(value)`` raises ``ValueError`` which the runtime catch-all maps to ``INTERNAL_ERROR`` (500) — bad UX for what is a caller-correctable input mistake. Wrapping here gives the LLM an actionable 422 with the offending field name. Echoed value is bounded by ``_short_repr`` to keep adversarial-input prompt-injection surface narrow. """ from datetime import date as _date from app.core.exceptions import ValidationError try: return _date.fromisoformat(str(value)) except (TypeError, ValueError) as exc: raise ValidationError( f"{field} must be ISO 8601 date (YYYY-MM-DD), got " f"{_short_repr(value)}", ) from exc def _money_or_none(value: str | None, *, field: str): """Parse an LLM-supplied money string into ``Decimal``, or ``None``. Same reasoning as :func:`_parse_iso_date`: a bad amount is a caller-correctable input mistake and must surface as a typed 422, not the runtime catch-all's blank 500. Accepts the empty string as "not supplied" because LLMs routinely send ``""`` for an optional field. """ from decimal import Decimal as _Dec from decimal import InvalidOperation from app.core.exceptions import ValidationError if value is None or str(value).strip() == "": return None try: return _Dec(str(value).strip()) except (InvalidOperation, TypeError, ValueError) as exc: raise ValidationError( f"{field} must be a decimal amount, got {_short_repr(value)}", ) from exc def _iso_utc(dt: datetime | date_type | _time | None) -> str | None: """Render a datetime as a tz-tagged ISO 8601 string, treating naive datetimes as UTC. MySQL ``DATETIME(timezone=True)`` columns round-trip to a *naive* datetime on fresh load even when the column declares ``DateTime(tz=True)`` (see project memory note ``feedback_mysql_datetime_naive_on_fresh_load``). A bare ``dt.isoformat()`` on such a value emits an unsuffixed string ("2026-05-27T03:14:00") which LLM clients / downstream charts silently treat as the LOCAL clock — a subtle attribution bug for audit / billing / SLA payloads. Wrapping the call in ``_iso_utc`` coerces the naive load back to UTC at the serialization boundary so every outbound timestamp is unambiguously tz-tagged. Pair this with the same defensive coerce in ``serialize()`` (runtime.py). Returns ``None`` for ``None`` input — call sites can use ``_iso_utc(maybe_dt)`` without a guarding ternary. For a plain ``date`` (no time component) we short-circuit to ``date.isoformat()`` — ``date.replace()`` rejects a ``tzinfo`` kwarg with TypeError, and a date has no clock semantics to coerce anyway. Without this guard the wrapper crashes on any tool that returns a date-typed column (e.g. cmdb_cloud_domains.expiry_date — ``Mapped[date | None]`` — used by ``list_cloud_domains`` / ``get_cloud_domain``). (R2 review catch.) """ if dt is None: return None # ``datetime`` is a subclass of ``date``; check the concrete # ``datetime`` type FIRST so a real datetime falls into the # tz-coerce branch, and a pure date short-circuits. from datetime import date as _date from datetime import datetime as _datetime if isinstance(dt, _datetime): if dt.tzinfo is None: dt = dt.replace(tzinfo=UTC) return dt.isoformat() if isinstance(dt, _date): return dt.isoformat() # Anything else with an ``.isoformat()`` (e.g. ``time``) falls # through to its own representation; tz-coerce is meaningless. return dt.isoformat() def _parse_iso_datetime(value: str, *, field: str, default_utc: bool = True): """Parse an LLM-supplied ISO datetime string into a tz-aware datetime. Naive datetimes are assumed UTC when ``default_utc=True``. Without this normalisation, downstream arithmetic against ``datetime.now(UTC)`` raises ``TypeError`` (offset-naive vs offset-aware), which surfaces to the LLM as an opaque 500. """ from datetime import datetime as _dt from app.core.exceptions import ValidationError try: # ``fromisoformat`` accepts the trailing ``Z`` from Python 3.11+; # Helix is on 3.12 so this is safe. parsed = _dt.fromisoformat(str(value)) except (TypeError, ValueError) as exc: raise ValidationError( f"{field} must be ISO 8601 datetime, got {_short_repr(value)}", ) from exc if parsed.tzinfo is None and default_utc: parsed = parsed.replace(tzinfo=UTC) return parsed def _require_asset_admin(rt) -> None: """Match the HTTP route's ``Depends(require_asset_admin())`` gate. Several of the asset-mutation MCP tools (claim/return/borrow/ return_borrowed/get_history) used to call ``AssetService`` directly without any RBAC check, while the HTTP equivalents are admin-only. Centralising the check here keeps the tools terse and ensures the next tool author can't accidentally re-introduce the hole. """ from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_assets if not can_manage_assets(rt.user.role_names): raise ForbiddenError( "This action is restricted to asset / global admins.", ) async def _mcp_enforce_asset_region(rt, asset_id) -> None: """Mirror the HTTP route's ``_enforce_asset_region`` gate. MCP is a first-class surface (`feedback_mcp_sync_rule`). Without this helper, MCP `claim_asset` / `return_asset` / `borrow_asset` / `recycle_asset_to_office` / `release_asset_from_office` / etc. sidestep the region check that gates the HTTP equivalents — a region-scoped asset_admin could call MCP `claim_asset` on any asset anywhere in the company, defeating the entire feature. Loads the asset, runs ``assert_can_manage_asset`` (which honours global_admin bypass + auditor / management read-only short-circuit at the same call site as the HTTP route). """ import uuid as _uuid # noqa: PLC0415 from app.core.dependencies import assert_can_manage_asset # noqa: PLC0415 from app.core.exceptions import NotFoundError # noqa: PLC0415 from app.domains.asset.models import AssetItem # noqa: PLC0415 aid = asset_id if isinstance(asset_id, _uuid.UUID) else _uuid.UUID(str(asset_id)) asset = await rt.db.get(AssetItem, aid) if asset is None or asset.deleted_at is not None: raise NotFoundError("Asset", str(aid)) await assert_can_manage_asset(rt.db, rt.user, asset) async def _mcp_assert_target_person_in_region(rt, target_person_id) -> None: """Apply the HTTP route's target-person region gate, unchanged. Delegates rather than reimplements. The two used to be separate copies and they drifted — the MCP one had lost the explicit-location fallback and had nested the pre-hire bypass under an extra truthiness check — which meant the same call was authorized differently depending on which surface it arrived through. The gate now lives in ``app.domains.asset.region_gates``. """ import uuid as _uuid # noqa: PLC0415 from app.domains.asset.region_gates import ( # noqa: PLC0415 assert_target_person_in_region, ) tpid = ( target_person_id if isinstance(target_person_id, _uuid.UUID) else _uuid.UUID(str(target_person_id)) ) await assert_target_person_in_region(rt.db, rt.user, tpid) async def _mcp_assert_target_office_in_region(rt, target_office_id) -> None: """Apply the HTTP route's target-office region gate, unchanged.""" import uuid as _uuid # noqa: PLC0415 from app.domains.asset.region_gates import ( # noqa: PLC0415 assert_target_office_in_region, ) toid = ( target_office_id if isinstance(target_office_id, _uuid.UUID) else _uuid.UUID(str(target_office_id)) ) await assert_target_office_in_region(rt.db, rt.user, toid) def _require_global_admin(role_names) -> None: """Match the HTTP route's ``Depends(require_admin())`` gate. Used by tools whose HTTP twin is strictly global-admin (compliance incidents, etc.). Mirroring the exact gate keeps MCP from leaking data the HTTP layer refuses to serve to auditors / managers. """ from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role if not has_role(role_names or [], GLOBAL_ADMIN_ROLE): raise ForbiddenError( "This action is restricted to global admins.", ) def _require_admin_read(role_names) -> None: """Match the HTTP route's ``Depends(require_admin_read())`` gate. Admits global_admin + auditor + management (read-only governance surface). Used by MCP tools whose HTTP twin is ``require_admin_read`` — e.g. the procurement read tools. ``tool_runtime`` only enforces active-employee status, so without this an ordinary employee (who can self-mint a PAT) could read admin-only data via MCP that the HTTP layer refuses them. See `feedback_mcp_sync_rule`. """ from app.core.exceptions import ForbiddenError from app.core.rbac import ( AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role, ) if not has_role( role_names or [], GLOBAL_ADMIN_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError( "This action is restricted to admins / auditors / management.", ) def _require_assistant_eval_read(role_names) -> None: """Read gate for the assistant evaluation console. Deliberately NARROWER than :func:`_require_admin_read`: it excludes ``management``, mirroring the HTTP router's own gate and the frontend's ``canReadAssistantTranscripts``. Evaluation rows carry full assistant replies produced under a simulated employee identity, which is the same class of content as a chat transcript — and the transcript surface has excluded the leadership read-only tier since it shipped. An MCP tool that admitted management here would be the way around that decision. """ from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role if not has_role(role_names or [], GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError( "This action is restricted to global admins / auditors.", ) def _require_security_read(role_names) -> None: """Read gate for the security/auditor surface (周期性自我检查 等). Mirrors ``can_view_security`` exactly (auditor + global_admin + management — the read-only leadership view), so MCP never widens or narrows the audience the HTTP routes scope. The write counterpart is ``_require_security_manage`` (defined below, shared with the compliance tools). """ from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_security if not can_view_security(role_names or []): raise ForbiddenError( "This action is restricted to auditors / global admins.", ) def _require_software_read(role_names) -> None: """Match the HTTP route's ``Depends(require_software_read())`` gate. Mirrors :func:`app.core.dependencies.require_software_read` — the READ-only software / procurement / supplier / billing surface: global_admin + asset_admin + finance + auditor + management. Used by the procurement read tools and software-billing read tools whose HTTP twins are ``require_software_read``. The narrower ``_require_admin_read`` (global_admin / auditor / management only) wrongly 403'd finance + asset_admin — who OWN procurement / billing data — on the MCP surface. See ``feedback_mcp_sync_rule``. """ from app.core.exceptions import ForbiddenError from app.core.rbac import ( ASSET_ADMIN_ROLE, AUDITOR_ROLE, FINANCE_ROLE, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role, ) if not has_role( role_names or [], GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE, FINANCE_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError( "This action is restricted to admins / finance / auditors / " "management.", ) def _require_hr_read(role_names) -> None: """Match the HTTP route's ``Depends(require_hr_read())`` gate. Mirrors :func:`app.core.rbac.can_view_hr_data` — global_admin + hr + auditor + management. Used by the identity golden-record tool whose HTTP twin is ``require_hr_read``; without it an ordinary employee (who can self-mint a PAT) could read another person's login bindings + projection state via MCP that the HTTP layer refuses them. See ``feedback_mcp_sync_rule``. """ from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_hr_data if not can_view_hr_data(role_names or []): raise ForbiddenError( "This action is restricted to admins / HR / auditors / " "management.", ) def _require_hr_admin(role_names) -> None: """Match the HTTP route's ``Depends(require_hr_admin())`` gate — global_admin + hr. Used by HR write tools (e.g. the single-person HRIS refresh) so an ordinary employee who self-mints a PAT can't trigger an SF pull + downstream push the HTTP layer refuses them. """ from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, HR_ROLE, has_role if not ( has_role(role_names or [], GLOBAL_ADMIN_ROLE) or has_role(role_names or [], HR_ROLE) ): raise ForbiddenError( "This action is restricted to IT (global) admins and HR.", ) def _require_ai_spending_view(rt) -> None: """Match the HTTP gate enforced by the AI-spending router. The dashboard is restricted to IT admin / finance / auditor. Mirroring it here keeps MCP tools from leaking aggregated AI-cost data to roles the HTTP surface refuses. """ from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_ai_spending if not can_view_ai_spending(rt.user.role_names): raise ForbiddenError( "AI-spending data is restricted to IT admin, finance, or auditor.", ) def _bound_free_text( value: str | None, *, field: str, max_len: int = 4000, ) -> str | None: """Enforce a hard cap on caller-supplied free text at the MCP boundary. The audit-args redactor caps strings at 500 chars for the audit row, but downstream services often write the same value verbatim into a TEXT column (asset note, transfer reason, scope confirmation reason). Without a tool-boundary cap an LLM client can stuff a 1MB free-text payload into the DB. Pydantic schemas at the HTTP layer enforce these caps; MCP tools that bypass Pydantic and call the service directly must replicate the cap here. Returns the original value when ``None`` or within the cap; raises typed VALIDATION_ERROR otherwise. """ if value is None: return None if len(value) > max_len: from app.core.exceptions import ValidationError raise ValidationError( f"{field} must be ≤ {max_len} characters (got {len(value)}).", ) return value def _clamp_pagination( page: int, page_size: int, *, max_size: int = 100, ) -> tuple[int, int]: """Bound MCP-supplied pagination args at the tool boundary. The HTTP routes lean on FastAPI's ``Query(..., ge=1, le=100)`` for bounds, but MCP tools take Python-typed args directly — so an LLM client could pass ``page_size=10_000_000`` and pull the entire table in one shot. We clamp every list tool's pagination to a sane upper bound; pagination beyond that requires multiple calls. The same bound is enforced by the equivalent HTTP endpoint. """ if page is None or page < 1: page = 1 if page_size is None or page_size < 1: page_size = 1 if page_size > max_size: page_size = max_size return page, page_size def _enum_guard(field: str, value: str, allowed: tuple[str, ...]) -> str: """Reject an out-of-vocabulary enum arg with a typed VALIDATION_ERROR. The service layers deliberately fall back to a permissive default for an unrecognised filter value (``kind="statik"`` → "all"), which is right for a browser but wrong for a model: the tool would answer a DIFFERENT question than the one asked and the caller would have no way to notice. Fail loudly at the boundary instead, naming the allowed values so the retry is obvious. """ from app.core.exceptions import ValidationError if value not in allowed: raise ValidationError( f"{field} must be one of {', '.join(allowed)}, got " f"{_short_repr(value)}", ) return value def _day_start(value: str | None): """``YYYY-MM-DD`` → UTC-aware datetime at 00:00:00, or None.""" if not value: return None from datetime import UTC from datetime import datetime as _dt from datetime import time as _t return _dt.combine(_parse_iso_date(value, field="date_from"), _t.min, tzinfo=UTC) def _day_end(value: str | None): """``YYYY-MM-DD`` → UTC-aware datetime at 23:59:59.999999, or None. End-of-day so a ``date_from == date_to`` range covers that whole calendar day rather than only its first instant. """ if not value: return None from datetime import UTC from datetime import datetime as _dt from datetime import time as _t return _dt.combine(_parse_iso_date(value, field="date_to"), _t.max, tzinfo=UTC) # ════════════════════════════════════════════════════════════════════ # Profile / identity # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="whoami", description=( "Return who the caller is, with the profile fields employees actually " "ask about: name, email, employee number, GitHub id, department, job " "title, employment status, roles, work location, legal entity, " "cost centre, hire date and existing email aliases — plus their " "MANAGER BY NAME (`manager_name` / `manager_email` / " "`manager_job_title`) and how many direct reports and peers they have. " "Use this as your first call so subsequent tools can be tailored to the " "user (e.g. only call list_team_assets if `is_manager` is true), and " "answer 「我的工号/GitHub/办公地是什么」「我的经理是谁」「我在哪个部门」" "「我什么时候入职的」 from here rather than saying you do not have it." ), ) async def whoami() -> dict: async with tool_runtime("whoami", {}) as rt: from app.auth.service import AuthService from app.domains.me_universe.team_summary import build_my_team_summary is_manager = await AuthService(rt.db).has_subordinates(rt.user.id) # 「我的经理是谁」 used to be answerable only as a UUID: this tool # promised "manager" and returned ``manager_person_id``, and the ONLY # tool that resolves a person id is HR-gated — so for an ordinary # employee the answer was a machine value or an invention. The same # self-scoped primitive the /me banner uses resolves it, including its # rule that a departed manager is not named (a stale pointer at an # offboarded lead should not surface a ghost). team = await build_my_team_summary(rt.db, rt.user) manager_card = team.manager or {} manager_email = None if manager_card.get("id"): from app.identity.person_models import IdentityPerson manager_row = await rt.db.get( IdentityPerson, _u(str(manager_card["id"])), ) manager_email = manager_row.work_email if manager_row else None return { "id": str(rt.user.id), "email": rt.user.work_email, "display_name": rt.user.display_name, "department": rt.user.department, "job_title": rt.user.job_title, "employment_status": rt.user.employment_status, "roles": list(rt.user.role_names), "manager_person_id": ( str(rt.user.manager_person_id) if rt.user.manager_person_id else None ), "manager_name": manager_card.get("display_name"), "manager_email": manager_email, "manager_job_title": manager_card.get("job_title"), "is_manager": is_manager, "direct_report_count": team.direct_report_count, "peer_count": team.peer_count, # Surface enough context for the LLM client to localise its # responses without a second `get_my_profile` round-trip. # Direct attribute access (not getattr-with-default) so a # future column rename fails loud instead of silently # returning None — see feedback_no_getattr_default_for_model_columns. "preferred_language": rt.user.preferred_language, "avatar_url": rt.user.avatar_url, # Asked for their GitHub id, the assistant answered "not in the # identity fields" and sent the employee to a settings page that # does not carry it — while ``github_id`` sat on this very row. # These are the self-service facts people ask an IT assistant for; # omitting them made a populated column look like missing data. # Personal contact details (mobile, personal email) stay out on # purpose: they are not what anyone asks IT, and every answer here # lands in a stored, administrator-readable transcript. "github_id": rt.user.github_id, "employee_no": rt.user.employee_no, "hire_date": ( rt.user.hire_date.isoformat() if rt.user.hire_date else None ), "work_location_name": rt.user.work_location_name, "city": rt.user.city, "country": rt.user.country, "company_name": rt.user.company_name, "cost_center": rt.user.cost_center, "email_aliases": list(rt.user.email_aliases or []), } @mcp_server.tool( name="cli_get_download_manifest", description=( "List the official `helix` CLI artifacts available for download: " "the pip/pipx wheel plus per-OS single-file binaries (macOS arm64/" "Intel, Linux x86_64/arm64, Windows x86_64), with version, filename, " "size and sha256 per platform. Returns available=false when no " "release has been published yet. Use this to tell the user which " "build matches their OS and where to get it — the Helix web UI " "(Help → AI 助手集成 → helix CLI) also offers one-click download. " "Read-only; does not download the binary itself." ), ) async def cli_get_download_manifest() -> dict: async with tool_runtime("cli_get_download_manifest", {}) as rt: from app.domains.cli_dist import service as cli_dist_service return await cli_dist_service.get_manifest(rt.db) @mcp_server.tool( name="plugin_get_download_manifest", description=( "Describe the official Helix Claude Code plugin release: version, " "marketplace-zip filename, size and sha256. The plugin bundles the " "Helix MCP connection (OAuth, no token pasting), skills and slash " "commands for Claude Code users. Returns available=false when no " "release has been published yet. Install paths: `helix plugin " "install` (CLI, recommended) or the download card in the Helix web " "UI (Help → AI 助手集成 → Claude Code 插件). Read-only; does not " "download the zip itself." ), ) async def plugin_get_download_manifest() -> dict: async with tool_runtime("plugin_get_download_manifest", {}) as rt: from app.domains.cli_dist import service as cli_dist_service return await cli_dist_service.get_plugin_manifest(rt.db) @mcp_server.tool( name="get_my_profile", description=( "Return the user's full profile, including masked contact info " "(mobile_phone_masked / personal_email_masked). Carries their " "MANAGER and their SUBORDINATES by name and email, so 「我的下属" "有谁」「我的团队里有哪些人」「我的经理是谁」 are answered here (for " "the caller's own manager alone, `whoami` already carries the name). " "Also termination_date for a leaver. To " "reveal the unmasked phone / personal email use reveal_my_contact." ), ) async def get_my_profile() -> dict: async with tool_runtime("get_my_profile", {}) as rt: from app.auth.service import AuthService user = await AuthService(rt.db).get_user_detail(rt.user.id) if user is None: # Should be unreachable: ``tool_runtime`` already loaded # the same row before yielding. If the row vanished between # those two queries (deletion mid-request), surface a # specific error instead of an empty dict so the audit # trail records the unusual state. from app.core.exceptions import NotFoundError raise NotFoundError("User", str(rt.user.id)) def mask_email(email: str | None) -> str | None: if not email or "@" not in email: return email local, domain = email.split("@", 1) if len(local) <= 2: return f"{local[0]}*@{domain}" return f"{local[0]}{'*' * (len(local) - 2)}{local[-1]}@{domain}" def mask_phone(phone: str | None) -> str | None: if not phone: return None if len(phone) < 7: return "*" * len(phone) return phone[:3] + "*" * (len(phone) - 6) + phone[-3:] return { "id": str(user.id), "email": user.work_email, "display_name": user.display_name, "employee_no": user.employee_no, "department": user.department, "job_title": user.job_title, "employment_status": user.employment_status, "hire_date": user.hire_date.isoformat() if user.hire_date else None, "termination_date": ( user.termination_date.isoformat() if user.termination_date else None ), "roles": list(user.role_names), # GitHub handle from HRIS (SF cust_Other.cust_id, migration 369). # Non-sensitive; sparse (only staff with one on file in SF). "github_id": user.github_id, "mobile_phone_masked": mask_phone(user.mobile_phone), "personal_email_masked": mask_email(user.personal_email), "has_mobile_phone": bool(user.mobile_phone), "has_personal_email": bool(user.personal_email), "manager": ( { "id": str(user.manager.id), "display_name": user.manager.display_name, "email": user.manager.work_email, } if user.manager else None ), "subordinates": [ { "id": str(s.id), "display_name": s.display_name, "email": s.work_email, } for s in (user.subordinates or []) ], } @mcp_server.tool( name="reveal_my_contact", description=( "Return the user's UNMASKED mobile_phone and personal_email. " "This is identical to clicking 'reveal' on /my/profile in the " "web UI — the action is recorded in the audit log." ), ) async def reveal_my_contact() -> dict: async with tool_runtime("reveal_my_contact", {}, mutates=True) as rt: # Step-up parity with the REST twin /auth/me/reveal-contact, which is # gated by ``require_browser_session`` (auth_via == 'jwt'). Its threat- # model comment is explicit: "a stolen PAT can pull the owner's # mobile_phone + personal_email — the very fields the dual-tier mask # exists to gate." MCP authenticates ONLY via PAT / MCP-OAuth — never a # Google-SSO browser session (auth_method is 'pat'/'oauth', never # 'jwt') — so a stolen PAT could otherwise exfiltrate the owner's # unmasked contact PII here while the REST route refuses it. Enforce the # same policy: contact-PII reveal is browser-session only. (The sibling # reveal_api_key allows owner-self over PAT, but the REST contact # endpoint deliberately does NOT — we follow the stricter documented # contact-PII policy rather than widen the blast radius.) from app.audit.service import AuditService from app.auth.auth_context import current_auth_method from app.core.dependencies import apply_sensitive_rate_limit from app.core.exceptions import ForbiddenError, NotFoundError from app.identity.person_models import IdentityPerson if current_auth_method.get() != "jwt": raise ForbiddenError( "Revealing your unmasked contact details requires a Google " "SSO browser session — open /my/profile in the web UI. " "Personal access tokens / MCP clients cannot reveal PII: a " "stolen token must not be able to exfiltrate your mobile " "phone and personal email.", ) # Same per-user cap the HTTP route enforces. await apply_sensitive_rate_limit(rt.user.id) person = await rt.db.get(IdentityPerson, rt.user.id) if person is None or person.deleted_at is not None: raise NotFoundError("User", str(rt.user.id)) await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="identity.person.contact_reveal_self", resource_type="identity_person", resource_id=str(rt.user.id), status="success", detail={"via": "mcp"}, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return { "mobile_phone": person.mobile_phone, "personal_email": person.personal_email, } @mcp_server.tool( name="search_directory_people", description=( "Look one colleague up by NAME or email — 「张三是谁」「XX 的邮箱是" "什么」「XX 在哪个部门」 — and resolve them to the " "IdentityPerson UUID that every ``*_person_id`` argument on this " "surface expects — successor_person_id, recipient_person_id, " "new_owner_person_id, owner_person_id, … Without this tool a " "handover / transfer cannot be completed from MCP at all, " "because no other tool turns a human name into a person UUID. " "Matching spans display_name, work_email, and pinyin (full + " "initials), so '孟' / 'meng' / 'MQ' / a partial address all " "resolve. Returns id, display_name, work_email, department and " "employment_status for up to ``limit`` (max 30) matches, ordered " "by display_name.\n\n" "Mirrors HTTP `GET /api/v1/auth/directory/people/lite` — the " "self-service people picker behind every /me transfer modal — " "and carries the SAME gate (any active employee) and the SAME " "anti-scrape safeguards: ``q`` is MANDATORY (blank returns an " "empty list; there is no full-roster dump), there is no " "pagination, only active / leaver_pending people whose HRIS " "record is still present are visible, and the shared " "600-calls/hour per-user directory cap applies. Read-only.\n\n" "Deliberately NARROWER than the admin people list: no " "employee_no, no roles, no manager, no last_login_at. For the " "HR-gated view of one person use get_person_identity_profile; " "for the 工号 ledger use list_employee_numbers." ), ) async def search_directory_people(q: str, limit: int = 20) -> dict: async with tool_runtime( "search_directory_people", {"q": q, "limit": limit}, ) as rt: from sqlalchemy import or_, select from sqlalchemy.orm import selectinload from app.core.dependencies import apply_directory_read_rate_limit from app.identity.person_models import IdentityPerson # Same per-user cap the HTTP twin gets from # ``Depends(check_directory_read_rate_limit)``. Without it the MCP # path would be a WEAKER surface than the browser one: a PAT is a # headless, long-lived credential, and an unthrottled name→UUID # resolver is exactly the scripted org-chart enumeration the HTTP # limiter exists to stop. await apply_directory_read_rate_limit(rt.user.id) keyword = (_bound_free_text(q, field="q", max_len=200) or "").strip() if not keyword: # No blank scrape — mirrors the HTTP endpoint's empty-q return. return {"items": [], "count": 0} limit = max(1, min(int(limit), 30)) like = f"%{keyword}%" stmt = ( select(IdentityPerson) .options(selectinload(IdentityPerson.org_memberships)) .where( IdentityPerson.deleted_at.is_(None), IdentityPerson.hris_record_state == "present", IdentityPerson.employment_status.in_( ["active", "leaver_pending"], ), or_( IdentityPerson.display_name.ilike(like), IdentityPerson.work_email.ilike(like), IdentityPerson.name_pinyin.ilike(like), IdentityPerson.name_pinyin_abbr.ilike(like), ), ) .order_by(IdentityPerson.display_name) .limit(limit) ) rows = (await rt.db.execute(stmt)).scalars().all() items = [ { "id": str(p.id), "display_name": p.display_name, "work_email": p.work_email, "department": p.primary_department, "employment_status": p.employment_status, } for p in rows ] return {"items": items, "count": len(items)} def _require_directory_read(role_names) -> None: """Match the HTTP route's ``Depends(require_directory_read())`` gate. Mirrors :func:`app.core.rbac.can_view_directory` — every admin-tier role. """ from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_directory if not can_view_directory(role_names): raise ForbiddenError( "Directory read requires an admin-tier role. An employee looking " "up one colleague should use search_directory_people instead." ) @mcp_server.tool( name="list_directory_people", description=( "The people roster, filtered and COUNTED — who is in a department, " "and how many. Answers 「Treasury and Tax 有多少人」「这个部门有哪些" "人」「谁在 IT 团队」: pass `department` (exact org-node name, from " "list_departments) and read `total` for the headcount, `items` for " "the roster. `search` matches name / email, `status` narrows to " "active vs leaver, `role` to holders of one platform role. Each row " "is {id, display_name, work_email, employee_no, department, " "employment_status}. Requires an admin-tier role (global_admin, hr, " "asset_admin, finance, cloud_admin, auditor, management) — the same " "gate as HTTP GET /api/v1/directory/people, which this mirrors. An " "employee resolving ONE colleague by name uses " "search_directory_people (no role needed); the HR governance view of " "one person is get_person_identity_profile. Bounded pagination — max " "200 rows." ), ) async def list_directory_people( search: str | None = None, department: str | None = None, status: str | None = None, role: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_directory_people", { "search": search, "department": department, "status": status, "role": role, "page": page, "page_size": page_size, }, ) as rt: _require_directory_read(rt.user.role_names) # Same per-user cap the HTTP twin carries. A headless credential # walking the roster page by page is exactly what it exists to bound. from app.core.dependencies import apply_directory_read_rate_limit await apply_directory_read_rate_limit(rt.user.id) from app.auth.service import AuthService from app.core.exceptions import ValidationError from app.core.rbac import CANONICAL_ROLE_NAMES, canonicalize_role_name if role is not None: # Rejected at the boundary rather than silently returning nothing — # an empty roster reads as "that department has nobody in it". if canonicalize_role_name(role) not in CANONICAL_ROLE_NAMES: raise ValidationError( f"unknown role filter: {role!r}. " f"Allowed: {sorted(CANONICAL_ROLE_NAMES)}" ) users, total = await AuthService(rt.db).list_users_filtered( page, page_size, _bound_free_text(search, field="search", max_len=200), _bound_free_text(department, field="department", max_len=200), status, role, include_purged=False, ) return { "items": [ { "id": str(u.id), "display_name": u.display_name, "work_email": u.work_email, "employee_no": u.employee_no, "department": u.department, "employment_status": u.employment_status, } for u in users ], # The headcount answer. ``total`` is the count for the FILTER, not # the page — that is what 「这个部门有多少人」 asks for. "total": total, "page": page, "page_size": page_size, "filters": { "search": search, "department": department, "status": status, "role": role, }, } @mcp_server.tool( name="list_departments", description=( "The company's department / org structure — 有哪些部门, 部门层级, " "每个部门多少人. Returns one row per department org node with its " "name, parent, full org path (breadcrumb), level and `member_count` " "(direct primary members, not rolled up). Use it to find the exact " "department NAME that list_directory_people, search_assets and the " "cost tools expect, and to answer 「XX 部门下面有哪些团队」. Pass " "`search` to filter by name or path. Requires an admin-tier role — " "the same gate as HTTP GET /api/v1/auth/departments/tree, which this " "mirrors. For the top-level BU list every employee may see, use " "list_business_units. Read-only." ), ) async def list_departments( search: str | None = None, page: int = 1, page_size: int = 200, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=500) async with tool_runtime( "list_departments", {"search": search, "page": page, "page_size": page_size}, ) as rt: _require_directory_read(rt.user.role_names) from app.auth.service import AuthService rows = await AuthService(rt.db).get_department_tree() needle = (_bound_free_text(search, field="search", max_len=200) or "").strip().lower() if needle: rows = [ r for r in rows if needle in str(r.get("name") or "").lower() or needle in str(r.get("org_path") or "").lower() ] total = len(rows) window = rows[(page - 1) * page_size:page * page_size] return { "items": [ { "source_id": r.get("source_id"), "name": r.get("name"), "parent_source_id": r.get("parent_source_id"), "org_path": r.get("org_path"), "level": r.get("level"), "member_count": r.get("count"), } for r in window ], "total": total, "page": page, "page_size": page_size, "search": search, } @mcp_server.tool( name="get_person_identity_profile", description=( "Return the IDENTITY GOLDEN RECORD for one person by id: the " "person summary, the upstream HRIS source block, every login " "binding (Google / Feishu — with its kill-switch + suspend " "state), the downstream PROJECTION MATRIX (each sync target the " "person is projected into, with last status / external id / " "error / drift = does the live payload still match what we last " "pushed), signal-derived attribute provenance, and the recent " "identity-change timeline. This is the admin Identity-Hub view of " "one person — restricted to global_admin / HR / auditor / " "management, the same gate as GET /users/{id}. Read-only; pass " "drift=false to skip the per-target live render." ), ) async def get_person_identity_profile( person_id: str, drift: bool = True, ) -> dict: async with tool_runtime( "get_person_identity_profile", {"person_id": person_id, "drift": drift}, ) as rt: _require_hr_read(rt.user.role_names) from app.identity.profile_service import IdentityProfileService return await IdentityProfileService(rt.db).get_identity_profile( _u(person_id), include_drift=drift, ) @mcp_server.tool( name="preview_identity_sync_mapping", description=( "Render the outbound payload ONE person would be pushed as to " "ONE identity-sync target, using the target's saved mapping " "config — WITHOUT pushing. Returns the rendered payload, its " "hash, the per-rule trace (source template → transform → " "value_map → final value, with dropped/used_default flags), and " "any warnings. This is the MCP twin of the admin 'Mapping " "Preview' drawer — use it to debug why a downstream field is " "empty/wrong before running a sync. Restricted to global_admin." ), ) async def preview_identity_sync_mapping( target_id: str, person_id: str, ) -> dict: async with tool_runtime( "preview_identity_sync_mapping", {"target_id": target_id, "person_id": person_id}, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) target = await svc.get_target(_u(target_id)) result = await svc.preview_payload( person_id=_u(person_id), mapping_config=target.mapping_config or {}, runner_options=target.runner_options or {}, ) # preview_payload returns person_id as a UUID (HTTP serializes it # via the envelope); MCP returns the dict raw, so stringify it # (None stays None rather than the string "None"). pid = result.get("person_id") result["person_id"] = str(pid) if pid is not None else None # Mask PII (mobile_phone, id numbers) in the embedded person context # + rendered_payload before returning — same contract as the HTTP # preview twin and the sibling identity-sync MCP tools; a stolen # read-scoped admin PAT must not scrape plaintext PII here. return _mask_idsync(result) @mcp_server.tool( name="list_identity_data_quality_findings", description=( "List open identity DATA-QUALITY findings from the continuous " "reconciliation scan: departed/deleted people whose login binding " "still works (orphan_login_identity), HRIS-purged people who can " "still log in (stale_tombstone), active people whose manager has " "left (manager_departed) or who have no manager (missing_manager), " "people stuck failing downstream sync (sync_drift_failed), and " "people whose HR-assigned email name in SuccessFactors is not an " "email name (hris_email_name_malformed). " "Each finding has a severity + a person_id you can feed to " "get_person_identity_profile. Optional filters: status " "(open/acknowledged/accepted_risk/resolved), finding_type, " "severity. Read-only; restricted to global_admin / auditor / " "management." ), ) async def list_identity_data_quality_findings( status: str | None = None, finding_type: str | None = None, severity: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_identity_data_quality_findings", { "status": status, "finding_type": finding_type, "severity": severity, "page": page, "page_size": page_size, }, ) as rt: _require_admin_read(rt.user.role_names) from app.identity.data_quality_service import IdentityDataQualityService return await IdentityDataQualityService(rt.db).list_findings( status=status, finding_type=finding_type, severity=severity, page=page, page_size=page_size, ) @mcp_server.tool( name="get_identity_hub_overview", description=( "One-call snapshot of the identity platform: headcount by " "lifecycle state (active / leaver_pending / left / pre_hire), " "login bindings by provider (+ how many are login-disabled / " "suspended), every downstream sync target's health (enabled, " "last-run status, next run), the most recent sync runs, and the " "data-quality posture (open findings / open high-severity / by " "type). Read-only; restricted to global_admin / HR / auditor / " "management (same gate as the /admin/identity hub page)." ), ) async def get_identity_hub_overview() -> dict: async with tool_runtime("get_identity_hub_overview", {}) as rt: _require_hr_read(rt.user.role_names) from app.identity.hub_service import IdentityHubService return await IdentityHubService(rt.db).overview() @mcp_server.tool( name="get_identity_field_catalog", description=( "Return the read-only identity FIELD DICTIONARY: every user and " "department attribute with its key, type, source (hris / " "hr_managed / lifecycle / sso / system), whether it's shown in " "the admin console detail, whether it's shown in the user's " "self-service profile, and whether the user can edit it. The " "field list is read straight off the schema so it's always " "complete. Read-only; restricted to global_admin / HR / auditor " "/ management." ), ) async def get_identity_field_catalog() -> dict: async with tool_runtime("get_identity_field_catalog", {}) as rt: _require_hr_read(rt.user.role_names) from app.identity.field_catalog import build_field_catalog return build_field_catalog() @mcp_server.tool( name="list_identity_consumers", description=( "List registered IDENTITY CONSUMERS — external systems holding " "helix_svc_ service tokens that PULL people/org data from the " "outbound Directory API / SCIM surface. Returns per consumer: " "slug, status (active/disabled), granted scopes, owner, active " "token count and last-used timestamp. Read-only; admin-tier " "(global_admin / auditor / management)." ), ) async def list_identity_consumers() -> list[dict]: async with tool_runtime("list_identity_consumers", {}) as rt: _require_admin_read(rt.user.role_names) from sqlalchemy import select as _select from app.identity.consumers.models import IdentityConsumer from app.identity.consumers.token_service import token_is_active rows = list((await rt.db.execute( _select(IdentityConsumer) .where(IdentityConsumer.deleted_at.is_(None)) .order_by(IdentityConsumer.created_at.desc()) )).scalars().all()) out = [] for c in rows: used = [t.last_used_at for t in c.tokens if t.last_used_at] out.append({ "id": str(c.id), "name": c.name, "slug": c.slug, "status": c.status, "scopes": list(c.scopes or []), "owner_person_id": str(c.owner_person_id), "rate_limit_per_minute": c.rate_limit_per_minute, "active_token_count": sum( 1 for t in c.tokens if token_is_active(t) ), "last_used_at": max(used).isoformat() if used else None, "created_at": c.created_at.isoformat() if c.created_at else None, }) return out @mcp_server.tool( name="get_identity_consumer", description=( "Get one identity consumer's full governance detail: scopes, " "person filter_config, rate limit, every token (prefix only — " "plaintext is never retrievable), and the last-30-days request " "counts. Accepts the consumer UUID or its slug. Read-only; " "admin-tier (global_admin / auditor / management)." ), ) async def get_identity_consumer(consumer_id_or_slug: str) -> dict: async with tool_runtime( "get_identity_consumer", {"consumer_id_or_slug": consumer_id_or_slug}, ) as rt: _require_admin_read(rt.user.role_names) import uuid as _uuid from datetime import UTC as _UTC from datetime import datetime as _dt from datetime import timedelta as _td from sqlalchemy import select as _select from app.core.exceptions import NotFoundError from app.identity.consumers.models import ( IdentityConsumer, IdentityConsumerUsageDaily, ) from app.identity.consumers.token_service import token_is_active try: key = str(_uuid.UUID(consumer_id_or_slug)) cond = IdentityConsumer.id == key except (ValueError, AttributeError): cond = IdentityConsumer.slug == consumer_id_or_slug.strip().lower() row = (await rt.db.execute( _select(IdentityConsumer).where( cond, IdentityConsumer.deleted_at.is_(None), ) )).scalar_one_or_none() if row is None: raise NotFoundError("IdentityConsumer", consumer_id_or_slug) since = (_dt.now(_UTC) - _td(days=30)).date() usage = list((await rt.db.execute( _select(IdentityConsumerUsageDaily) .where( IdentityConsumerUsageDaily.consumer_id == row.id, IdentityConsumerUsageDaily.usage_date >= since, ) .order_by(IdentityConsumerUsageDaily.usage_date.asc()) )).scalars().all()) return { "id": str(row.id), "name": row.name, "slug": row.slug, "description": row.description, "status": row.status, "scopes": list(row.scopes or []), "granted_fields": list(row.granted_fields or []), "filter_config": dict(row.filter_config or {}), "rate_limit_per_minute": row.rate_limit_per_minute, "owner_person_id": str(row.owner_person_id), "tokens": [ { "id": str(t.id), "name": t.name, "token_prefix": t.token_prefix, "is_active": token_is_active(t), "expires_at": t.expires_at.isoformat() if t.expires_at else None, "revoked_at": t.revoked_at.isoformat() if t.revoked_at else None, "last_used_at": ( t.last_used_at.isoformat() if t.last_used_at else None ), } for t in row.tokens ], "requests_30d": sum(u.request_count for u in usage), "usage_recent_days": [ {"date": u.usage_date.isoformat(), "count": u.request_count} for u in usage ], } # ════════════════════════════════════════════════════════════════════ # Hardware assets # ════════════════════════════════════════════════════════════════════ def _serialize_asset(asset) -> dict: return { "id": str(asset.id), "asset_tag": asset.asset_tag, "asset_name": asset.asset_name, "category": asset.category, "status": asset.status, "brand": asset.brand, "model": asset.model, "serial_number": asset.serial_number, # Round 4 audit fix-up 2026-05-04: ``assigned_to`` is the # ORIGINAL OWNER (post-115). For status='borrowed' the # current physical holder is in ``borrowed_by``. Without # surfacing ``borrowed_by`` here, MCP clients (LLMs) tell the # user "your laptop is currently with X" pointing at the # owner, not the borrower — same lying-API bug the REST # response fixed in Round 2c. Per CLAUDE.md MCP-sync rule, # MCP must mirror the REST shape. "assigned_to": str(asset.assigned_to) if asset.assigned_to else None, "assigned_at": ( _iso_utc(asset.assigned_at) if asset.assigned_at else None ), "borrowed_by": str(asset.borrowed_by) if asset.borrowed_by else None, "borrow_started_at": ( _iso_utc(asset.borrow_started_at) if asset.borrow_started_at else None ), # Office-held mode (migration 183, 2026-05-19). Surfacing # ``holder_kind`` lets LLM clients distinguish "personal device # to claim" from "office-held shared kit" so suggestions like # 'transfer this asset to X' don't accidentally apply to a # meeting-room TV. Direct attribute access (NOT getattr-default) # per ``feedback_no_getattr_default_for_model_columns`` — a # silent "personal" fallback would mask a future model-column # rename and lie to every LLM client. "holder_kind": asset.holder_kind, "holder_office_id": ( str(asset.holder_office_id) if asset.holder_office_id is not None else None ), "location": asset.location, "usage_company": asset.usage_company, "expected_return_date": ( asset.expected_return_date.isoformat() if asset.expected_return_date else None ), # 「我的电脑保修到什么时候」 is one of the questions employees actually # ask, and both dates are already on their own device card in the H5 # 我的资产 view — leaving them out of the tool meant the assistant # listed the laptop and then could not say the one thing asked about # it. Same shape of gap as the software catalogue's missing 负责人. "purchase_date": ( asset.purchase_date.isoformat() if asset.purchase_date else None ), "warranty_until": ( asset.warranty_until.isoformat() if asset.warranty_until else None ), } @mcp_server.tool( name="list_my_assets", description=( "List the hardware assets currently assigned to the user " "(laptops, monitors, peripherals, etc.) — 我的设备/电脑/显示器. Each " "row carries asset tag, name, category, brand, model, serial " "number, location, when it was assigned, and its purchase date + " "warranty expiry (保修到期), so 「我有几台电脑」「我的电脑保修到什么" "时候」「序列号是多少」 are all answered from here. Borrowed assets " "are included with status='borrowed'. Bounded pagination — max 200 " "rows per call. Use page+page_size to walk through if you have " "more than 200 assets (rare for an individual)." ), ) async def list_my_assets(page: int = 1, page_size: int = 200) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_assets", {"page": page, "page_size": page_size}, ) as rt: from app.domains.asset.service import AssetService rows = await AssetService(rt.db).get_user_assets(rt.user.id) total = len(rows) offset = (page - 1) * page_size return { "assets": [ _serialize_asset(a) for a in rows[offset:offset + page_size] ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="mobile_action_inbox", description=( "The unified action inbox: every 'waiting for me to act on it' " "item across domains, as one prioritised list. Self-scope for " "every caller: incoming asset transfers, provisioning tasks " "pending my action, month-end close tasks I own, an open access " "self-attestation I still have undecided items in, and help-desk " "tickets I raised that are waiting on my reply. Role-scoped: an " "auditor also sees open security findings; a global_admin also " "sees help-desk tickets assigned to them; a leaver-blocked admin " "also sees pending offboarding blockers. Company-wide approval " "queues are NOT here — those are an observation surface (the admin " "approvals overview), not a personal to-do. Each item has a type, " "title, severity (urgent/warning/info), a deep_link to the page " "that acts on it, and optional inline actions. Read-only; mirrors " "the H5 GET /m/aggregate/inbox the Feishu mobile workspace shows. " "Use this to answer 'what do I need to do?'. Optional comma-" "separated `types` filter; bounded pagination (max 100 rows)." ), ) async def mobile_action_inbox( types: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "mobile_action_inbox", {"types": types, "page": page, "page_size": page_size}, ) as rt: from app.mobile.action_inbox import build_action_inbox wanted = [t.strip() for t in types.split(",") if t.strip()] if types else None payload = await build_action_inbox( rt.db, person_id=rt.user.id, role_names=list(rt.user.role_names), types=wanted, page=page, page_size=page_size, ) return payload.model_dump() @mcp_server.tool( name="list_my_requests", description=( "The unified '我的申请' tracker: every request the CALLER submitted " "across all domains (asset/software/api-key/Google employee " "requests, cloud scope-requests, DNS record requests, VPN " "applications, AI-tool applications, announcement requests, domain " "registrations, API-key release requests), as one list with a " "normalised lifecycle status. This is the complement to " "mobile_action_inbox (which shows what I must ACT on); this shows " "what I INITIATED and want to TRACK — use it to answer 'where is my " "laptop request / did my cloud-scope request get approved?'. Each " "item carries source, category, normalised + raw status, created/" "updated timestamps, a deep_link to the owning page, and (for " "still-cancellable pending items) a cancel_path. Read-only. " "Optional `status` (pending/in_progress/completed/rejected/" "cancelled/revoked/failed, or 'open' = pending+in_progress) and " "`category` (hardware/software/email/network/cloud/ai/general) " "filters; `q` is a case-insensitive substring search over title / " "subtitle / request type; bounded pagination (max 100 rows)." ), ) async def list_my_requests( status: str | None = None, category: str | None = None, q: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_requests", { "status": status, "category": category, "q": q, "page": page, "page_size": page_size, }, ) as rt: from app.domains.me_universe.my_requests import build_my_requests payload = await build_my_requests( rt.db, person_id=rt.user.id, status=status, category=category, q=(q or "")[:100] or None, page=page, page_size=page_size, ) return payload.model_dump() @mcp_server.tool( name="get_my_it_footprint", description=( "我的 IT 足迹: the CALLER's permanent personal recap on the platform — " "the story-shaped counterpart to the utilitarian /me IT-home. Returns " "tenure (hire date, days/years here, join_rank = your place in the line " "of everyone who joined before you, current headcount), devices " "(total ever held, current count, your first device, and the current " "device that has stayed with you longest), software seats (current + " "ever), active permissions, support tickets (total / resolved / first " "time you asked IT for help), and a curated milestone timeline " "(join → anniversaries → first device → first ticket). Read-only, " "self-scoped (no target param). Use it to answer 'how long have I been " "here / what was my first laptop / how many times have I needed IT?'." ), ) async def get_my_it_footprint() -> dict: async with tool_runtime("get_my_it_footprint", {}) as rt: from app.domains.me_universe.footprint import build_my_footprint payload = await build_my_footprint(rt.db, person=rt.user) return payload.model_dump() @mcp_server.tool( name="get_my_it_inventory", description=( "我的完整 IT 清单: the CALLER's live, lifecycle-grade inventory of " "everything they currently use or are accountable for. Returns three " "explicit groups: access in use (software seats/permissions, API keys, " "cloud grants, SSO, VPN and AI-tool access), accountability (systems, " "approval flows, mail groups, cloud/network resources, software and " "identity ownership), and equipment. This is the SAME protected " "vocabulary used by role-change and offboarding review, not a smaller " "chat-specific list. A failed category is returned explicitly and MUST " "never be described as empty. Read-only and self-scoped; it cannot " "revoke, transfer, or change any item. Use for 'what access do I have / " "what systems am I responsible for / what would a job change affect?'." ), ) async def get_my_it_inventory() -> dict: """Expose the single authoritative personal-holdings read to MCP/AI.""" async with tool_runtime("get_my_it_inventory", {}) as rt: from app.core.coordinators.person_holdings_coordinator import ( scan_person_holdings, ) scan = await scan_person_holdings(rt.db, rt.user.id) access_types = { "software_seat", "software_permission", "software_account", "api_key", "cloud_access_grant", "sso_grant", "vpn_application", "ai_tool_application", } rows = [ { "resource_type": item.resource_type, "resource_id": str(item.resource_id), "label": item.label, "detail": item.detail, } for item in scan.holdings ] return { "access_in_use": [ row for row in rows if row["resource_type"] in access_types ], "accountabilities": [ row for row in rows if row["resource_type"] not in access_types and row["resource_type"] != "hardware" ], "equipment": [ row for row in rows if row["resource_type"] == "hardware" ], "failed_categories": scan.failed_types, "coverage_note": ( "A category listed in failed_categories was not read; it is not " "evidence that the caller holds no item in that category." ), } @mcp_server.tool( name="get_my_device_gallery", description=( "装备图鉴: every hardware device the CALLER has ever held — current AND " "returned — laid out like a collection. Each card carries asset tag / " "name / category / brand / model, total service days (summed across all " "ownership stints from the real operation trail; borrow events excluded), " "first_received, currently_held, and a playful rarity tier earned by " "tenure (rookie <1y / veteran 1-3y / elder 3-5y / legend 5y+). Plus a " "summary (total devices / current count / total service days). Read-only, " "self-scoped; never names a previous owner. Use it to answer 'what " "gear have I had over the years / which device served me longest?'." ), ) async def get_my_device_gallery() -> dict: async with tool_runtime("get_my_device_gallery", {}) as rt: from app.domains.me_universe.device_gallery import build_my_device_gallery payload = await build_my_device_gallery(rt.db, person=rt.user) return payload.model_dump() @mcp_server.tool( name="get_my_loadout", description=( "我的装备墙: the CALLER's platform presence as an RPG character sheet — " "equipped hardware (current devices, slotted weapon/offhand/comm/gear + " "rarity tier), a level + power score derived from tenure, a 5-axis " "attribute spread (assets / accounts / permissions / cloud / security), " "inventory counts (devices / seats / permissions / accounts+providers / " "cloud grants), an achievements-preview milestone timeline, and a " "security rank chip (tier by lifetime quiz-correct + current streak). " "Composed from the footprint / device-gallery / security-quiz reads. " "Read-only, self-scoped. Use it for 'what's my Helix loadout / level / " "security rank?'." ), ) async def get_my_loadout() -> dict: async with tool_runtime("get_my_loadout", {}) as rt: from app.domains.me_universe.loadout import build_my_loadout payload = await build_my_loadout(rt.db, person=rt.user) return payload.model_dump() @mcp_server.tool( name="get_my_leave_balances", description=( "我的假期余额与记录: the CALLER's leave/time-off, mirrored daily from " "SuccessFactors EC Time-Off. Returns every leave-account bucket " "(法定年假 / 福利年假 / 带薪病假 / 调休假 …, each fiscal year as its own " "entry — 调休假 with several SF accounts shows as several entries) with " "its available balance (balance_days), entitled (entitled_days) and used " "(used_days) days, whether it's the current-year bucket (is_current), and " "the company HR FY label (leave_fiscal_year, e.g. an account granted in " "2025 and expiring in 2026 is FY26; do NOT infer FY from fiscal_year), and " "its full posting ledger (账户假期记录): every accrual / booking line with " "amount, posting date, running balance, creator and comment. Read-only, " "self-scoped. Use it for 'how much annual leave do I have left / show my " "leave records'." ), ) async def get_my_leave_balances() -> dict: async with tool_runtime("get_my_leave_balances", {}) as rt: from app.domains.hr.leave_service import ( HrLeaveService, leave_fiscal_year_label, ) svc = HrLeaveService(rt.db) accounts = await svc.list_accounts(str(rt.user.id)) out: list[dict] = [] for a in accounts: postings = await svc.list_postings(str(a.id)) out.append({ "id": str(a.id), "account_type": a.account_type, "account_type_label": a.account_type_label, "fiscal_year": a.fiscal_year, # ``fiscal_year`` is the raw SF bucket start-year and is not # the label employees use. Always give the assistant the # authoritative HR label so it cannot call FY26 leave "FY25". "leave_fiscal_year": leave_fiscal_year_label( period_end=a.booking_end_date or a.end_date, fiscal_year=a.fiscal_year, ), "leave_fiscal_year_basis": ( "HR leave FY is named for the period-end year; " "financial FY starts on 1 April." ), "is_current": a.is_current, "account_closed": a.account_closed, "balance_days": ( float(a.balance_days) if a.balance_days is not None else None ), "entitled_days": ( float(a.entitled_days) if a.entitled_days is not None else None ), "used_days": ( float(a.used_days) if a.used_days is not None else None ), "start_date": a.start_date.isoformat() if a.start_date else None, "end_date": a.end_date.isoformat() if a.end_date else None, "postings": [ { "booking_type": p.booking_type, "booking_amount": ( float(p.booking_amount) if p.booking_amount is not None else None ), "booking_date": ( p.booking_date.isoformat() if p.booking_date else None ), "balance_after": ( float(p.balance_after) if p.balance_after is not None else None ), "created_by": p.created_by_source, "comment": p.comment, "reason": p.reason, "leave_type": p.leave_type, "request_status": p.request_status, } for p in postings ], }) return {"person_id": str(rt.user.id), "accounts": out} @mcp_server.tool( name="refresh_my_leave_balances", description=( "从 SuccessFactors 立即刷新我的假期数据: re-pull the CALLER's own " "leave/time-off from SuccessFactors EC Time-Off RIGHT NOW, instead of " "waiting for the daily sync. Use when you just submitted or edited a " "leave request in HRIS and want Helix to reflect it immediately. " "Mutates Helix's cached leave data; returns the refreshed per-account " "balances. Call get_my_leave_balances afterwards for the full ledger." ), ) async def refresh_my_leave_balances() -> dict: async with tool_runtime( "refresh_my_leave_balances", {}, mutates=True, ) as rt: from app.audit.service import AuditService from app.core.exceptions import NotFoundError from app.domains.hr.leave_service import HrLeaveService from app.domains.hr.service import sync_one_person_leave_now from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) if person is None or person.deleted_at is not None: raise NotFoundError("User", str(rt.user.id)) async def _audit_before_commit(summary: dict) -> None: await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="hr.leave.manual_sync_person", resource_type="identity_person", resource_id=str(rt.user.id), status=( "partial" if summary.get("failed_instances") else "success" ), detail={**summary, "via": "mcp"}, ) summary = await sync_one_person_leave_now( rt.db, person, actor_id=rt.user.id, before_commit=_audit_before_commit, ) svc = HrLeaveService(rt.db) accounts = await svc.list_accounts(str(rt.user.id)) balances = [ { "account_type": a.account_type, "account_type_label": a.account_type_label, "fiscal_year": a.fiscal_year, "is_current": a.is_current, "balance_days": ( float(a.balance_days) if a.balance_days is not None else None ), "entitled_days": ( float(a.entitled_days) if a.entitled_days is not None else None ), "used_days": ( float(a.used_days) if a.used_days is not None else None ), } for a in accounts ] return { "person_id": str(rt.user.id), # Must track the HTTP twin exactly (hr/router.py): `synced` is # true only when a pull actually happened AND every HRIS # instance answered. Reporting a clean sync after a total # SuccessFactors outage would have an AI client tell the user # "已同步" over balances that did not move — the same lie the # UI was just fixed to stop telling, one surface over. "synced": ( summary.get("skipped") is None and not summary.get("failed_instances") ), "summary": summary, "accounts": balances, } @mcp_server.tool( name="get_leave_health_insights", description=( "休假健康洞察 — leave-UTILISATION roll-ups across the org, with the " "counter-intuitive lens that a team barely using its granted annual " "leave is a team-health / leadership RISK signal, not diligence. " "Read-only governance view; admits global_admin / hr / auditor / " "management only. Args: dimension = 'bu' | 'department' | 'lead' " "(default 'lead'); limit = how many lowest-utilisation 'needs " "attention' groups to return (default 10). Returns the company " "baseline (fiscal year, elapsed fraction, coverage, company " "utilisation = used/entitled annual days, pace index, forfeit-at-risk " "and banked-comp totals), the configured health thresholds, and the " "ranked groups (utilisation, vs-company ratio, low-utiliser count, " "forfeit-at-risk, comp-banked, health band). Utilisation counts " "annual leave only (法定+福利+MY); 调休 is surfaced separately as " "banked overtime; sick/paternity excluded. No-quota people are " "'no data', never 0%. Groups below the min-size guard are " "'insufficient_data' and excluded from the ranking." ), ) async def get_leave_health_insights( dimension: str = "lead", limit: int = 10, ) -> dict: async with tool_runtime( "get_leave_health_insights", {"dimension": dimension, "limit": limit}, ) as rt: from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import ( AUDITOR_ROLE as _AUD, ) from app.core.rbac import ( GLOBAL_ADMIN_ROLE as _ADMIN, ) from app.core.rbac import ( HR_ROLE as _HR, ) from app.core.rbac import ( MANAGEMENT_ROLE as _MGMT, ) from app.core.rbac import has_role as _has_role from app.domains.hr.leave_insights_service import ( DIMENSION_BU, DIMENSION_DEPARTMENT, DIMENSION_LEAD, HEALTH_INSUFFICIENT, LeaveInsightsService, ) if not _has_role(rt.user.role_names, _ADMIN, _HR, _AUD, _MGMT): raise ForbiddenError( "hr / auditor / management / global_admin role required", ) if dimension not in (DIMENSION_BU, DIMENSION_DEPARTMENT, DIMENSION_LEAD): raise ValidationError( "dimension must be one of bu / department / lead", ) limit = max(1, min(int(limit), 50)) svc = LeaveInsightsService(rt.db) ctx = await svc.build_context() groups = svc.group(dimension, ctx) eligible = [g for g in groups if g.health != HEALTH_INSUFFICIENT] eligible.sort( key=lambda g: ( g.utilisation if g.utilisation is not None else 1e9, -g.covered_count, ) ) covered = sum(1 for m in ctx.metrics if m.has_quota) return { "baseline": { "fiscal_year": ctx.fiscal_year, "elapsed_fraction": ctx.elapsed_fraction, "as_of": ctx.as_of.isoformat(), "active_population": len(ctx.metrics), "covered_population": covered, "company_utilisation": ctx.company_utilisation, "company_pace_index": ctx.company_pace_index, "forfeit_at_risk_total": round( sum(m.forfeit_at_risk for m in ctx.metrics), 2, ), "comp_banked_total": round( sum(m.comp_balance for m in ctx.metrics), 2, ), }, "thresholds": ctx.thresholds, "dimension": dimension, "attention_groups": [g.as_dict() for g in eligible[:limit]], } @mcp_server.tool( name="list_leave_expiry_reminder_candidates", description=( "年假到期提醒 · 候选名单 (read-only; global_admin / hr ONLY — mirrors " "the HTTP POST /preview gate, because the payload is per-person PII " "that surface deliberately withholds from auditor / management). " "Who currently holds a leave balance that is about to be " "forfeited, using the SAME cohort resolver the HR send page uses — so " "this answers '谁的年假快清零了 / 这次会发给多少人' without touching " "the send path. Args: account_types = SF accountType codes (default " "the forfeiting annual buckets 法定年假 CHN_AnnualLeave + MY_AnnuL); " "fiscal_years = optional list; min_balance_days (default 0.5); " "expiring_within_days (default 120, null = no window); limit (default " "50, max 500). Returns the honest cohort summary (recipient / account " "counts, total at-risk days, how many were reminded recently, coverage " "= active headcount vs people who have ANY HRIS leave account, how " "many were skipped for having no work email, HRIS sync freshness, and " "the Asia/Shanghai as-of date) plus the ranked candidates (most urgent " "first) with per-account balance, deadline and days-left. " "**Read-only on purpose: there is NO MCP tool that sends the " "reminder.** A mass employee-facing send is browser-session-gated so a " "stolen PAT cannot fire it; do it in the HR page." ), ) async def list_leave_expiry_reminder_candidates( account_types: list[str] | None = None, fiscal_years: list[int] | None = None, min_balance_days: float = 0.5, expiring_within_days: int | None = 120, limit: int = 50, ) -> dict: async with tool_runtime( "list_leave_expiry_reminder_candidates", { "account_types": account_types, "fiscal_years": fiscal_years, "min_balance_days": min_balance_days, "expiring_within_days": expiring_within_days, "limit": limit, }, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( GLOBAL_ADMIN_ROLE as _ADMIN, ) from app.core.rbac import ( HR_ROLE as _HR, ) from app.core.rbac import has_role as _has_role from app.domains.hr import leave_reminder_service as _svc from app.domains.hr.leave_reminder_schemas import ReminderFilters # 与 HTTP 的 ``POST /preview`` 同一个门(global_admin + hr),**不是** # ``require_hr_read``:这个工具返回的正是 ``/preview`` 对 auditor / # management 刻意关掉的东西 —— 逐人 work_email / 部门 / 每个账户的 # 授予·已用·余额·清零日。HTTP 关了前门、MCP 开着后门没有意义,而且 MCP # 走的是 PAT 这条加不了 step-up 的通道。 if not _has_role(rt.user.role_names, _ADMIN, _HR): raise ForbiddenError("hr / global_admin role required") limit = max(1, min(int(limit), 500)) defaults = _svc.default_filters() filters = ReminderFilters( account_types=list(account_types or defaults.account_types), fiscal_years=list(fiscal_years or []), min_balance_days=float(min_balance_days), expiring_within_days=expiring_within_days, ) cohort = await _svc.resolve_cohort(rt.db, filters) return { "filters": filters.model_dump(mode="json"), "summary": cohort.summary.model_dump(mode="json"), "candidates": [ c.model_dump(mode="json") for c in cohort.candidates[:limit] ], "truncated": len(cohort.candidates) > limit, } @mcp_server.tool( name="get_my_badges", description=( "我的成就徽章墙: the CALLER's achievements — every catalog badge across " "同行时光 / 装备 / 软件与工具 / 云端 / 安全 / 合规 / 自助与审批 / 社区与互助 / " "放松一下, each with earned+unlocked_at, ``progress`` toward the locked " "ones ({current, target, percent}), ``tier`` (稀有度) and ``holders`` / " "``holder_percent`` (share of active colleagues holding it, whole " "percent — the raw count is admin-only). Also returns " "per-category rollups and ``next_up`` — the codes the caller is closest " "to earning. Lazily evaluates so a freshly-qualified badge shows " "immediately; ``newly_unlocked`` lists codes earned on this call. " "Read-only, self-scoped, pure-pull. Use it for 'what achievements have " "I unlocked / what am I closest to earning?'." ), ) async def get_my_badges() -> dict: async with tool_runtime("get_my_badges", {}, mutates=True) as rt: from app.domains.gamification.service import get_my_badges as _badges return await _badges(rt.db, rt.user) @mcp_server.tool( name="get_achievement_stats", description=( "成就徽章 全站分布 (global_admin / management only): aggregate " "unlock distribution — holders and holder_percent per badge, " "participation rate (how many active colleagues hold at least one), " "average badges per participant, and ``unearned_codes`` (badges nobody " "holds, i.e. mis-tuned or measuring something that never happens). " "Aggregate-only by design — no per-person data, no named ranking. Use " "it for 'is the achievement system landing / which badges are dead?'." ), ) async def get_achievement_stats() -> dict: async with tool_runtime("get_achievement_stats", {}) as rt: from app.domains.gamification.router import _require_stats_reader from app.domains.gamification.service import get_org_stats _require_stats_reader(rt.user) return await get_org_stats(rt.db) @mcp_server.tool( name="get_my_wrapped", description=( "年度报告 (Helix Wrapped): the CALLER's year-in-review for a given year " "— tenure milestone, security-quiz effort (answered/correct/longest " "streak), devices received, support tickets opened, achievement badges " "earned that year + highlight lines. Read-only, self-scoped. Pass " "``year`` (e.g. 2026). Use it for 'what did I do on Helix this year?'." ), ) async def get_my_wrapped(year: int) -> dict: async with tool_runtime("get_my_wrapped", {"year": year}) as rt: from app.domains.me_universe.wrapped import build_my_wrapped # ``build_my_wrapped`` clamps ``year`` internally to a sane window, so # an out-of-range value can't reach its ``datetime(year + 1, ...)``. payload = await build_my_wrapped(rt.db, person=rt.user, year=year) return payload.model_dump() @mcp_server.tool( name="get_my_cohort", description=( "入职同期: the colleagues who joined Helix on the CALLER's exact hire " "date — a gentle 'you didn't arrive alone'. Returns hire_date, " "years_together, is_anniversary_today (true on the cohort's shared " "anniversary), the active same-day colleagues (name / employee_no / " "department), and a count of departed cohort-mates (not named). " "Read-only, self-scoped (resolved from the caller's own hire date). Use " "it to answer 'who started the same day as me?'." ), ) async def get_my_cohort() -> dict: async with tool_runtime("get_my_cohort", {}) as rt: from app.domains.me_universe.cohort import build_my_cohort payload = await build_my_cohort(rt.db, person=rt.user) return payload.model_dump() @mcp_server.tool( name="get_my_welcome", description=( "入职第一天仪式感: the CALLER's warm welcome / Day-1 snapshot — who you " "are here (employee_no / job_title / work_location / department), your " "manager, the gear assigned to or reserved for you (the unboxing " "moment), your onboarding standing (status / days_to_hire / ready / " "pending blocker labels), and starter signals (seat/permission counts, " "has_avatar). `is_new` flags a recent hire. Read-only, self-scoped. Use " "it to answer 'what is set up for me / what is my first laptop / who is " "my manager?' on Day 1." ), ) async def get_my_welcome() -> dict: async with tool_runtime("get_my_welcome", {}) as rt: from app.domains.me_universe.welcome import build_my_welcome payload = await build_my_welcome(rt.db, person=rt.user) return payload.model_dump() @mcp_server.tool( name="get_helix_cockpit", description=( "运管驾驶舱: the leadership/IT operational cockpit — the FULL picture in " "labelled metric sections: people (headcount / new this month / " "onboarding+offboarding in progress / leavers blocked), assets (total / " "assigned / idle / held-by-leaver / warranty expiring), cost (active " "seats / monthly software spend), cloud (servers / domains), ops backlog " "(provisioning open / open transfers / open support), compliance (open " "incidents). Read-only. Gated to admin-read roles (global_admin / " "management) — cost is included, hence the gate. 'Helix 此刻' is " "leadership-only (the employee 广场 pulse was removed)." ), ) async def get_helix_cockpit() -> dict: async with tool_runtime("get_helix_cockpit", {}) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role roles = rt.user.role_names or [] if not ( has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin") or has_role(roles, MANAGEMENT_ROLE) ): raise ForbiddenError("运管驾驶舱 requires global_admin or management.") from app.dashboard.pulse import build_cockpit return await build_cockpit(rt.db) @mcp_server.tool( name="get_todays_security_quiz", description=( "安全每日一题: today's security-awareness question and whether the " "CALLER has answered it. Returns the question (prompt + options + " "category) and, once answered, the result (selected_key / is_correct / " "correct_key / explanation) plus the caller's streak stats. " "Rendered in the caller's own language (their stored " "preferred_language, then country) — `lang` says which one, and " "`is_fallback_lang` is true when that question has no copy in their " "language and they are seeing the Chinese baseline. " "Self-scoped. Use it to fetch today's question before answering." ), ) async def get_todays_security_quiz() -> dict: # ``mutates=True``: get_today -> ensure_today_assignment does a # get-or-create INSERT of the day's question row. Without this the MCP # runtime skips the business-session commit and the assignment is rolled # back, so a subsequent answer_security_quiz re-picks a DIFFERENT # (least-recently-used) question than the one shown here. (On HTTP the # request-scoped get_db autocommit masked this; the MCP surface did not.) async with tool_runtime("get_todays_security_quiz", {}, mutates=True) as rt: from app.domains.security_quiz import service return await service.get_today(rt.db, rt.user) @mcp_server.tool( name="answer_security_quiz", description=( "安全每日一题 — submit the CALLER's answer to today's question. The " "question's `qtype` (from get_todays_security_quiz) decides the format of " "`selected_key`: single-choice → one option key (e.g. 'A'); " "multiple-choice → all chosen keys concatenated (e.g. 'ABD', order/dupes " "don't matter), correct only on an exact match; true/false (boolean) → " "'T' (对/true) or 'F' (错/false). One shot per day (idempotent — a second " "call returns the already-recorded result). Returns the result " "(is_correct / correct_key / explanation) and updated streak stats. " "Option keys are language-independent, so an answer is graded the same " "whichever language the question was read in; the explanation comes " "back in the caller's own language when it exists." ), ) async def answer_security_quiz(selected_key: str) -> dict: async with tool_runtime( "answer_security_quiz", {"selected_key": selected_key}, mutates=True, ) as rt: from app.domains.security_quiz import service return await service.submit_answer(rt.db, rt.user, (selected_key or "").strip()) @mcp_server.tool( name="get_security_quiz_leaderboard", description=( "安全每日一题 全员排行榜: the top players by correct answers (name / " "correct / answered), plus the CALLER's own rank. Read-only. Use it to " "answer 'who's leading the security quiz / where do I rank?'." ), ) async def get_security_quiz_leaderboard(limit: int = 20) -> dict: async with tool_runtime("get_security_quiz_leaderboard", {"limit": limit}) as rt: from app.domains.security_quiz import service return await service.get_leaderboard(rt.db, rt.user, limit=limit) @mcp_server.tool( name="get_security_team_battle", description=( "安全段位擂台 部门擂台: department standings for the daily security quiz " "over a trailing window (default 30 days) — each department's headcount, " "participants, participation %, answered, correct, accuracy %, ranked by " "participation then accuracy — plus last ISO-week's frozen snapshot for " "the week-over-week view. Read-only, any signed-in user. Use it for " "'which team is winning the security quiz / our department's standing?'." ), ) async def get_security_team_battle(window_days: int = 30) -> dict: async with tool_runtime( "get_security_team_battle", {"window_days": window_days}, ) as rt: from app.domains.security_quiz import team_battle as tb live = await tb.compute_department_standings(rt.db, window_days=window_days) snapshot = await tb.latest_snapshot(rt.db) return {"live": live, "last_week": snapshot} @mcp_server.tool( name="get_my_arcade_overview", description=( "放松一下 (Security Arcade) 总览: the CALLER's arcade wallet — points " "balance / total earned / total spent (points are earned by answering " "the daily security quiz: correct/wrong/streak-bonus rates included in " "earn_rules), today's play budget (plays_used / plays_left / cap), the " "game wall with personal best scores, and the entry cost per round. " "Lazily credits any not-yet-credited quiz answers on read (idempotent). " "Self-scoped. NOTE: actually PLAYING a round is a real-time interactive " "web/H5 experience (/me/arcade) — it is deliberately not an MCP tool." ), ) async def get_my_arcade_overview() -> dict: # mutates=True: the read lazily reconciles quiz credits (ledger inserts + # wallet update) and expires stale sessions. async with tool_runtime("get_my_arcade_overview", {}, mutates=True) as rt: from app.domains.arcade import service return await service.get_overview(rt.db, rt.user) @mcp_server.tool( name="get_arcade_leaderboard", description=( "放松一下 游戏排行榜. board='knowledge' (default) → the 科举 " "most-knowledgeable board: top players by lifetime correct trivia " "answers, each with their imperial-exam title (白丁→状元), plus the " "CALLER's rank + title. board='game' → a single game's best-score " "board; pass game (e.g. 'quiz_rush','password_sniper','schulte'," "'memory_match','stroop') and optionally variant (a trivia category " "slug like 'history' for a per-category board — 'who knows history " "best?'). Read-only, any signed-in user. Answers 'who's the most " "knowledgeable / best at / where do I rank?'." ), ) async def get_arcade_leaderboard( board: str = "knowledge", game: str | None = None, variant: str | None = None, limit: int = 20, ) -> dict: async with tool_runtime( "get_arcade_leaderboard", {"board": board, "game": game, "variant": variant, "limit": limit}, ) as rt: from app.domains.arcade import leaderboard return await leaderboard.get_leaderboard( rt.db, rt.user, board=board, game_key=game, variant=(variant or None), limit=max(1, min(limit, 100)), ) @mcp_server.tool( name="get_arcade_participation", description=( "放松一下 参与看板 (adoption analytics, admin/management). Over a " "trailing window (default 30 days): headcount, players + participation " "%, total plays, avg plays/player, per-game breakdown (plays/players/" "avg score), per-department participation, and the points economy " "(total earned/spent/outstanding). Read-only, global_admin or " "management. Use it for 'how much is the arcade being used / which " "games land / which teams engage?'." ), ) async def get_arcade_participation(window_days: int = 30) -> dict: async with tool_runtime( "get_arcade_participation", {"window_days": window_days}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role roles = rt.user.role_names or [] if not ( has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin") or has_role(roles, MANAGEMENT_ROLE) ): raise ForbiddenError("Arcade participation requires global_admin or management.") from app.domains.arcade import participation return await participation.get_participation( rt.db, window_days=max(1, min(window_days, 366)), ) @mcp_server.tool( name="get_arcade_economy", description=( "放松一下 经济参数 (admin). The live, tunable point economy: " "points_correct / points_wrong / points_correct_streak / " "streak_threshold / backfill_cap / entry_cost / daily_play_cap, " "plus the seed defaults and each field's valid bounds. Read-only, " "global_admin. Pair with set_arcade_economy to retune." ), ) async def get_arcade_economy() -> dict: async with tool_runtime("get_arcade_economy", {}) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role roles = rt.user.role_names or [] if not (has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin")): raise ForbiddenError("Arcade economy requires global_admin.") from app.domains.arcade import economy cfg = await economy.get_economy_config(rt.db) return { "config": economy.economy_to_dict(cfg), "defaults": economy.economy_to_dict(economy.DEFAULT_ECONOMY), "bounds": {k: list(v) for k, v in economy.ECONOMY_BOUNDS.items()}, } @mcp_server.tool( name="set_arcade_economy", description=( "调整放松一下经济参数 (admin, mutating). Pass ONLY the fields you want " "to change (any of points_correct / points_wrong / " "points_correct_streak / streak_threshold / backfill_cap / entry_cost " "/ daily_play_cap) as integers; unset fields keep their current value. " "Each value is validated against its bounds (out-of-range → error). " "e.g. raise the daily cap for a test window, or make correct answers " "worth more during a campaign. global_admin only." ), ) async def set_arcade_economy( points_correct: int | None = None, points_wrong: int | None = None, points_correct_streak: int | None = None, streak_threshold: int | None = None, backfill_cap: int | None = None, entry_cost: int | None = None, daily_play_cap: int | None = None, ) -> dict: updates = { k: v for k, v in { "points_correct": points_correct, "points_wrong": points_wrong, "points_correct_streak": points_correct_streak, "streak_threshold": streak_threshold, "backfill_cap": backfill_cap, "entry_cost": entry_cost, "daily_play_cap": daily_play_cap, }.items() if v is not None } async with tool_runtime("set_arcade_economy", updates, mutates=True) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role roles = rt.user.role_names or [] if not (has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin")): raise ForbiddenError("Arcade economy requires global_admin.") from app.domains.arcade import economy cfg = await economy.set_economy_config(rt.db, updates, actor_id=rt.user.id) return {"config": economy.economy_to_dict(cfg)} @mcp_server.tool( name="get_my_arcade_history", description=( "我的战绩 (放松一下): the CALLER's own finished game rounds, newest " "first — for each round the game_key, variant (difficulty / trivia " "category), status (completed/expired), score, correct_count (games " "that have one), duration_ms, points cost, and started/finished " "timestamps. Self-scoped, read-only. Answers 'what have I played / how " "did my last rounds go / my score history?'." ), ) async def get_my_arcade_history(limit: int = 30) -> dict: async with tool_runtime("get_my_arcade_history", {"limit": limit}) as rt: from app.domains.arcade import service return await service.get_history(rt.db, rt.user, limit=max(1, min(limit, 100))) @mcp_server.tool( name="list_arcade_sessions", description=( "放松一下 对局审计台账 (admin/management): every player's finished " "game rounds across the company, newest first — each row carries the " "player (person_id + display_name + primary department), game_key, " "variant, status, score, correct_count, duration, points cost and " "timestamps. Filter by person_id, game (game_key), status " "(completed/expired); paginate with limit (≤200) + offset (total " "returned for paging). Read-only, global_admin or management. Use it " "to audit individual plays / investigate a player's activity." ), ) async def list_arcade_sessions( person_id: str | None = None, game: str | None = None, status: str | None = None, limit: int = 50, offset: int = 0, ) -> dict: async with tool_runtime( "list_arcade_sessions", {"person_id": person_id, "game": game, "status": status, "limit": limit, "offset": offset}, ) as rt: import uuid as _uuid from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role roles = rt.user.role_names or [] if not ( has_role(roles, GLOBAL_ADMIN_ROLE) or has_role(roles, "it_admin") or has_role(roles, MANAGEMENT_ROLE) ): raise ForbiddenError("Arcade session audit requires global_admin or management.") pid = _uuid.UUID(person_id) if person_id else None from app.domains.arcade import service return await service.admin_list_sessions( rt.db, person_id=pid, game_key=(game or None), status=(status or None), limit=limit, offset=offset, ) @mcp_server.tool( name="list_my_todos_done", description=( "我的已办: the items the CALLER has already acted on across all " "todo domains — asset transfers I responded to, provisioning tasks " "I completed, cloud-grant approvals I decided, api-key releases I " "decided, inventory items I responded to — newest first. The " "read-only history complement to the 待办 (pending) queues; use it " "to answer 'what did I already handle?'. Each item carries type, " "title, status, acted_at, deep_link. Optional `type` filter " "(asset_transfer/provisioning/cloud_grant/apikey_release/inventory); " "bounded pagination (max 100)." ), ) async def list_my_todos_done( type: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_todos_done", {"type": type, "page": page, "page_size": page_size}, ) as rt: from app.domains.me_universe.todo_history import build_todo_history payload = await build_todo_history( rt.db, person_id=rt.user.id, type_filter=type, page=page, page_size=page_size, ) return payload.model_dump() @mcp_server.tool( name="my_todos_done_stats", description=( "我的已办报表: aggregate analytics over the items the CALLER has " "already acted on across all todo domains — total handled, the mix " "by type (asset_transfer/provisioning/cloud_grant/apikey_release/" "inventory) and by terminal status, a 12-month activity trend " "(month buckets in the caller's local timezone), this-month vs " "previous-month counts, distinct active days, and first/last acted " "timestamps. The report complement to list_my_todos_done; use it to " "answer 'how much have I handled, of what, and when?'. Read-only, " "owner-scoped; `truncated=true` means a heavy history exceeded the " "per-type cap and the stats cover the most-recent items." ), ) async def my_todos_done_stats() -> dict: async with tool_runtime("my_todos_done_stats", {}) as rt: from app.domains.me_universe.todo_history import build_todo_history_stats payload = await build_todo_history_stats(rt.db, person=rt.user) return payload.model_dump() @mcp_server.tool( name="urge_request", description=( "催办 (urge / nudge) one of MY still-open requests — pings ONLY the " "person(s) the request is currently waiting on so it gets processed " "faster. `id` is the '{source}:{pk}' id from list_my_requests. For a " "live Feishu approval it notifies just the current pending approver(s); " "otherwise it falls back to the requester's manager + IT admins. " "In-app + Feishu; rate-limited to once per request per 24h. " "Owner-scoped: you can only urge your own requests. Returns " "{urged, notified, last_urged_at, reason}; urged=false with reason " "'notOpen' (terminal request) or 'rateLimited' (already urged today)." ), ) async def urge_request_tool(id: str) -> dict: async with tool_runtime("urge_request", {"id": id}, mutates=True) as rt: from app.domains.me_universe.urge import urge_request result = await urge_request( rt.db, item_id=id, actor=rt.user, is_admin=False, ) return result.model_dump() @mcp_server.tool( name="list_my_attendance_reports", description=( "List the intern attendance monthly-reports that involve ME — either " "as the intern (my own report) or as a leader who needs to review a " "subordinate's report. Each item carries the cycle, status " "(generated/pending_intern/pending_leader/returned/completed), the " "per-period summary (work_days, abnormal_days, lack/late/early counts), " "`viewer_role` (intern|leader), and `can_confirm`/`can_return`/`can_undo` " "flags telling you what you may do right now. Read-only, owner-scoped." ), ) async def list_my_attendance_reports() -> dict: async with tool_runtime("list_my_attendance_reports", {}) as rt: from app.domains.attendance import self_service as ss items = await ss.list_my_reports(rt.db, person=rt.user) return {"reports": items, "count": len(items)} @mcp_server.tool( name="confirm_attendance_report", description=( "Confirm one of MY attendance monthly-reports. `report_id` is the id " "from list_my_attendance_reports. If I am the intern, this confirms my " "own report and routes it to my leader; if I am the leader, this is the " "final confirmation and the report is completed. Owner-scoped + " "idempotent. Returns {id, status, role}. To dispute instead of confirm, " "use return_attendance_report." ), ) async def confirm_attendance_report(report_id: str) -> dict: async with tool_runtime( "confirm_attendance_report", {"report_id": report_id}, mutates=True, leaver_allowed=True, ) as rt: # Payroll carve-out, mirrored in # ``attendance/me_router.py`` (which is deliberately ungated): # the monthly report IS the payroll basis for a period already # worked, so a ``leaver_pending`` intern must still be able to # sign off their last one (#2160). from uuid import UUID from app.domains.attendance import self_service as ss return await ss.confirm(rt.db, report_id=UUID(report_id), actor=rt.user) @mcp_server.tool( name="return_attendance_report", description=( "Return (退回) one of MY attendance monthly-reports to HR with a reason " "— use when there's a missing punch you still need to fix (补卡) etc. " "`report_id` is from list_my_attendance_reports; `reason` is required. " "Works whether I'm the intern or the reviewing leader. After HR re-pulls " "and resends, you confirm again. Owner-scoped. Returns {id, status, role}." ), ) async def return_attendance_report(report_id: str, reason: str) -> dict: async with tool_runtime( "return_attendance_report", {"report_id": report_id, "reason": reason}, mutates=True, leaver_allowed=True, ) as rt: # Payroll carve-out, mirrored in # ``attendance/me_router.py`` (which is deliberately ungated): # the monthly report IS the payroll basis for a period already # worked, so a ``leaver_pending`` intern must still be able to # sign off their last one (#2160). from uuid import UUID from app.domains.attendance import self_service as ss return await ss.return_to_hr( rt.db, report_id=UUID(report_id), actor=rt.user, reason=reason, ) @mcp_server.tool( name="undo_attendance_confirmation", description=( "Undo MY confirmation of an attendance monthly-report — only the intern " "can undo, and only while it's still waiting on the leader (before the " "leader acts). Use if you confirmed by mistake. `report_id` is from " "list_my_attendance_reports. Owner-scoped. Returns {id, status, role}; " "errors if the leader has already acted / the report is completed." ), ) async def undo_attendance_confirmation(report_id: str) -> dict: async with tool_runtime( "undo_attendance_confirmation", {"report_id": report_id}, mutates=True, leaver_allowed=True, ) as rt: # Payroll carve-out — see confirm_attendance_report (#2160). from uuid import UUID from app.domains.attendance import self_service as ss return await ss.undo(rt.db, report_id=UUID(report_id), actor=rt.user) @mcp_server.tool( name="get_my_access_review", description=( "Read MY half-yearly access self-attestation (访问自查). Returns " "{open, campaign:{label,due_at,days_left}, items:[{id, access_type, " "access_label, sensitivity, removable, granted_at, last_used_at, " "decision}], pending_count, total_count}. `open=false` means there is " "no review campaign in progress right now (nothing to do). Items are " "elevated-first (cloud grants / API keys / SSO). Use submit_access_review " "to confirm or flag items." ), ) async def get_my_access_review() -> dict: async with tool_runtime("get_my_access_review", {}) as rt: from app.domains.auditor import access_certification_service as acs return await acs.my_open_self_attestation(rt.db, person_id=rt.user.id) @mcp_server.tool( name="submit_access_review", description=( "Batch-decide MY OWN access self-attestation items. `keep_item_ids` = " "access I still need (the attestation); `remove_item_ids` = 我不需要了 — " "routes each to that access type's existing self-service removal " "(software seat → cancel at period end, cloud grant → revoke, software " "permission → async revoke; SSO grants route to IT for handover; API " "keys are confirm-only and cannot be removed here). Item ids are from " "get_my_access_review. `remove_reason` optional. Owner-scoped + " "idempotent (already-decided items skipped). Returns {kept, removed, " "skipped}." ), ) async def submit_access_review( keep_item_ids: list[str] | None = None, remove_item_ids: list[str] | None = None, remove_reason: str | None = None, ) -> dict: async with tool_runtime( "submit_access_review", {"keep": keep_item_ids or [], "remove": remove_item_ids or []}, mutates=True, ) as rt: from uuid import UUID from app.domains.auditor import access_certification_service as acs return await acs.submit_self_attestation( rt.db, actor_person_id=rt.user.id, keep_item_ids=[UUID(x) for x in (keep_item_ids or [])], remove_item_ids=[UUID(x) for x in (remove_item_ids or [])], remove_reason=remove_reason, ) @mcp_server.tool( name="admin_approval_overview", description=( "Admin-only: the platform-wide pending-approval board — every " "approval-type item awaiting a decision across ALL domains " "(employee asset/software/api-key/Google requests, cloud " "scope-requests, leaver dual-sign overrides, DNS / domain / VPN / " "AI-tool / announcement requests, API-key releases), in one " "oldest-pending-first list. Each item is tagged with `decided_in` " "(helix = a Helix page decides it / feishu = the approver acts in " "Feishu and Helix only mirrors status), a `deep_link` to that " "surface when one exists, the requester, and `age_days`. Use to " "answer 'what approvals are pending across the platform?'. " "Read-only — mirrors GET /approval/admin/overview; decisions still " "happen on each item's owning page. Requires global_admin / auditor " "/ management. Optional `source` / `decided_in` filters; `q` is a " "case-insensitive substring search over title / subtitle / " "requester name; bounded pagination (max 100 rows)." ), ) async def admin_approval_overview( source: str | None = None, decided_in: str | None = None, q: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "admin_approval_overview", { "source": source, "decided_in": decided_in, "q": q, "page": page, "page_size": page_size, }, ) as rt: _require_admin_read(rt.user.role_names) from app.approval.admin_overview import build_admin_approval_overview payload = await build_admin_approval_overview( rt.db, source=source, decided_in=decided_in, q=(q or "")[:100] or None, page=page, page_size=page_size, ) return payload.model_dump() @mcp_server.tool( name="admin_request_list", description=( "Admin-only: the platform-wide ALL-STATUS request list — every " "approval-type request in ANY lifecycle state (pending / approved / " "rejected / completed / failed / cancelled), across ALL domains " "(employee asset/software/api-key/Google requests, cloud " "scope-requests, DNS / domain / VPN / AI-tool / announcement " "requests, API-key releases, leaver dual-sign overrides, " "identity-consumer requests), newest-first. The cross-domain, " "full-history sibling of admin_approval_overview (which is " "pending-only). Each item carries `source`, `status_normalized` " "(canonical lifecycle), raw `status`, `requester_display`, " "`created_at` / `processed_at`, and a `deep_link` to the owning " "page. Use to answer 'show me all DNS / VPN / cloud / software / … " "requests and their state', not just the asset table. Read-only — " "mirrors GET /approval/admin/all-requests. Requires global_admin / " "auditor / management. Optional `source` / `status` / `request_type` " "(asset/software sub-type) / `requester_id` / `start_date` / " "`end_date` filters; `q` is a case-insensitive substring search " "over title / subtitle / requester name; bounded pagination " "(max 100 rows)." ), ) async def admin_request_list( source: str | None = None, status: str | None = None, request_type: str | None = None, requester_id: str | None = None, q: str | None = None, start_date: str | None = None, end_date: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "admin_request_list", { "source": source, "status": status, "request_type": request_type, "requester_id": requester_id, "q": q, "start_date": start_date, "end_date": end_date, "page": page, "page_size": page_size, }, ) as rt: _require_admin_read(rt.user.role_names) from app.approval.admin_overview import build_admin_request_list payload = await build_admin_request_list( rt.db, source=source, status=status, request_type=request_type, requester_id=UUID(requester_id) if requester_id else None, q=(q or "")[:100] or None, start_date=start_date, end_date=end_date, page=page, page_size=page_size, ) return payload.model_dump() @mcp_server.tool( name="admin_request_detail", description=( "Admin-only read of one platform-wide approval item by the stable " "`source:id` returned by admin_request_list/admin_approval_overview. " "Returns requester, meaningful source fields, lifecycle timeline and " "best-effort live Feishu progress. Sensitive SAML XML/certificate " "material is never returned. Read-only twin of GET " "/approval/admin/all-requests/detail. Requires global_admin / auditor " "/ management." ), ) async def admin_request_detail(item_id: str) -> dict: async with tool_runtime( "admin_request_detail", {"item_id": item_id[:160]}, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.me_universe.request_detail import build_my_request_detail detail = await build_my_request_detail( rt.db, person_id=None, item_id=item_id[:160], enforce_owner=False, with_live_approval=True, ) return detail.model_dump() @mcp_server.tool( name="list_team_requests", description=( "Manager-scope: every request SUBMITTED by the caller's direct or " "transitive (skip-level) reports, across all domains — the " "team/manager view of list_my_requests. Same normalised-lifecycle " "shape, plus a `requester_display` naming which report submitted " "each item. Manager-derived scope (no DB role): a caller with no " "reports gets an empty list, not 403. Read-only — managers see " "their reports' requests, they don't decide them; use to answer " "'what has my team requested / what's still pending for them?'. " "Optional `status` / `category` filters; `q` is a case-insensitive " "substring search over title / subtitle / request type / requester " "name; bounded pagination (max 100 rows)." ), ) async def list_team_requests( status: str | None = None, category: str | None = None, q: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_team_requests", { "status": status, "category": category, "q": q, "page": page, "page_size": page_size, }, ) as rt: from app.domains.me_universe.my_requests import build_team_requests payload = await build_team_requests( rt.db, manager=rt.user, status=status, category=category, q=(q or "")[:100] or None, page=page, page_size=page_size, ) return payload.model_dump() @mcp_server.tool( name="list_team_assets", description=( "Manager-scope: every asset assigned to the user's direct or " "transitive (skip-level) reports. Mirrors the HTTP /assets/team " "route which is STRICTLY manager-scope — an asset_admin / " "it_admin without subordinates gets an empty list, not the " "company-wide view. (Admins wanting the global view should use " "search_assets.) Other roles get 403. Bounded pagination — max " "200 rows per call. Optional holder_kind / holder_office_id " "filters mirror the admin-side search_assets behaviour so a " "manager can ask for e.g. 'team members' office-held assets at " "Tokyo'." ), ) async def list_team_assets( page: int = 1, page_size: int = 200, holder_kind: list[str] | None = None, holder_office_id: list[str] | None = None, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_team_assets", { "page": page, "page_size": page_size, "holder_kind": holder_kind, "holder_office_id": holder_office_id, }, ) as rt: from uuid import UUID from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import can_manage_assets from app.domains.asset.router import _resolve_subordinate_ids from app.domains.asset.service import AssetService # Match the HTTP route's three-way scope (asset/router.py # /assets/team — STRICTLY manager-scope): # 1. Non-manager without admin → 403. # 2. Non-manager with admin (no subordinates) → empty list. The # HTTP route returns empty success for this branch and the # page label promises "your team's", not "everyone's" — an # admin who actually wants global must use search_assets. # 3. Manager (with subordinates) → resolve TRANSITIVE reports # via the BFS helper. Direct-only matching (the previous # ``rt.user.subordinates``) silently dropped skip-level # reports A→B→C from A's view. is_admin = can_manage_assets(rt.user.role_names) # Eager-loaded by ``_load_user_with_roles`` (selectinload). direct_subs = list(rt.user.subordinates or []) if not direct_subs: if is_admin: return { "assets": [], "total": 0, "page": page, "page_size": page_size, } raise ForbiddenError( "list_team_assets is manager-scope: caller has no " "direct subordinates. Admins wanting the company-wide " "view should use search_assets.", ) visited = await _resolve_subordinate_ids(rt.db, rt.user.id) visited.discard(rt.user.id) # scope = reports, not self if not visited: return { "assets": [], "total": 0, "page": page, "page_size": page_size, } # Parse optional holder_office_id as UUIDs at the boundary — # raises ValidationError on malformed input rather than # silently filtering to zero rows. Same shape as the admin # ``search_assets`` tool below. office_uuids: list[UUID] | None = None if holder_office_id: try: office_uuids = [UUID(x) for x in holder_office_id] except (TypeError, ValueError) as exc: raise ValidationError( f"holder_office_id must be UUIDs: {exc}", ) service = AssetService(rt.db) assets, total = await service.list_assets( page=page, page_size=page_size, assigned_to_ids=list(visited), holder_kind=holder_kind or None, holder_office_id=office_uuids, sort_by="assigned_at", sort_order="desc", ) return { "assets": [_serialize_asset(a) for a in assets], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="get_asset_history", description=( "Return the action history for a single asset: every claim, " "return, borrow, transfer, repair etc. Bounded pagination — " "max 100 rows per call. **Asset-admin only** (matches the HTTP " "route's `require_asset_admin` gate). Non-admins should use " "`list_my_assets` for their own asset overview." ), ) async def get_asset_history(asset_id: str, page: int = 1, page_size: int = 20) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "get_asset_history", {"asset_id": asset_id, "page": page, "page_size": page_size}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_assets from app.domains.asset.service import AssetService # Match the HTTP route's `Depends(require_asset_admin())` gate. # The previous tool body had no check at all despite the # docstring's claim of "owner / manager / admin" enforcement — # that was a privilege-escalation hole every employee could # exercise via PAT. if not can_manage_assets(rt.user.role_names): raise ForbiddenError( "Asset history is admin-only. Use list_my_assets for " "your own asset overview.", ) history, total = await AssetService(rt.db).get_asset_history( asset_id=_u(asset_id), page=page, page_size=page_size, ) return { "items": [ { "id": str(h.id), "asset_id": str(h.asset_id), "action": h.action, "from_user": str(h.from_user) if h.from_user else None, "to_user": str(h.to_user) if h.to_user else None, "operator_id": str(h.operator_id) if h.operator_id else None, "notes": h.notes, "created_at": _iso_utc(h.created_at), } for h in history ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="claim_asset", description=( "**Asset-admin only.** Claim an unassigned hardware asset (status " "idle or available) to a person. Use to_user_id= to " "claim for yourself; omit to default to the caller. Records an " "asset operation + history entry. Mirrors the HTTP route's " "`require_asset_admin` gate — non-admins get 403." ), ) async def claim_asset( asset_id: str, to_user_id: str | None = None, notes: str | None = None, ) -> dict: async with tool_runtime( "claim_asset", {"asset_id": asset_id, "to_user_id": to_user_id, "notes": notes}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) target_pid = _u(to_user_id) if to_user_id else rt.user.id await _mcp_assert_target_person_in_region(rt, target_pid) from app.domains.asset.schemas import AssetClaimRequest from app.domains.asset.service import AssetService body = AssetClaimRequest( to_user_id=target_pid, notes=notes, ) asset, op = await AssetService(rt.db).claim_asset(_u(asset_id), body, rt.user.id) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="return_asset", description=( "**Asset-admin only.** Return a claimed/assigned hardware asset " "to stock. Records an operation + history entry; the asset row " "goes back to `idle`. Mirrors the HTTP route's " "`require_asset_admin` gate — non-admins get 403. Employees who " "want to return their own asset should use `create_asset_transfer` " "to hand it back to IT, or talk to IT through Feishu." ), ) async def return_asset(asset_id: str, notes: str | None = None) -> dict: async with tool_runtime( "return_asset", {"asset_id": asset_id, "notes": notes}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) from app.domains.asset.schemas import AssetReturnRequest from app.domains.asset.service import AssetService body = AssetReturnRequest(notes=notes) asset, op = await AssetService(rt.db).return_asset(_u(asset_id), body, rt.user.id) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="borrow_asset", description=( "**Asset-admin only.** Lend an idle hardware asset short-term. " "Required: expected_return_date (future ISO YYYY-MM-DD). Defaults " "to_user_id to the caller. Mirrors the HTTP route's " "`require_asset_admin` gate — non-admins get 403." ), ) async def borrow_asset( asset_id: str, expected_return_date: str, to_user_id: str | None = None, notes: str | None = None, ) -> dict: async with tool_runtime( "borrow_asset", { "asset_id": asset_id, "expected_return_date": expected_return_date, "to_user_id": to_user_id, "notes": notes, }, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) target_pid = _u(to_user_id) if to_user_id else rt.user.id await _mcp_assert_target_person_in_region(rt, target_pid) from app.domains.asset.schemas import AssetBorrowRequest from app.domains.asset.service import AssetService body = AssetBorrowRequest( to_user_id=target_pid, expected_return_date=_parse_iso_date( expected_return_date, field="expected_return_date", ), notes=notes, ) asset, op = await AssetService(rt.db).borrow_asset(_u(asset_id), body, rt.user.id) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="extend_asset_borrow", description=( "Extend a borrowed asset's expected return date — **no approval**. " "Authorized for the **borrower themselves** OR an **asset admin** " "(mirrors the HTTP route `POST /assets/{id}/borrow-extension`). " "Admins may also bring the date forward; a borrower can only push " "it later. Required: expected_return_date (future ISO YYYY-MM-DD) " "and reason (why the loan is held longer). Notifies the borrower, " "their lead, and — on borrower self-service — the asset admins." ), ) async def extend_asset_borrow( asset_id: str, expected_return_date: FutureDate, reason: str, ) -> dict: async with tool_runtime( "extend_asset_borrow", { "asset_id": asset_id, "expected_return_date": expected_return_date, "reason": reason, }, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import can_manage_assets from app.domains.asset.service import AssetService svc = AssetService(rt.db) asset = await svc.get_asset(_u(asset_id)) is_borrower = ( asset.borrowed_by is not None and asset.borrowed_by == rt.user.id ) is_admin = can_manage_assets(rt.user.role_names) if not is_borrower and not is_admin: raise ForbiddenError( "Only the borrower or an asset admin can extend this loan.", ) if is_admin and not is_borrower: await _mcp_enforce_asset_region(rt, asset_id) # `reason` is required (audit + stakeholder notification body). reason = _bound_free_text(reason, field="reason", max_len=2000) or "" if not reason.strip(): raise ValidationError("reason is required") asset, op = await svc.extend_borrow( _u(asset_id), new_expected_return_date=_parse_iso_date( expected_return_date, field="expected_return_date", ), reason=reason, operator_id=rt.user.id, allow_earlier=is_admin and not is_borrower, trace_id=_trace_str(), ) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="mark_asset_held_by_leaver", description=( "**Asset-admin only.** Mark an asset as `held_by_leaver` — the " "non-standard but real-world state where the asset is still " "company property but physically held by a departed employee " "(overseas walk-out, dispute settlement, unresolved offboarding). " "`assigned_to` is preserved so audit / 'last holder' attribution " "stays intact; the asset is locked out of claim / borrow / " "transfer until an admin calls `recover_asset_from_leaver`. The " "offboarding console fires the same path automatically when an " "operator clicks Skip on a hardware blocker — call this tool " "manually when you spot a legacy / orphan attribution. " "`reason` is required (audit context); the previous status " "must imply a holder (assigned / claimed / borrowed)." ), ) async def mark_asset_held_by_leaver(asset_id: str, reason: str) -> dict: async with tool_runtime( "mark_asset_held_by_leaver", {"asset_id": asset_id, "reason": reason}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) # Cap free-text at the MCP boundary — `reason` is stored in # ``asset_operations.notes`` (TEXT). Without this an LLM client # could write a multi-MB note. reason = _bound_free_text(reason, field="reason", max_len=2000) or "" from app.domains.asset.service import AssetService asset, op = await AssetService(rt.db).mark_held_by_leaver( _u(asset_id), reason=reason, operator_id=rt.user.id, source="manual", ) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="recover_asset_from_leaver", description=( "**Asset-admin only.** Recover a `held_by_leaver` asset back to " "idle stock so it can be re-claimed by another employee. Use " "after the ex-employee physically returns the laptop / hardware. " "Clears `assigned_to`, `borrowed_by`, `expected_return_date` " "and flips status to `idle`. Fails 400 if the asset is in any " "other status — pair with `list_team_assets` (status filter) to " "find recoverable rows." ), ) async def recover_asset_from_leaver( asset_id: str, notes: str | None = None, ) -> dict: async with tool_runtime( "recover_asset_from_leaver", {"asset_id": asset_id, "notes": notes}, mutates=True, ) as rt: _require_asset_admin(rt) # ``recover_from_leaver`` operates on a non-deleted asset row # (status=held_by_leaver). The region gate must allow it — # ``_mcp_enforce_asset_region`` loads via ``db.get`` with # ``deleted_at IS NULL`` check which matches that path. await _mcp_enforce_asset_region(rt, asset_id) # Cap free-text at the MCP boundary — service writes ``notes`` # straight into ``asset_operations.notes`` (TEXT) with no # Pydantic length validator between us and the DB. HTTP twin # caps at 2000 via ``AssetRecoverFromLeaverRequest``. Without # this an LLM client can write a multi-MB note. Mirrors the # sibling guards in ``mark_asset_held_by_leaver`` (line 769), # ``recycle_asset_to_office`` (845), ``release_asset_from_office`` # (884). (R1 code review finding 2026-05-28.) notes = _bound_free_text(notes, field="notes", max_len=2000) from app.domains.asset.service import AssetService asset, op = await AssetService(rt.db).recover_from_leaver( _u(asset_id), notes=notes, operator_id=rt.user.id, ) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="recycle_asset_to_office", description=( "**Asset-admin only.** Recycle an asset to a city office " "(`holder_kind='office'`, `status='held_by_office'`). The " "device becomes routed to that office's admin for inventory " "and offboarding handoffs — no Person assignee. Use for " "meeting-room kit, shared docks, or returning an ex-employee's " "laptop to its home office instead of stacking on a manager. " "`office_id` must be an active `asset_office_addresses` row." ), ) async def recycle_asset_to_office( asset_id: str, office_id: str, reason: str, ) -> dict: async with tool_runtime( "recycle_asset_to_office", {"asset_id": asset_id, "office_id": office_id, "reason": reason}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) await _mcp_assert_target_office_in_region(rt, office_id) reason = _bound_free_text(reason, field="reason", max_len=2000) or "" from app.domains.asset.service import AssetService asset, op = await AssetService(rt.db).recycle_to_office( _u(asset_id), office_id=_u(office_id), reason=reason, operator_id=rt.user.id, source="manual", ) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="release_asset_from_office", description=( "**Asset-admin only.** Inverse of `recycle_asset_to_office`. " "Releases an office-held asset back to `status='idle'` with " "`holder_kind='personal'` — ready for someone to `claim_asset`. " "Use when the office is shipping the device to a new hire or " "permanently moving it out of the office pool. Only valid for " "`status='held_by_office'` rows." ), ) async def release_asset_from_office( asset_id: str, notes: str | None = None, ) -> dict: async with tool_runtime( "release_asset_from_office", {"asset_id": asset_id, "notes": notes}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) notes = _bound_free_text(notes or "", field="notes", max_len=2000) or None from app.domains.asset.service import AssetService asset, op = await AssetService(rt.db).release_from_office( _u(asset_id), notes=notes, operator_id=rt.user.id, ) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } @mcp_server.tool( name="list_recycle_bin_assets", description=( "**Asset-admin only.** List soft-deleted assets in the recycle " "bin (newest deletion first) so an AI client can answer 'what " "deleted assets can I restore?' and find the id to hand to " "`restore_asset`. Mirrors the HTTP /assets/recycle-bin route's " "`require_asset_admin` gate — non-admins get 403. Bounded " "pagination — max 200 rows per call. Optional `search` matches " "asset tag / name / serial number." ), ) async def list_recycle_bin_assets( page: int = 1, page_size: int = 200, search: str | None = None, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_recycle_bin_assets", {"page": page, "page_size": page_size, "search": search}, ) as rt: from app.domains.asset.service import AssetService _require_asset_admin(rt) rows, total = await AssetService(rt.db).list_deleted_assets( page=page, page_size=page_size, search=search, ) return { "assets": [_serialize_asset(a) for a in rows], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="restore_asset", description=( "**Asset-admin only.** Restore a soft-deleted asset from the " "recycle bin: clears the delete and brings the row back with its " "original tag / serial / history intact. Mirrors the HTTP " "/assets/{id}/restore route's `require_asset_admin` gate. Find " "restorable ids with `list_recycle_bin_assets`. Fails 409 if the " "asset isn't actually deleted." ), ) async def restore_asset(asset_id: str) -> dict: async with tool_runtime( "restore_asset", {"asset_id": asset_id}, mutates=True, ) as rt: from app.core.dependencies import assert_can_manage_asset from app.core.exceptions import NotFoundError from app.domains.asset.models import AssetItem from app.domains.asset.service import AssetService _require_asset_admin(rt) # Region check on a soft-deleted asset — bypass the # ``deleted_at IS NULL`` filter in ``_mcp_enforce_asset_region`` # by loading directly. Mirrors the HTTP /restore endpoint's # inline check. asset_uuid = _u(asset_id) target = await rt.db.get(AssetItem, asset_uuid) if target is None: raise NotFoundError("Asset", str(asset_uuid)) await assert_can_manage_asset(rt.db, rt.user, target) asset = await AssetService(rt.db).restore_asset( asset_uuid, operator_id=rt.user.id, ) return {"asset": _serialize_asset(asset)} @mcp_server.tool( name="return_borrowed_asset", description=( "**Asset-admin only.** Return a borrowed asset before/after the " "expected_return_date. Mirrors the HTTP route's " "`require_asset_admin` gate — non-admins get 403." ), ) async def return_borrowed_asset(asset_id: str, notes: str | None = None) -> dict: async with tool_runtime( "return_borrowed_asset", {"asset_id": asset_id, "notes": notes}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) from app.domains.asset.schemas import AssetBorrowReturnRequest from app.domains.asset.service import AssetService body = AssetBorrowReturnRequest(notes=notes) asset, op = await AssetService(rt.db).return_borrowed_asset( _u(asset_id), body, rt.user.id, ) return { "asset": _serialize_asset(asset), "operation": { "operation_number": op.operation_number, "operation_type": op.operation_type, "status": op.status, }, } # ════════════════════════════════════════════════════════════════════ # Software seats / catalog # ════════════════════════════════════════════════════════════════════ #: Characters that carry no meaning in a product name people type from memory. #: Production, 2026-08-18: an employee asked for 「OPEN AI 的坐席」 three ways #: (「openai」/「open ai」/「OPEN AI」) and the assistant answered that OpenAI is #: not in Helix's software catalogue — while the catalogue held 「OPEN AI」 with a #: live, active seat plan the web UI happily shows a 申请坐席 button for. _NAME_NOISE = str.maketrans({c: None for c in " \t-_.·・()()[]"}) def _normalise_product_name(value: str) -> str: return (value or "").translate(_NAME_NOISE).casefold() #: Shortest name that may be searched for INSIDE a whole typed sentence, by #: character class. Taken from the real catalogue rather than guessed: the #: shortest live names are 2-character CJK (「飞书」「北森」「飞连」) and #: 3-character ASCII acronyms (``AWS``, ``GCP``) — a flat minimum of 4 excluded #: every one of them from sentence matching, so 「帮我申请飞书的坐席」 stopped #: finding 飞书 while the tool description still promised that 0 matches means #: it genuinely is not there. CJK carries more meaning per character, which is #: why the two differ; the point of any minimum at all is that a 2-letter Latin #: name would match almost any sentence. _REVERSE_MATCH_MIN_CJK = 2 _REVERSE_MATCH_MIN_ASCII = 3 def _catalogue_name_matches(row: Any, search: str) -> bool: """Whether the search term picked this row out **by name**. Forward: the typed name appears in the stored one, which is what makes 「openai」 find 「OPEN AI」. Reverse: the stored name appears inside a whole typed sentence, so 「我要申请 openai 的坐席」 finds it too — only when the stored name is long enough to mean something on its own. """ needle = _normalise_product_name(search) name = _normalise_product_name(str(getattr(row, "name", "") or "")) if not needle or not name: return False if needle in name: return True minimum = ( _REVERSE_MATCH_MIN_ASCII if name.isascii() else _REVERSE_MATCH_MIN_CJK ) return len(name) >= minimum and name in needle def _catalogue_matches(row: Any, search: str) -> bool: """Whether one catalogue row is what the person meant. The name decides it (see :func:`_catalogue_name_matches`); a forward match in the description or the remark also counts, so a product found by what it is used for still surfaces. The reverse direction stays off those two on purpose: a description is prose and a remark is a note, and matching a whole sentence against a fragment of either is how an unrelated product ends up on a confirmation card. """ needle = _normalise_product_name(search) if not needle: return True if _catalogue_name_matches(row, search): return True return any( value and needle in value for value in ( _normalise_product_name(str(getattr(row, field, "") or "")) for field in ("description", "remark") ) ) #: A catalogue row's long-form 详细介绍 is MEDIUMTEXT — one entry in production #: runs to 2.5k characters of integration write-up. Whole rows of that turn a #: listing into something no caller can read: the assistant's bridge hands the #: model at most a few thousand characters, so an unbounded listing arrives #: sliced mid-product and the products past the cut look like they do not #: exist. Full text is worth the room for ONE product the caller asked about by #: name; across a listing it is not. _CATALOGUE_TEXT_CHARS_IN_LIST = 400 _CATALOGUE_TEXT_CHARS_SINGLE = 4000 _CATALOGUE_REMARK_CHARS = 200 def _catalogue_role_quality( *, raw_value: str | None, person_id: Any, person_name: str | None, ) -> str: """Classify whether a role is safe to answer as an employee. The free-text columns predate the canonical person foreign keys and may contain a team name (or a stale spelling). Returning this explicitly keeps an LLM from promoting legacy text into a named employee. """ if person_id and person_name: return "canonical_person" if person_id: return "broken_person_reference" if raw_value: return "legacy_text_only" return "missing" def _clip(value: str | None, limit: int) -> str | None: """``value`` shortened to ``limit`` characters, marked when it was cut.""" if value is None: return None text = str(value) return text if len(text) <= limit else text[:limit] + "…[truncated]" @mcp_server.tool( name="list_software_catalog", description=( "The company's software / application catalogue (Zoom, GitHub, Figma, " "NetSuite, …) — the same 软件目录 employees browse on the web. Use it " "for ANY question about an application: who owns it (`owner` / " "负责人), who to contact in IT (`it_contact` / IT 联系人), which " "department runs it, its version, login URL, what it is for, and its " "license plans (plan_id is what request_software_seat needs; each " "plan carries name, price, billing_cycle, max_seats). " "Pass `search` to look ONE product up by name — matching ignores " "case, spaces and punctuation, so 'openai' finds the entry stored " "as 'OPEN AI'. ALWAYS check here before telling anyone a product is " "not in the catalogue: `total_matched` is 0 only when it genuinely " "is not there. Note this is NOT list_sso_apps: that one is only the " "Google SAML integrations, and a product absent there is usually " "present here. For owner / IT-contact answers, use ONLY the nested " "owner_person / it_contact_person objects. The raw owner and " "it_contact fields are legacy display text and may be a team or stale " "value. `data_quality.owner` and `data_quality.it_contact` tell you " "whether a named employee is canonical, unresolved, legacy-only, or " "missing; never guess a person when the canonical object is null." ), ) async def list_software_catalog( page: int = 1, page_size: int = 100, search: Annotated[ str | None, Field(default=None, description=( "Product name to look for, in whatever form the person said it. " "Case, spaces and punctuation are ignored." )), ] = None, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_software_catalog", {"page": page, "page_size": page_size, "search": search}, ) as rt: from app.domains.asset.models import AssetSoftwareCatalog, SoftwareLicensePlan from app.domains.asset.router import _enrich_software_catalog q = ( select(AssetSoftwareCatalog) .where(AssetSoftwareCatalog.status == "active") .order_by(AssetSoftwareCatalog.name) ) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 all_rows = list((await rt.db.execute(q)).scalars().all()) # Filtered in Python, over the ACTIVE catalogue only (tens of rows, one # per purchased product). The normalisation is the whole point of the # parameter — 「openai」 has to find the row stored as 「OPEN AI」 — # and nested SQL ``REPLACE()``s express it far less readably than a # helper a test can call directly. # A ``search`` of only spaces or punctuation normalises to nothing, which # is not a filter — and reporting it as one told the reader every product # matched what they typed. Treated as absent, and echoed back as absent. if search and not _normalise_product_name(search): search = None matched = ( [r for r in all_rows if _catalogue_matches(r, search)] if search else all_rows ) total_matched = len(matched) rows = matched[(page - 1) * page_size:page * page_size] item_ids = [r.id for r in rows] plans_by_item: dict[Any, list] = {} if item_ids: plan_rows = list(( await rt.db.execute( select(SoftwareLicensePlan) .where( SoftwareLicensePlan.software_id.in_(item_ids), SoftwareLicensePlan.status == "active", ) ) ).scalars().all()) for p in plan_rows: plans_by_item.setdefault(p.software_id, []).append({ "id": str(p.id), "name": p.name, "billing_cycle": p.billing_cycle, "price": str(p.price) if p.price is not None else None, "max_seats": p.max_seats, "description": p.description, }) # 负责人 / IT 联系人 resolved to a real name and address, the same way # the web catalogue resolves them. Without these the tool could list a # product but not answer the first question anyone asks about one — # production, 2026-08-19: 「Netsuite 这个应用的负责人是谁」 got 「Helix # 没有记录这项应用的负责人信息」 while the web page had 邱芳 in the # 负责人 column. Both columns are already visible to every authenticated # user on ``/assets/software-catalog/public``, so this exposes nothing # new — it stops the assistant from denying what the page shows. enriched = { d["id"]: d for d in await _enrich_software_catalog(rt.db, rows) } # Long-form text is full for the product the caller actually named, and # a summary everywhere else. Keyed on the NAME matching rather than on # the result being a single row: 「Netsuite」 also matches Salesforce, # whose write-up happens to mention NetSuite, and the row the caller # asked about should not lose its detail to that coincidence. items = [] for r in rows: text_limit = ( _CATALOGUE_TEXT_CHARS_SINGLE if search and _catalogue_name_matches(r, search) else _CATALOGUE_TEXT_CHARS_IN_LIST ) extra = enriched.get(str(r.id)) or enriched.get(r.id) or {} item = { "id": str(r.id), "name": r.name, "version": r.version, "asset_type": r.asset_type, "department": r.department, # 负责人 as the admin recorded it — may be a team ("财务部") # rather than a person, which is why the raw text is kept # alongside the resolved person. "owner": r.owner, "owner_person_name": extra.get("owner_person_name"), "owner_person_email": extra.get("owner_person_email"), "owner_person": _catalogue_enriched_person_payload(extra, "owner"), "it_contact": r.it_contact, "it_contact_person_name": extra.get("it_contact_person_name"), "it_contact_person_email": extra.get("it_contact_person_email"), "it_contact_person": _catalogue_enriched_person_payload(extra, "it_contact"), "data_quality": { "owner": _catalogue_role_quality( raw_value=r.owner, person_id=extra.get("owner_person_id"), person_name=extra.get("owner_person_name"), ), "it_contact": _catalogue_role_quality( raw_value=r.it_contact, person_id=extra.get("it_contact_person_id"), person_name=extra.get("it_contact_person_name"), ), }, "login_url": r.login_url, "remark": _clip(r.remark, _CATALOGUE_REMARK_CHARS), "description": _clip(r.description, text_limit), "plans": plans_by_item.get(r.id, []), } # A departed owner is worth saying out loud — routing an employee # to someone who has left is worse than saying the row is stale. for role in ("owner", "it_contact"): if (extra.get(f"{role}_person_employment_status") or "active") != "active": item[f"{role}_person_has_left"] = True items.append({k: v for k, v in item.items() if v is not None}) return { "items": items, # With a ``search`` these are the SAME number, deliberately. # ``total`` is the field every caller already reads, so leaving it at # the unfiltered catalogue count while ``total_matched`` said 1 let a # reader believe 57 products matched. The unfiltered size is not a # fact anyone asked for on a filtered call. "total": total_matched if search else total, "total_matched": total_matched, "search": search, "page": page, "page_size": page_size, } @mcp_server.tool( name="list_my_software_seats", description=( "List the software seats currently allocated to the user. " "Bounded pagination — max 200 rows per call. Caller pagination " "ensures a power user (rare: 100+ seats) doesn't exhaust the " "LLM context with a single tool call." ), ) async def list_my_software_seats( page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_software_seats", {"page": page, "page_size": page_size}, ) as rt: from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareLicensePlan, SoftwareSeatAllocation, ) base_q = ( select(SoftwareSeatAllocation) .where(SoftwareSeatAllocation.user_id == rt.user.id) ) count_q = select(func.count()).select_from(base_q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 rows = list(( await rt.db.execute( base_q .order_by(SoftwareSeatAllocation.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) ).scalars().all()) plan_ids = list({r.plan_id for r in rows if r.plan_id}) plans: dict[Any, SoftwareLicensePlan] = {} if plan_ids: plans = { p.id: p for p in (await rt.db.execute( select(SoftwareLicensePlan).where(SoftwareLicensePlan.id.in_(plan_ids)) )).scalars().all() } software_ids = {p.software_id for p in plans.values()} software: dict[Any, AssetSoftwareCatalog] = {} if software_ids: software = { s.id: s for s in (await rt.db.execute( select(AssetSoftwareCatalog).where(AssetSoftwareCatalog.id.in_(software_ids)) )).scalars().all() } out = [] for r in rows: plan = plans.get(r.plan_id) sw = software.get(plan.software_id) if plan else None out.append({ "id": str(r.id), "plan_id": str(r.plan_id) if r.plan_id else None, "plan_name": plan.name if plan else None, "software_name": sw.name if sw else None, "billing_cycle": plan.billing_cycle if plan else None, "status": r.status, # ``allocated_at`` is non-null only after IT confirms # the seat — pending rows (created via # ``request_software_seat``) carry it as NULL until # the approval lands. Guarding here avoids a 500 the # moment a user lists their seats while one is # pending. Found in the launch-readiness audit # 2026-05-05. "allocated_at": ( _iso_utc(r.allocated_at) ), }) return { "seats": out, "total": int(total), "page": page, "page_size": page_size, } @mcp_server.tool( name="list_software_addon_items", description=( "List one-time add-on items — e.g. AI credit packs, vendor " "person-days. These are consumed one-off charges (priced " "unit_price × quantity), NOT held seats. Pass software_id to scope " "to one software, or omit it to list every active add-on item " "across all software (the unified request-catalog view). ``item_id`` " "from here is what request_software_addon takes. Only active items " "are returned; unit_price is omitted for callers without " "finance/admin scope." ), ) async def list_software_addon_items(software_id: str | None = None) -> dict: async with tool_runtime( "list_software_addon_items", {"software_id": software_id}, ) as rt: from app.core.rbac import can_manage_assets, can_view_finance from app.domains.asset import addon_service from app.domains.asset.models import AssetSoftwareCatalog show_price = can_view_finance(rt.user.role_names) or can_manage_assets( rt.user.role_names, ) sw_name: str | None = None if software_id: sw_uuid = _u(software_id) items = await addon_service.list_items_for_software( rt.db, sw_uuid, include_inactive=False, ) sw_name = (await rt.db.execute( select(AssetSoftwareCatalog.name).where( AssetSoftwareCatalog.id == sw_uuid ) )).scalar_one_or_none() else: items = await addon_service.list_active_items_all(rt.db) return { "software_id": software_id, "software_name": sw_name, "items": [ { "item_id": str(i.id), "software_id": str(i.software_id), "software_name": (i.software.name if i.software else None), "name": i.name, "unit_label": i.unit_label, "unit_price": (str(i.unit_price) if show_price else None), "currency": i.currency, "min_quantity": i.min_quantity, "step": i.step, "max_quantity": i.max_quantity, "description": i.description, } for i in items ], } @mcp_server.tool( name="request_software_addon", description=( "Request a one-time software add-on purchase — e.g. 2500 AI credits, " "3 vendor person-days. item_id from list_software_addon_items; " "quantity must respect the item's min/step/max envelope. Creates a " "pending SoftwareAddonCharge and (if a Feishu approval flow is " "configured for software_addon) submits it for IT/Asset admin " "review. This is a one-time spend, NOT a recurring seat — on " "approval it is billed once into the software's and the requester's " "department cost. Repeat purchases are allowed, but each logical " "purchase needs a stable idempotency_key retained across retries." ), ) async def request_software_addon( item_id: str, quantity: int, idempotency_key: str, reason: str | None = None, ) -> dict: async with tool_runtime( "request_software_addon", { "item_id": item_id, "quantity": quantity, "idempotency_key": idempotency_key, "reason": reason, }, mutates=True, ) as rt: from app.domains.asset import addon_service reason = _bound_free_text(reason, field="reason", max_len=2000) charge = await addon_service.request_charge( rt.db, item_id=_u(item_id), quantity=quantity, reason=reason, current_user=rt.user, trace_id=_trace_str(), idempotency_key=idempotency_key, ) item = charge.item return { "id": str(charge.id), "item_id": str(charge.item_id), "item_name": item.name if item else None, "software_name": ( item.software.name if item and item.software else None ), "quantity": charge.quantity, "unit_price": str(charge.unit_price), "currency": charge.currency, "amount": str(charge.amount), "status": charge.status, } @mcp_server.tool( name="list_my_software_addon_charges", description=( "List the caller's one-time software add-on charges (AI credits, " "person-days, …) across all statuses (pending / approved / rejected " "/ cancelled). Bounded pagination — max 200 rows." ), ) async def list_my_software_addon_charges( page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_software_addon_charges", {"page": page, "page_size": page_size}, ) as rt: from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareAddonCharge, SoftwareAddonItem, ) base_q = select(SoftwareAddonCharge).where( SoftwareAddonCharge.user_id == rt.user.id, ) total = (await rt.db.execute( select(func.count()).select_from(base_q.subquery()) )).scalar() or 0 rows = list((await rt.db.execute( select(SoftwareAddonCharge, SoftwareAddonItem, AssetSoftwareCatalog) .join( SoftwareAddonItem, SoftwareAddonCharge.item_id == SoftwareAddonItem.id, ) .outerjoin( AssetSoftwareCatalog, SoftwareAddonCharge.software_id == AssetSoftwareCatalog.id, ) .where(SoftwareAddonCharge.user_id == rt.user.id) .order_by(SoftwareAddonCharge.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) )).all()) return { "charges": [ { "id": str(c.id), "item_name": item.name if item else None, "software_name": sw.name if sw else None, "quantity": c.quantity, "unit_price": str(c.unit_price), "currency": c.currency, "amount": str(c.amount), "status": c.status, "period": c.period, "created_at": _iso_utc(c.created_at), "approved_at": _iso_utc(c.approved_at), } for c, item, sw in rows ], "total": int(total), "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_software_addon_charge", description=( "Cancel one of the caller's own still-pending software add-on " "charges. Approved charges are already-spent money and cannot be " "self-cancelled. charge_id from list_my_software_addon_charges." ), ) async def cancel_my_software_addon_charge(charge_id: str) -> dict: async with tool_runtime( "cancel_my_software_addon_charge", {"charge_id": charge_id}, mutates=True, ) as rt: from app.domains.asset import addon_service charge = await addon_service.cancel_charge( rt.db, charge_id=_u(charge_id), current_user=rt.user, trace_id=_trace_str(), ) return {"id": str(charge.id), "status": charge.status} @mcp_server.tool( name="list_scheduled_software_cancellations", description=( "Admin-only: list every active software seat that has a pending " "cancellation (employee or admin set " "``cancel_at_period_end`` — the seat is alive now and will be " "auto-revoked by the ``software_seat.auto_revoke_pending_cancels`` " "cron at the next period boundary). Powers the Software Seat " "Cancellations overview at /admin/software/cancellations and " "lets AI clients answer 'whose seats are scheduled to be cut next " "month?' without scraping the dashboard. Mirrors the matching " "HTTP route ``GET /assets/seat-allocations?has_pending_cancel=true``." ), ) async def list_scheduled_software_cancellations( page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_scheduled_software_cancellations", {"page": page, "page_size": page_size}, ) as rt: from app.core.exceptions import AppException from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareLicensePlan, SoftwareSeatAllocation, ) from app.identity.person_models import IdentityPerson # Admin read surface — management (read-only leadership view) # admitted alongside global_admin per the role-matrix design. # Mutations on individual cancellations stay admin-only. if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE): raise AppException( code="FORBIDDEN", message="软件坐席取消中心仅 IT 管理员 / 管理层可访问", status_code=403, ) base_q = ( select(SoftwareSeatAllocation) .where(SoftwareSeatAllocation.cancel_at_period_end.is_not(None)) .where(SoftwareSeatAllocation.status == "active") ) count_q = select(func.count()).select_from(base_q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 rows = list(( await rt.db.execute( base_q .order_by(SoftwareSeatAllocation.cancel_at_period_end.asc()) .offset((page - 1) * page_size) .limit(page_size) ) ).scalars().all()) plan_ids = list({r.plan_id for r in rows if r.plan_id}) plans: dict[Any, SoftwareLicensePlan] = {} if plan_ids: plans = { p.id: p for p in (await rt.db.execute( select(SoftwareLicensePlan).where(SoftwareLicensePlan.id.in_(plan_ids)) )).scalars().all() } software_ids = {p.software_id for p in plans.values()} software: dict[Any, AssetSoftwareCatalog] = {} if software_ids: software = { s.id: s for s in (await rt.db.execute( select(AssetSoftwareCatalog).where(AssetSoftwareCatalog.id.in_(software_ids)) )).scalars().all() } user_ids = list({r.user_id for r in rows}) users: dict[Any, IdentityPerson] = {} if user_ids: users = { u.id: u for u in (await rt.db.execute( select(IdentityPerson).where(IdentityPerson.id.in_(user_ids)) )).scalars().all() } out = [] for r in rows: plan = plans.get(r.plan_id) sw = software.get(plan.software_id) if plan else None user = users.get(r.user_id) out.append({ "id": str(r.id), "user_id": str(r.user_id), "user_name": user.display_name if user else None, "user_email": user.work_email if user else None, "plan_id": str(r.plan_id) if r.plan_id else None, "plan_name": plan.name if plan else None, "software_name": sw.name if sw else None, "billing_cycle": plan.billing_cycle if plan else None, "cancel_at_period_end": ( _iso_utc(r.cancel_at_period_end) if r.cancel_at_period_end else None ), "cancel_requested_at": ( _iso_utc(r.cancel_requested_at) if r.cancel_requested_at else None ), "cancel_reason": r.cancel_reason, }) return { "items": out, "total": int(total), "page": page, "page_size": page_size, } _RECON_READ_ROLES = ( "global_admin", "asset_admin", "finance", "auditor", "management", ) @mcp_server.tool( name="provisioning_gap_overview", description=( "Admin/auditor read: access Helix believes it granted but never " "actually delivered to the vendor. Four independent buckets — " "approvals still 'pending' here against an already-terminal vendor " "status; seats marked active whose grant task never completed; " "overdue vendor calls (past their not_before defer gate); and " "failed vendor calls. Answers 'the approval says approved — why " "does the vendor console show nothing?', which on 2026-08-06 took " "20 hours and a human diffing two consoles to notice. Predicates " "are deliberately narrow (vendor-imported seats and deferred " "leaver revokes are excluded), so a non-zero count is actionable " "rather than noise. Mirrors HTTP ``GET " "/admin/reconciliation/provisioning-gap``." ), ) async def provisioning_gap_overview() -> dict: async with tool_runtime("provisioning_gap_overview", {}) as rt: from app.domains.reconciliation.service import provisioning_gap # Must match the HTTP twin's ``require_admin_read()`` = # {global_admin, auditor, management}. The broader # ``_RECON_READ_ROLES`` would over-serve finance + asset_admin — # the same call the sibling ``cost_rollup_drift`` tool already # made. It matters here because ``stale_approvals`` is NOT # filtered by ``approval_type``: it returns applicant emails + # Feishu instance codes for EVERY approval domain (VPN, API key, # data-access group, cloud scope, SSO grant …), none of which an # location-scoped ``asset_admin`` — a role auto-derived from # ``asset_location_admins`` — may read over HTTP. _require_admin_read(rt.user.role_names) return await provisioning_gap(rt.db) @mcp_server.tool( name="seat_reconciliation_overview", description=( "Admin/finance/auditor read: company-wide software-seat " "reconciliation — one row per integration-backed software showing " "vendor license holders vs employee-occupied seats vs EXTERNAL " "(unmatched) holders vs scheduled-cancel, plus last-sync time. " "Answers 'why does the vendor bill 72 but Helix shows 69 seats?' " "(the 3 are external accounts that match no Helix employee). " "Mirrors HTTP ``GET /assets/seat-reconciliation``; drill into one " "software with ``software_seat_reconciliation_detail``." ), ) async def seat_reconciliation_overview() -> dict: async with tool_runtime("seat_reconciliation_overview", {}) as rt: from app.core.exceptions import AppException from app.core.rbac import has_role from app.domains.asset.router import ( _compute_seat_reconciliation_overview, ) if not has_role(rt.user.role_names, *_RECON_READ_ROLES): raise AppException( code="FORBIDDEN", message="软件坐席对账仅 IT/资产/财务/审计/管理层可访问", status_code=403, ) items = await _compute_seat_reconciliation_overview(rt.db) return {"items": items, "total": len(items)} @mcp_server.tool( name="software_seat_reconciliation_detail", description=( "Admin/finance/auditor read: per-software seat-reconciliation " "detail — headline counts (vendor / occupied / external / " "scheduled-cancel), per-plan snapshot, the paginated list of " "EXTERNAL license holders (email, vendor account id/status, " "product/site, first/last seen, vendor-admin deep-link), and the " "live scheduled-cancel ('下个月不占') seat list. Pass " "``software_id`` (UUID). ``include_resolved=true`` also returns " "holders that have since fallen off the vendor side. ``category`` " "server-filters the holder list: 'anomalies' (the four anomaly " "categories minus known/expected accounts — matches the 全部异常 " "badge), a single category ('external' / 'leaver_residual' / " "'vendor_inactive' / 'off_paid_site' / 'no_email'), or omit for " "every row. Mirrors HTTP " "``GET /assets/seat-reconciliation/{software_id}``." ), ) async def software_seat_reconciliation_detail( software_id: str, page: int = 1, page_size: int = 50, include_resolved: bool = False, category: str | None = None, ) -> dict: import uuid as _uuid page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "software_seat_reconciliation_detail", { "software_id": software_id, "page": page, "page_size": page_size, "category": category, }, ) as rt: from app.core.exceptions import AppException from app.core.rbac import has_role from app.domains.asset.router import ( _compute_seat_reconciliation_detail, ) if not has_role(rt.user.role_names, *_RECON_READ_ROLES): raise AppException( code="FORBIDDEN", message="软件坐席对账仅 IT/资产/财务/审计/管理层可访问", status_code=403, ) try: sid = _uuid.UUID(str(software_id)) except (ValueError, AttributeError, TypeError) as e: raise AppException( code="INVALID_ARGUMENT", message=f"software_id 不是合法 UUID: {software_id!r}", status_code=400, ) from e return await _compute_seat_reconciliation_detail( rt.db, sid, page=page, page_size=page_size, include_resolved=bool(include_resolved), category=category, ) @mcp_server.tool( name="cost_rollup_drift", description=( "Admin/finance/auditor read: AI-spend cost reconciliation. For " "each recent CLOSED month and each bucket (seats / bailian / " "foundry / bedrock / vertex), compares the materialised " "ai_spending_period_rollup value against a fresh LIVE recompute " "from the same in-platform sources (cmdb_cost_facts / " "bailian_usage_daily / seat allocations). They should be equal; a " "supra-threshold gap (>max($1, 0.5%)) is a drift finding tagged " "with a likely_cause (stale_fingerprint / not_materialized / " "late_facts_or_drift). Answers 'does the AI dashboard's cached " "rollup still match what a live recompute would show?'. Does NOT " "reconcile against cloud/Bailian consoles (no API-equivalent " "basis — see data_caveat). Mirrors HTTP " "``GET /admin/reconciliation/cost``. ``months_back`` (1-13, " "default 3) is how many closed months to check." ), ) async def cost_rollup_drift(months_back: int = 3) -> dict: months_back = max(1, min(int(months_back), 13)) async with tool_runtime( "cost_rollup_drift", {"months_back": months_back}, ) as rt: from app.domains.ai_spending.cost_drift import ( cost_rollup_drift as _compute_cost_rollup_drift, ) # HTTP twin GET /admin/reconciliation/cost gates on # require_admin_read (global_admin / auditor / management) — the # broader _RECON_READ_ROLES would over-serve finance + asset_admin. _require_admin_read(rt.user.role_names) return await _compute_cost_rollup_drift( rt.db, months_back=months_back, ) @mcp_server.tool( name="software_billing_preview_period", description=( "Admin-only: live preview of the software-billing bill for a " "given period (YYYY-MM). For periods that haven't been closed " "yet — including the current month — recomputes the bill on " "the fly from active + revoked seat allocations. For closed " "periods, returns the frozen snapshot. Authoritative source " "for 'what do we owe this month?' answers; mirrors the HTTP " "preview/list endpoints under /assets/software-billing per " "the MCP-sync rule. Critically, monthly-paid seats revoked " "mid-period STILL appear at full price — vendors do not " "refund mid-cycle. **Items are capped at the first 200 rows** " "to keep the MCP payload tight; ``items_truncated=true`` and " "``item_count`` in the response flag when the cap kicked in — " "use the HTTP route /assets/software-billing/{period}/all for " "full pagination. Aggregates (``total_amount``, " "``by_currency``, ``active_seats``) are computed on the FULL " "set and remain authoritative regardless of truncation. RBAC: " "global_admin / asset_admin." ), ) async def software_billing_preview_period(period: str) -> dict: async with tool_runtime( "software_billing_preview_period", {"period": period}, ) as rt: # HTTP twin admits finance (require_software_read) — billing is # finance-owned data; asset-admin-only would 403 finance via MCP. _require_software_read(rt.user.role_names) from app.domains.asset.billing_service import ( get_period_status, list_period_bills, preview_current_or_draft, ) status = await get_period_status(rt.db, period) if status == "finalized": data = await list_period_bills(rt.db, period) else: data = await preview_current_or_draft(rt.db, period) # Trim items to a bounded payload to keep MCP responses tight # even for a 5k-row fleet; callers wanting full pagination # should use the HTTP endpoint. Total is preserved so AI # clients can tell when truncation happened. items = data.get("items", []) truncated = len(items) > 200 return { "period": data.get("period", period), "status": status, "total_amount": ( str(data["total_amount"]) if data.get("total_amount") is not None else None ), "by_currency": data.get("by_currency", {}), "currency_warning": data.get("currency_warning", False), "active_seats": data.get("active_seats"), "finalized_at": ( _iso_utc(data["finalized_at"]) if data.get("finalized_at") else None ), "item_count": len(items), "items_truncated": truncated, "items": [ { "user_id": str(it.get("user_id")) if it.get("user_id") else None, "user_name": it.get("user_name"), "user_email": it.get("user_email"), "department": it.get("department"), "software_name": it.get("software_name"), "plan_name": it.get("plan_name"), "billing_cycle": it.get("billing_cycle"), "price": ( str(it["price"]) if it.get("price") is not None else None ), "amount": ( str(it["amount"]) if it.get("amount") is not None else None ), "currency": it.get("currency"), "status": it.get("status"), "allocated_at": ( _iso_utc(it["allocated_at"]) if it.get("allocated_at") else None ), "revoked_at": ( _iso_utc(it["revoked_at"]) if it.get("revoked_at") else None ), } for it in items[:200] ], } @mcp_server.tool( name="list_software_billing_periods", description=( "Admin-only: list known software-billing periods with their " "close status + headline totals. Trailing 12 months by " "default; bounded by ``months`` (1-36). Each entry carries " "``status`` ∈ {finalized, draft, available} so AI clients " "can decide whether to surface a close action. Mirrors the " "HTTP route under /assets/software-billing/periods per the " "MCP-sync rule. RBAC: global_admin / asset_admin." ), ) async def list_software_billing_periods(months: int = 12) -> dict: # Clamp aggressively — 36 months × per-period totals is still a # tight payload, and beyond that the UI doesn't render the rows. if months < 1: months = 1 if months > 36: months = 36 async with tool_runtime( "list_software_billing_periods", {"months": months}, ) as rt: # HTTP twin admits finance (require_software_read) — billing is # finance-owned data; asset-admin-only would 403 finance via MCP. _require_software_read(rt.user.role_names) from app.domains.asset.billing_service import list_known_periods rows = await list_known_periods(rt.db, months=months) return { "periods": [ { "period": r.get("period"), "status": r.get("status"), "total_amount": ( str(r["total_amount"]) if r.get("total_amount") is not None else None ), "by_currency": r.get("by_currency", {}), "currency_warning": r.get("currency_warning", False), "active_seats": r.get("active_seats"), "finalized_at": ( _iso_utc(r["finalized_at"]) if r.get("finalized_at") else None ), } for r in rows ], "months_window": months, } @mcp_server.tool( name="software_cost_range_summary", description=( "'这些软件在这段时间分别一共花了多少' — per-software software " "cost totals across a MONTH RANGE (start/end are YYYY-MM, " "inclusive, ≤24 months apart, either order accepted). " "``software_ids`` narrows to specific catalog rows (from " "list_software_catalog); ``department`` narrows to an org " "subtree. Mirrors the HTTP route " "/assets/software-billing/range-summary per the MCP-sync rule " "and shares its caliber: a CLOSED month is read from the FROZEN " "monthly snapshot (so a later price edit never moves it) while " "an unclosed month is recomputed live — ``live_periods`` names " "those, and ``excluded_periods`` names any unclosed month past " "the live-preview cap that is therefore NOT in the totals (a " "non-empty list means the total is short; close those months " "first), and ``future_periods`` names months later than the " "current one — never computed, never projected, not in the " "totals. Amounts stay in NATIVE currency, keyed per currency in " "``by_currency`` — never FX-converted and never summed across " "currencies; a non-empty ``currency_drift`` means part of that " "split is a STALE LABEL on a closed month (it names the software, " "the months and the software's current plan currency), not a " "second real billing currency. Use finance_spend_pivot instead when you want the " "USD-normalised ledger lens across cost types. RBAC: " "global_admin / asset_admin / finance / auditor / management " "(company-wide read)." ), ) async def software_cost_range_summary( start: str, end: str, software_ids: list[str] | None = None, department: str | None = None, ) -> dict: async with tool_runtime( "software_cost_range_summary", { "start": start, "end": end, "software_ids": software_ids, "department": department, }, ) as rt: # Company-wide lens → same gate as the HTTP ``view=all`` branch # (require_software_read: admin / asset_admin / finance / auditor # / management). _require_software_read(rt.user.role_names) from app.core.exceptions import ValidationError from app.domains.asset.billing_service import summarize_range parsed: list[UUID] = [] for raw in software_ids or []: try: parsed.append(UUID(str(raw))) except (TypeError, ValueError) as exc: raise ValidationError( f"software_ids must be UUIDs, got {_short_repr(raw)}", ) from exc data = await summarize_range( rt.db, start, end, software_ids=parsed, department=department, ) # Drop the per-month matrix from the MCP payload — an AI client # asking "分别花了多少" wants the per-software range totals, and # 24 months × N software of nested currency maps is a lot of # tokens for a detail the HTTP surface already renders. The # month-level split stays one call away via # software_billing_preview_period per period. return { "start": data["start"], "end": data["end"], "periods": data["periods"], "live_periods": data["live_periods"], "excluded_periods": data["excluded_periods"], # Months later than the current one: reported, never computed — # the tool must not hand an AI client a projection to quote. "future_periods": data["future_periods"], # Frozen months whose currency label disagrees with the # software's CURRENT plan currency — a non-empty list means part # of the per-currency split is a stale label, not two real # billing currencies. Report it; never convert or merge buckets. "currency_drift": data["currency_drift"], "software": [ { "software_id": row["software_id"], "software_name": row["software_name"], "by_currency": row["by_currency"], "distinct_users": row["distinct_users"], } for row in data["software"] ], "totals_by_currency": data["totals"]["by_currency"], "currency_warning": data["currency_warning"], "distinct_users": data["distinct_users"], } @mcp_server.tool( name="request_software_seat", description=( "Request a software seat — e.g. a Zoom license, GitHub seat, " "Figma editor seat. plan_id from list_software_catalog. The " "request creates a pending SoftwareSeatAllocation and (if a " "Feishu approval flow is configured for software_seat) submits " "to Feishu for IT/Asset admin review. Quota over-subscription " "is rejected up-front by check_plan_quota. Pass one stable " "idempotency_key per logical request and reuse it after a timeout." ), ) async def request_software_seat( plan_id: str, idempotency_key: str, reason: str | None = None, ) -> dict: async with tool_runtime( "request_software_seat", { "plan_id": plan_id, "idempotency_key": idempotency_key, "reason": reason, }, mutates=True, ) as rt: # Mirror /assets/seat-requests exactly: validate plan, check # quota, create allocation, attempt Feishu approval. Keeping # the logic in lockstep with the HTTP endpoint avoids two code # paths drifting in their feasibility checks. from sqlalchemy.exc import IntegrityError from app.approval.schemas import AssetApprovalSubmitRequest from app.approval.service import ApprovalService from app.approval.submission_idempotency import ( normalize_approval_idempotency_key, stable_approval_resource_id, ) from app.core.coordinators.provisioning_coordinator import ( check_no_active_seat_request, check_plan_quota, ) from app.core.exceptions import AppException, NotFoundError from app.domains.asset.models import ( SoftwareLicensePlan, SoftwareSeatAllocation, ) # Cap free-text at the MCP boundary — written verbatim into # ``SoftwareSeatAllocation.reason`` (TEXT) and into the Feishu # approval card payload below. HTTP twin ``SeatRequestCreate`` # caps at max_length=2000. (R4 logic sweep: Class C cap.) reason = _bound_free_text(reason, field="reason", max_len=2000) key = normalize_approval_idempotency_key(idempotency_key) allocation_id = stable_approval_resource_id( scope="software_seat", applicant_id=rt.user.id, idempotency_key=key, ) plan_uuid = _u(plan_id) plan = ( await rt.db.execute( select(SoftwareLicensePlan).where(SoftwareLicensePlan.id == plan_uuid) ) ).scalar_one_or_none() if not plan: raise NotFoundError("Software plan", plan_id) if plan.status != "active": raise AppException( code="PLAN_INACTIVE", message="License plan is not active", status_code=400, ) allocation = await rt.db.get(SoftwareSeatAllocation, allocation_id) allocation_created = allocation is None if allocation is not None: if ( allocation.user_id != rt.user.id or allocation.plan_id != plan.id or (allocation.reason or None) != (reason or None) ): raise AppException( code="APPROVAL_IDEMPOTENCY_PAYLOAD_MISMATCH", message=( "This idempotency_key was already used with a " "different software seat request." ), status_code=409, ) else: # The first attempt owns the normal seat/quota guards. A retry # after an uncertain Feishu response must address the durable # pending row instead of rejecting itself or minting another row. await check_no_active_seat_request( rt.db, plan_id=plan.id, user_id=rt.user.id, ) await check_plan_quota(rt.db, plan.id) candidate = SoftwareSeatAllocation( id=allocation_id, plan_id=plan.id, user_id=rt.user.id, status="pending", reason=reason, ) try: async with rt.db.begin_nested(): rt.db.add(candidate) await rt.db.flush() allocation = candidate except IntegrityError: allocation = await rt.db.get( SoftwareSeatAllocation, allocation_id, ) if allocation is None: raise AppException( code="APPROVAL_SUBMISSION_IN_PROGRESS", message="This software seat request is still in progress.", status_code=409, ) from None allocation_created = False assert allocation is not None approval_svc = ApprovalService(rt.db) feishu_config = (await approval_svc.template_config()).get("software_seat") approval_request_id: str | None = None approval_link_created = False # i18n: pick the caller's preferred_language (zh-CN / en-US) for # the billing-cycle label inside the answers payload. The form # KEYS themselves are written under BOTH Chinese and English # aliases (see ``feishu_answers`` below) so a Feishu admin # renaming ``软件名称`` ↔ ``Software Name`` survives without a # code change. The HTTP route does the same fan-out at # asset/router.py:4018 — this used to write only one language's # keys and silently dropped the form data on Feishu rename. lang = (rt.user.preferred_language or "zh-CN").lower() if lang.startswith("en") or lang.startswith("ja"): billing_labels = { "monthly": "Monthly", "yearly": "Yearly", "one_time": "One-time", } else: billing_labels = { "monthly": "月付", "yearly": "年付", "one_time": "一次性", } software_name = plan.software.name if plan.software else "Unknown" # ``plan.price`` can legitimately be Decimal(0) (free internal # tools, $0 trial seats); ``if plan.price`` is FALSY for # Decimal(0) and used to render as "-" — leaking through into # the Feishu approval card body. Use ``is not None`` to match # the sibling check at line 1646. Currency must come from # ``plan.currency`` (CNY default per asset/models.py:836), NOT # a hardcoded "$" — a CNY plan otherwise renders as "$1500/月付" # in the approval payload. (R1 code review finding 2026-05-28.) price_str = ( f"{plan.currency or 'CNY'} {plan.price}/" f"{billing_labels.get(plan.billing_cycle or '', plan.billing_cycle or '')}" if plan.price is not None else "-" ) if feishu_config: # Bilingual fan-out — write each value under EVERY known # alias for the widget. Unknown keys are stripped by the # serializer's widget-id filter, so duplicates are inert. # Same hardening as the HTTP twin (asset/router.py:4018). # Alias tuples MUST match asset/router.py:4047-4050 exactly. # A drift would mean MCP populates a widget the HTTP route # ignores (or vice-versa) on Feishu rename. Source of truth # for these aliases is the HTTP route; mirror them verbatim. feishu_answers: dict[str, Any] = {} for value, aliases in ( (software_name, ("软件名称", "Software Name")), (plan.name or "", ("坐席类型", "Plan Name", "Seat Type")), (price_str, ("单价", "Unit Price", "Price")), (reason or "", ("申请原因", "Reason", "Application Reason")), ): for alias in aliases: feishu_answers[alias] = value submit_body = AssetApprovalSubmitRequest( template_key="software_seat", reason=reason, answers=feishu_answers, ) # Pre-launch hardening 2026-05-04: ``extra_source_data`` / # ``extra_detail`` are now kw-only service params instead # of public-schema fields. approval_resp = await approval_svc.create_asset_approval_request( body=submit_body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), idempotency_key=key, internal_idempotency_payload={ "kind": "software_seat", "plan_id": str(plan.id), "reason": reason, "answers": {}, }, internal_extra_source_data={ "seat_allocation_id": str(allocation.id), }, # Stamp the resolved software / plan / provider onto the # request's ``detail`` so the "我的申请" + "管理员审批" # tables render dedicated columns without rejoining # software_license_plans → asset_software_catalog on # every list call. Mirrors the HTTP route. internal_extra_detail={ "software_seat": { "software_id": ( str(plan.software_id) if plan.software_id else None ), "software_name": ( software_name if plan.software else None ), "plan_id": str(plan.id), "plan_name": plan.name, "billing_cycle": plan.billing_cycle, "price": ( str(plan.price) if plan.price is not None else None ), "sso_provider": ( plan.software.sso_provider if plan.software else None ), "seat_allocation_id": str(allocation.id), }, }, ) approval_request_id = str(approval_resp.id) if allocation.approval_request_id is None: allocation.approval_request_id = approval_resp.id approval_link_created = True await rt.db.flush() # Domain audit row (matches HTTP route's # ``software_seat.request`` action). The auto-MCP audit row # gives "tool was called"; this gives "what business event". from app.audit.service import AuditService if allocation_created or approval_link_created: await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="software_seat.request", resource_type="software_seat_allocation", resource_id=str(allocation.id), status="success", detail={ "plan_id": str(plan.id), "software_name": software_name if plan.software else None, "approval_request_id": approval_request_id, "feishu_approval": bool(feishu_config), "via": "mcp", }, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return { "allocation_id": str(allocation.id), "plan_id": str(plan.id), "plan_name": plan.name, "software_id": ( str(plan.software_id) if plan.software_id else None ), "software_name": software_name if plan.software else None, "billing_cycle": plan.billing_cycle, "price": (str(plan.price) if plan.price is not None else None), "status": allocation.status, "approval_request_id": approval_request_id, "feishu_approval_used": bool(feishu_config), "allocated_at": ( _iso_utc(allocation.allocated_at) if allocation.allocated_at else None ), "created_at": ( _iso_utc(allocation.created_at) if allocation.created_at else None ), } # ════════════════════════════════════════════════════════════════════ # Approvals # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_approval_templates", description=( "List the approval templates available for asset / software / " "buyback / borrow flows. Returns each template's id, name, " "and field schema." ), ) async def list_approval_templates() -> dict: async with tool_runtime("list_approval_templates", {}): from app.approval.service import ApprovalService from app.core import database as _db # Cache-miss path WRITE-THROUGHS the template cache # (``_write_template_cache`` → PlatformSetting upsert). Under # the non-mutating runtime that write would silently roll # back, so the cache could never be (re)populated via MCP and # every post-TTL call would repeat the full Feishu definition # fan-out. Run the service call in a SEPARATE committed # session (same pattern as ``list_my_api_keys``' expiry # sweep) so the cache write lands while the tool itself stays # a leaver-readable non-mutating read. (R1 audit 2026-06-10.) async with _db.async_session_factory() as svc_db: templates = await ApprovalService(svc_db).list_asset_templates() await svc_db.commit() return {"templates": serialize(templates)} @mcp_server.tool( name="list_approval_flow_stages", description=( "Return EVERY Feishu-backed approval flow on file (asset / " "software / cloud / security) with each one's stage chain " "(node_id / name / node_type / approver_names / admin-curated " "note). Read access is open to any authenticated user — the " "same data is served by the public `/approval/flow-stages/" "{code}` HTTP endpoint. Pass refresh=true ONLY as IT/asset " "admin — that bursts the cache and fans out 14 parallel " "outbound calls to Feishu, which is expensive and would " "drain Feishu API quota if invoked by every employee." ), ) async def list_approval_flow_stages(refresh: bool = False) -> dict: # ``mutates=True`` only when ``refresh`` — that path is an # admin-gated quota-burning action and must honour the leaver # lock. The plain read stays non-mutating so leavers keep read # access. Either way the SERVICE call runs in a separate # committed session: ``list_all_flow_stages`` write-throughs the # flow-stages cache on ANY cache miss (expired / missing / # corrupted), not just on force_refresh — under a non-mutating # runtime that write silently rolled back, so the cache could # never be (re)populated via MCP and every post-TTL employee # call re-triggered the full 14-call Feishu fan-out the tool's # own description warns about. (R1 audit 2026-06-10.) async with tool_runtime( "list_approval_flow_stages", {"refresh": refresh}, mutates=bool(refresh), ) as rt: from app.approval.service import ApprovalService from app.core import database as _db from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_assets # AuthZ on ``refresh=True``: this is the same admin-only # gate the HTTP `/admin/flow-stages?refresh=true` endpoint # enforces. Reading the cache is fine for any user. if refresh and not can_manage_assets(rt.user.role_names): raise ForbiddenError( "Only IT/asset admins can force-refresh approval flow stages.", ) async with _db.async_session_factory() as svc_db: svc = ApprovalService(svc_db) if refresh: await svc.invalidate_flow_stages_cache() bundles = await svc.list_all_flow_stages(force_refresh=refresh) await svc_db.commit() return {"flows": serialize(bundles), "count": len(bundles)} @mcp_server.tool( name="set_approval_stage_note", description=( "Upsert (or delete) one IT-admin-authored note shown to " "employees on the approval-flow preview. Pass note=null or " "empty string to delete. Keyed by (approval_code, node_id) — " "use list_approval_flow_stages first to find the node_id. " "Admin-only (it_admin / asset_admin)." ), ) async def set_approval_stage_note( approval_code: str, node_id: str, note: str | None = None, ) -> dict: # ``mutates=True`` mirrors the HTTP endpoint's leaver-lock gate — # without it a user in ``leaver_pending`` state could still # edit approval notes via MCP (the runtime context manager # blocks mutating tools for leavers, but only when this flag # is set). Discovered by 2026-05-21 security review pass. async with tool_runtime( "set_approval_stage_note", {"approval_code": approval_code, "node_id": node_id, "note_length": len(note) if note else 0}, mutates=True, ) as rt: from app.approval.service import ApprovalService from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_assets # AuthZ MUST mirror the HTTP endpoint's gate. The MCP layer # has the user (loaded by ``tool_runtime``) but does NOT # auto-apply per-tool role checks — every tool that maps to # an admin HTTP endpoint must repeat the role check here. # Discovered as P0 in 2026-05-21 security review (prior # version let any authenticated employee impersonate IT # admin via the MCP surface). if not can_manage_assets(rt.user.role_names): raise ForbiddenError( "Only IT/asset admins can edit approval stage notes.", ) # Cap free-text at the MCP boundary — service upserts into the # ``approval.stage_notes`` platform_settings JSON blob which is # loaded into memory on every approval-flow render across the # whole user base. An admin (or a stolen admin PAT) writing a # multi-MB note here would bloat every cache miss and push the # JSON column toward MySQL's ``max_allowed_packet``. HTTP caps # at 2000 via ``StageNoteUpdateRequest``; MCP must mirror. (R3 # logic finding 2026-05-28: Class C cap, admin-poison vector.) note = _bound_free_text(note, field="note", max_len=2000) await ApprovalService(rt.db).set_stage_note( approval_code=approval_code, node_id=node_id, note=note, ) return {"ok": True} @mcp_server.tool( name="list_my_approval_requests", description=( "List the approval requests the user has submitted (assets, " "software seats, etc.). Filters: status (pending/approved/" "rejected/completed/cancelled), request_type (e.g. 'software_seat')." ), ) async def list_my_approval_requests( status: str | None = None, request_type: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_approval_requests", { "status": status, "request_type": request_type, "page": page, "page_size": page_size, }, ) as rt: from app.approval.service import ApprovalService rows, total = await ApprovalService(rt.db).list_asset_approval_requests( current_user=rt.user, page=page, page_size=page_size, status=status, scope="mine", request_type=[request_type] if request_type else None, keyword=None, start_date=None, end_date=None, ) return { "items": serialize(rows), "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="get_buyback_quote", description=( "Compute the offboarding-buyback amount the employee would pay to " "given asset under IT policy (linear depreciation < 36 months, " "tiered floor table ≥ 36 months). Pair with submit_approval_request " "for template_key=offboarding_buyback. Currency defaults to the " "asset's purchase_entity currency (CNY otherwise); supply " "currency=USD|JPY|SGD to override. Returns amount, currency, " "mode (depreciation|floor), tier, months_used, and a full " "currency→amount map (prices) for client-side switching." ), ) async def get_buyback_quote( asset_id: str, currency: str | None = None, ) -> dict: async with tool_runtime( "get_buyback_quote", {"asset_id": asset_id, "currency": currency}, ) as rt: from app.approval.service import ApprovalService quote = await ApprovalService(rt.db).quote_offboarding_buyback( asset_id=_u(asset_id), current_user=rt.user, target_currency=currency, ) return serialize(quote) @mcp_server.tool( name="submit_approval_request", description=( "Submit a generic approval request (laptop_replacement, " "computer_borrow, asset_return, offboarding_buyback, " "software_seat). For software seats prefer request_software_seat. " "answers is a dict mapping the template's field names to values. " "Pass one stable idempotency_key per logical submission and reuse it " "after a timeout; changing the payload under the same key returns 409." ), ) async def submit_approval_request( template_key: str, reason: str, idempotency_key: str, asset_id: str | None = None, notes: str | None = None, answers: dict | None = None, ) -> dict: async with tool_runtime( "submit_approval_request", { "template_key": template_key, "reason": reason, "asset_id": asset_id, "notes": notes, "answers": answers, "idempotency_key": idempotency_key, }, mutates=True, leaver_allowed=True, ) as rt: # Offboarding carve-out, mirrored in the HTTP twin # ``POST /approval/asset/requests`` (exempted from the router # mutate gate via ``_LEAVER_MUTATE_EXACT_ALLOWLIST``): a 离职中 # employee must be able to submit 归还 (asset_return) and 回购 # (offboarding_buyback) — those are offboarding obligations, and # blocking them meant IT had to re-type the form through the # admin on-behalf path. ``leaver_allowed=True`` here is NOT a # blanket unlock: this tool accepts every template_key, so the # per-template decision lives in # ``ApprovalService.create_asset_approval_request`` # (``LEAVER_SELF_SERVICE_TEMPLATE_KEYS``), which still raises # LEAVER_RUNTIME_LOCKED for laptop_replacement / computer_borrow # / on-behalf. Both surfaces share that one decision so they # cannot answer the same state differently (#2160). from app.approval.schemas import AssetApprovalSubmitRequest from app.approval.service import ApprovalService from app.approval.submission_idempotency import ( SPECIALIZED_APPROVAL_TEMPLATE_KEYS, ) from app.core.dependencies import apply_approval_submit_rate_limit from app.core.exceptions import ValidationError # Per-user rate cap — mirrors request_api_key. Without this a # PAT-holding LLM/agent can flood the Feishu approval channel # through this generic submitter while the HTTP route is capped. await apply_approval_submit_rate_limit(rt.user.id) # Specialized software requests are the only safe entry points: they # validate the plan/item, enforce seat quota and pending-row rules, # freeze trusted prices, and bind the approval to its deterministic # local allocation/charge. The generic submitter cannot reproduce # those domain invariants. specialized_software_key = (template_key or "").lower() if specialized_software_key in SPECIALIZED_APPROVAL_TEMPLATE_KEYS: raise ValidationError( "Use request_software_seat or request_software_addon for " "software requests; the generic submitter cannot enforce " "their domain controls.", ) # Bound free-text inputs at the tool boundary. The LLM client # is untrusted; without a cap a 5MB ``reason`` or a 10k-key # ``answers`` dict could land in the request body before any # downstream validator sees it. if reason is not None and len(reason) > 4000: raise ValidationError( f"reason must be ≤ 4000 chars (got {len(reason)}).", ) if notes is not None and len(notes) > 4000: raise ValidationError( f"notes must be ≤ 4000 chars (got {len(notes)}).", ) if answers is not None and len(answers) > 50: raise ValidationError( f"answers must contain ≤ 50 keys (got {len(answers)}).", ) body = AssetApprovalSubmitRequest( template_key=template_key, # type: ignore[arg-type] asset_id=_u(asset_id) if asset_id else None, reason=reason, notes=notes, answers=answers or {}, ) response = await ApprovalService(rt.db).create_asset_approval_request( body=body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), idempotency_key=idempotency_key, ) return serialize(response) @mcp_server.tool( name="submit_buyback_on_behalf", description=( "Admin 代提回购 — start the offboarding-buyback approval for a " "leaver-held asset ON BEHALF of the departed employee. Asset-admin " "only. The buyback subject is the asset's current holder (the " "leaver) — resolved server-side, not passed in — so an asset with " "no holder is rejected. The admin becomes the Feishu applicant " "(the leaver's Feishu account can't submit) while the stored " "requester is the leaver, so approve-time disposal (status→sold) " "is attributed correctly. Amount/payment/owner-entity/location/" "last-working-day are all server-derived; supply only an optional " "reason and display currency (USD|JPY|SGD|CNY). Use this for " "离职员工持有的设备 buyback; for a still-employed requester use " "submit_approval_request with template_key=offboarding_buyback. Pass " "a stable idempotency_key and reuse it after an uncertain response." ), ) async def submit_buyback_on_behalf( asset_id: str, idempotency_key: str, reason: str | None = None, notes: str | None = None, currency: str | None = None, ) -> dict: async with tool_runtime( "submit_buyback_on_behalf", { "asset_id": asset_id, "reason": reason, "notes": notes, "currency": currency, "idempotency_key": idempotency_key, }, mutates=True, ) as rt: from app.approval.service import ApprovalService from app.core.dependencies import apply_approval_submit_rate_limit from app.core.exceptions import ValidationError await apply_approval_submit_rate_limit(rt.user.id) if reason is not None and len(reason) > 4000: raise ValidationError( f"reason must be ≤ 4000 chars (got {len(reason)}).", ) if notes is not None and len(notes) > 4000: raise ValidationError( f"notes must be ≤ 4000 chars (got {len(notes)}).", ) response = await ApprovalService( rt.db, ).create_offboarding_buyback_on_behalf( asset_id=_u(asset_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), reason=reason, notes=notes, currency=currency, idempotency_key=idempotency_key, ) return serialize(response) @mcp_server.tool( name="supplement_request_uploads", description=( "Attach supplementary files to an in-flight approval request " "(typically the laptop_replacement 回购流程, where the " "requester now needs to upload 转账成功截图 / " "上传数据清理视频证明 after the initial submit). " "Files must already be uploaded via the upload endpoint " "(POST /approval/asset/uploads?flow=supplement); pass them " "keyed by Feishu widget_id. Each list item is either a bare " "token string, or (preferred) an object {token, name?, url?, " "object_key?, size?} echoing the upload response — the Feishu " "comment attachment is URL-based, so entries WITHOUT url are " "only mentioned in the comment text, not attached; object_key " "is the durable Helix S3 copy that keeps the file openable " "after the signed Feishu url expires (~14h). Delivery: when " "the approval flow is parked on the requester's own node " "(提交人本人, upload widget editable) and the caller is the " "requester, the files are written into the Feishu form widget " "itself and that node completes; otherwise Helix posts them as " "a comment with attachments on the approval instance. The " "response's detail.feishu.supplements[-1].delivery says which " "('form' / 'comment' / 'mixed'). Only the original requester " "(or an asset admin — always comment channel) may supplement, " "and only while status='pending'." ), ) async def supplement_request_uploads( request_id: str, uploads: dict[str, list[str | dict]], note: str | None = None, ) -> dict: async with tool_runtime( "supplement_request_uploads", { "request_id": request_id, "uploads": uploads, "note": note, }, mutates=True, ) as rt: from app.core.rbac import can_manage_assets from app.domains.asset.schemas import ( EmployeeRequestResponse, EmployeeRequestSupplementUploadsRequest, ) from app.domains.asset.service import AssetService body = EmployeeRequestSupplementUploadsRequest( uploads=uploads, note=note, ) service = AssetService(rt.db) is_admin = can_manage_assets(rt.user.role_names) req = await service.supplement_employee_request_uploads( request_id=_u(request_id), actor=rt.user, is_admin=is_admin, uploads=body.uploads, note=body.note, ) return serialize(EmployeeRequestResponse.model_validate(req)) # ════════════════════════════════════════════════════════════════════ # API keys (vendor / 3rd-party) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_api_key_providers", description=( "List the third-party API-key providers Helix knows about " "(OpenAI, Anthropic, AWS, …). Returns id + name + auth_type " "for each active provider." ), ) async def list_api_key_providers() -> dict: async with tool_runtime("list_api_key_providers", {}) as rt: from app.domains.apikey.service import ApiKeyService items = await ApiKeyService(rt.db).list_providers(include_inactive=False) return {"providers": serialize(items)} @mcp_server.tool( name="api_key_analytics", description=( "Org-wide API-Key fleet analytics (the admin 'Reports' tab). " "Returns distribution (by provider / status / cost-attribution " "binding / department / top owners), the committed monthly-budget " "pool, actual month-to-date cloud spend (bounded-query batch " "projection of the per-key budget-panel rules; Bailian / unbound " "keys are " "counted as 'unresolvable' rather than under-counted), and " "lifecycle metrics (6-month created/revoked trend, validity " "buckets, revoke breakdown, shared-roster stats). Optional " "`month` (YYYY-MM) defaults to the current month. Read-only; " "restricted to asset/global admins, management, and auditors." ), ) async def api_key_analytics(month: str | None = None) -> dict: async with tool_runtime( "api_key_analytics", {"month": month}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( AUDITOR_ROLE, MANAGEMENT_ROLE, can_manage_assets, has_role, ) if not ( can_manage_assets(rt.user.role_names) or has_role(rt.user.role_names, MANAGEMENT_ROLE, AUDITOR_ROLE) ): raise ForbiddenError( "API Key analytics is restricted to asset/global admins, " "management, and auditors.", ) if month is not None: import re if not re.fullmatch(r"\d{4}-(0[1-9]|1[0-2])", month): from app.core.exceptions import ValidationError raise ValidationError( f"month must be YYYY-MM, got {month!r}", ) from app.domains.apikey.analytics import compute_apikey_analytics result = await compute_apikey_analytics(rt.db, month=month) return result @mcp_server.tool( name="list_my_api_keys", description=( "List the API-key assignments the user owns. Each row includes " "the provider, masked key value, status (pending/approved/active/" "revoked/expired), assigned_at, expires_at. Bounded pagination " "— max 200 rows per call. The underlying service returns the " "full list; pagination is applied in-memory at the tool layer " "to keep the LLM payload bounded for power users." ), ) async def list_my_api_keys(page: int = 1, page_size: int = 50) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_api_keys", {"page": page, "page_size": page_size}, ) as rt: # ``_to_response`` projects overdue active rows as expired without # mutating them. Durable status/audit transitions belong to the # scheduled expiry job, so this read tool needs no write session. from app.domains.apikey.service import ApiKeyService items = await ApiKeyService(rt.db).get_my_api_keys(rt.user) total = len(items) offset = (page - 1) * page_size page_items = items[offset:offset + page_size] return { "assignments": serialize(page_items), "total": total, "page": page, "page_size": page_size, # Backward compatibility for MCP clients that used this field # as a signal that returned expiry statuses are trustworthy. # There is no durable sweep on this read path anymore; the # service's deterministic projection cannot partially fail. "sweep_ok": True, "expiry_status_source": "derived", } @mcp_server.tool( name="list_my_pending_api_key_apply_approvals", description=( "List the AI API-Key APPLY (申请) requests currently waiting on " "YOUR approval in Feishu — the data behind the 「API Key 申请审批」 " "todo tab. Each row: request_number, requester, provider, project, " "monthly_budget_usd, expected_usage, submitted_at, " "feishu_instance_code. These are decided in the Feishu「审批」app, " "so this is a READ surface only (no approve/reject here) — it just " "makes the pending apply visible to its current approver. Distinct " "from API-Key RELEASE (释放) approvals, which Helix decides " "internally (see the release-request tools)." ), ) async def list_my_pending_api_key_apply_approvals() -> dict: async with tool_runtime( "list_my_pending_api_key_apply_approvals", {}, ) as rt: from app.domains.apikey.service import ApiKeyService rows = await ApiKeyService( rt.db, ).list_pending_apply_approvals_for_approver(current_user=rt.user) return {"items": serialize(rows), "total": len(rows)} @mcp_server.tool( name="request_api_key", description=( "Submit an API-key request to a third-party provider (OpenAI, " "Anthropic, …). project_name identifies what the key is for; " "reason is the business justification; monthly_budget_usd is " "the spend cap (≥ 20). valid_from / valid_until are ISO dates " "(YYYY-MM-DD); the period must not exceed 1 year." ), ) async def request_api_key( provider_id: str, project_name: str, reason: str, monthly_budget_usd: Annotated[ float, Field(description=( "Monthly spend cap in USD, at least 20. A number, not a string, " "and not a total for the whole period." )), ], valid_from: FutureDate, valid_until: FutureDate, expected_usage: str | None = None, ) -> dict: from decimal import Decimal, InvalidOperation async with tool_runtime( "request_api_key", { "provider_id": provider_id, "project_name": project_name, "reason": reason, "monthly_budget_usd": monthly_budget_usd, "valid_from": valid_from, "valid_until": valid_until, }, mutates=True, ) as rt: from app.core.dependencies import apply_approval_submit_rate_limit from app.core.exceptions import ValidationError from app.domains.apikey.schemas import ApiKeyRequestSubmit from app.domains.apikey.service import ApiKeyService # Round-3 security audit HIGH: parity with the HTTP route's # ``check_approval_submit_rate_limit`` (30/h/user). Without # this, a PAT-holding AI agent (or a stolen PAT) could # script-spam Feishu approval cards through the MCP path, # bypassing the entire cap that the HTTP route just landed. # Direct call to the underlying primitive (the HTTP dep wrapper # expects FastAPI's Depends machinery) — same Redis sliding # window, fail-open on Redis outage with 300ms backoff. await apply_approval_submit_rate_limit(rt.user.id) try: budget_decimal = Decimal(str(monthly_budget_usd)) except (InvalidOperation, TypeError, ValueError) as exc: raise ValidationError( f"monthly_budget_usd must be a finite number, got " f"{monthly_budget_usd!r}", ) from exc if not budget_decimal.is_finite(): raise ValidationError( f"monthly_budget_usd must be a finite number, got " f"{monthly_budget_usd!r}", ) body = ApiKeyRequestSubmit( provider_id=_u(provider_id), project_name=project_name, reason=reason, monthly_budget_usd=budget_decimal, valid_from=_parse_iso_date(valid_from, field="valid_from"), valid_until=_parse_iso_date(valid_until, field="valid_until"), expected_usage=expected_usage, ) response = await ApiKeyService(rt.db).submit_api_key_request( body=body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(response) @mcp_server.tool( name="reveal_api_key", description=( "Reveal the unmasked secret of an API-key assignment the user " "owns. The action is audited; do not paste the result anywhere " "the user wouldn't expect." ), ) async def reveal_api_key(assignment_id: str) -> dict: async with tool_runtime( "reveal_api_key", {"assignment_id": assignment_id}, mutates=True, ) as rt: # Mirror the HTTP route's per-user rate cap on key-reveal so a # PAT holder can't bypass it by switching transports. Default # 10/hour/user, configurable via SENSITIVE_RATE_LIMIT_*. from app.core.dependencies import apply_sensitive_rate_limit from app.core.exceptions import ForbiddenError, NotFoundError from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.service import ApiKeyService # HARD GATE — admin-on-behalf-of reveal is HTTP-only. # The HTTP route at apikey/router.py:240-249 explicitly carves # this off behind ``require_browser_session(request)`` with the # threat-model comment "a stolen IT-Admin PAT must NOT be able # to dump every employee's cloud-IAM credential set." MCP has # no browser-session concept, so the same policy is enforced # here by refusing any non-owner reveal at the boundary. await apply_sensitive_rate_limit(rt.user.id) target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if target is None: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) if target.assigned_to != rt.user.id: # Shared-access roster (migration 200): a teammate the Owner # added gets reveal access too — the whole point of the # roster is "small team shares one credential", and forcing # the team back through the browser-session admin path # would defeat it. Admin-on-behalf-of (a non-member admin # PAT) is still blocked because the threat model is # different (a stolen IT-Admin PAT must not dump every # employee's credentials). is_member = await ApiKeyService(rt.db).is_member( target.id, rt.user.id, ) if not is_member: raise ForbiddenError( "reveal_api_key over MCP is restricted to the " "assignment Owner or a member on its shared-access " "roster. Admin reveals on behalf of another user " "must use the browser-authenticated HTTP route — " "a stolen PAT must not be able to exfiltrate " "other users' cloud-IAM credentials.", ) result = await ApiKeyService(rt.db).reveal_key( assignment_id=_u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="api_key_usage_hints", description=( "Non-secret 'how to use this key' hints for an API-key " "assignment the user owns (or is a shared-access member of / an " "asset admin for). Returns the vendor kind (bailian / bedrock / " "azure_openai / generic), whether it's an adopted 存量纳管 key " "whose secret Helix never held, and — for Azure OpenAI — the " "LIVE list of model deployments IT created on the Foundry " "account so the user knows which deployment name to call. " "Returns NO key material; call reveal_api_key for the secret. " "When contact_it is true there is no usable model / secret and " "the user should contact IT rather than self-provision." ), ) async def api_key_usage_hints(assignment_id: str) -> dict: async with tool_runtime( "api_key_usage_hints", {"assignment_id": assignment_id}, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_assets from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.service import ApiKeyService is_admin = can_manage_assets(rt.user.role_names) target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if target is None: if is_admin: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) raise ForbiddenError( "api_key_usage_hints is restricted to the assignment " "Owner, a shared-access member, or an asset admin.", ) if target.assigned_to != rt.user.id and not is_admin: if not await ApiKeyService(rt.db).is_member(target.id, rt.user.id): raise ForbiddenError( "api_key_usage_hints is restricted to the assignment " "Owner, a shared-access member, or an asset admin.", ) # ``usage_hints`` returns a JSON-native dict[str, Any] (str / bool / # list[dict[str, str]]) — return it directly rather than through # ``serialize`` (whose Any return would trip the mypy no-any-return # ratchet) since there are no datetime / UUID / Decimal values to # coerce. result: dict = await ApiKeyService(rt.db).usage_hints( assignment_id=_u(assignment_id), current_user=rt.user, ) return result @mcp_server.tool( name="extend_api_key", description=( "Extend the validity of an API-key assignment owned by the " "user. new_valid_until is an ISO date in the future (and within " "1 year of the original valid_from)." ), ) async def extend_api_key( assignment_id: str, new_valid_until: FutureDate, ) -> dict: async with tool_runtime( "extend_api_key", {"assignment_id": assignment_id, "new_valid_until": new_valid_until}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_assets from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.schemas import ApiKeyExtendRequest from app.domains.apikey.service import ApiKeyService # Ownership pre-check at tool boundary — mirrors reveal_api_key. # The service layer has the same guard but we fail fast here # before acquiring a row lock so a stolen PAT can't DoS the DB. target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if target is None: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) if target.assigned_to != rt.user.id and not can_manage_assets( rt.user.role_names ): raise ForbiddenError( "extend_api_key is owner-only (or asset/IT admin).", ) body = ApiKeyExtendRequest( new_valid_until=_parse_iso_date( new_valid_until, field="new_valid_until", ), ) result = await ApiKeyService(rt.db).extend_validity( assignment_id=_u(assignment_id), body=body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="revoke_api_key", description=( "Revoke an API-key assignment. Admin-only — caller must hold " "asset / IT admin. The local key_value is cleared AND the " "cloud-side credential is torn down for auto-granted " "assignments (Bedrock IAM user / Bailian RAM user / Azure " "OpenAI cognitive-services account). Cloud-side failure " "leaves the assignment in its previous status so a retry is " "idempotent. reason required. reason_code is an optional " "bucket — one of admin_revoke / leaked / compliance / other " "— stored on the assignment row for governance reports; " "defaults to admin_revoke when omitted. The assignee + their " "direct manager are notified." ), ) async def revoke_api_key( assignment_id: str, reason: str, reason_code: str | None = None, ) -> dict: async with tool_runtime( "revoke_api_key", { "assignment_id": assignment_id, "reason": reason, "reason_code": reason_code, }, mutates=True, ) as rt: _require_asset_admin(rt) # Cap free-text at the MCP boundary — service writes `reason` # straight into `apikey_revocation_reason` TEXT column. reason = _bound_free_text(reason, field="reason", max_len=2000) or "" from app.domains.apikey.service import ( ADMIN_REVOKE_REASON_CODES, ApiKeyService, ) if reason_code is not None and reason_code not in ADMIN_REVOKE_REASON_CODES: # Use AppException(VALIDATION_ERROR) — a bare ValueError is # mapped by tool_runtime to INTERNAL_ERROR (500) which hides # the actionable hint from the LLM client. (R1 code review # finding 2026-05-28.) from app.core.exceptions import AppException raise AppException( code="VALIDATION_ERROR", message=( "reason_code must be one of " f"{sorted(ADMIN_REVOKE_REASON_CODES)}" ), status_code=422, ) result = await ApiKeyService(rt.db).revoke_key( assignment_id=_u(assignment_id), reason=reason, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), reason_code=reason_code, ) return serialize(result) @mcp_server.tool( name="rotate_api_key", description=( "Rotate the credential of a cloud-auto-granted API key you own " "(AWS Bedrock / Aliyun Bailian / Azure OpenAI). Mints a FRESH " "credential that goes live immediately while the OLD one keeps " "working for a grace period (dual-active), then the old one is " "retired automatically. Owner-only (or asset / IT admin). " "grace_days is 0 (retire immediately, no overlap), 3, 7 (default) " "or 14. Does NOT return the new secret — call reveal_api_key " "afterwards to read the new credential. Use complete_api_key_" "rotation once you've switched to retire the old key early, or " "cancel_api_key_rotation to abort and keep the original." ), ) async def rotate_api_key(assignment_id: str, grace_days: int = 7) -> dict: async with tool_runtime( "rotate_api_key", {"assignment_id": assignment_id, "grace_days": grace_days}, mutates=True, ) as rt: from app.core.exceptions import AppException, ForbiddenError, NotFoundError from app.core.rbac import can_manage_assets from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.rotation import ALLOWED_GRACE_DAYS from app.domains.apikey.service import ApiKeyService if grace_days not in ALLOWED_GRACE_DAYS: raise AppException( code="VALIDATION_ERROR", message=f"grace_days must be one of {list(ALLOWED_GRACE_DAYS)}", status_code=422, ) target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) # Collapse 404→403 for non-admins so a stolen PAT can't use the # NotFound-vs-Forbidden split to enumerate which assignment UUIDs # exist (same posture as the service HTTP path). Admins keep the # distinct 404 for their legitimate "does this row exist?" use. is_admin = can_manage_assets(rt.user.role_names) if target is None: if is_admin: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) raise ForbiddenError( "rotate_api_key is owner-only (or asset/IT admin).", ) if target.assigned_to != rt.user.id and not is_admin: raise ForbiddenError( "rotate_api_key is owner-only (or asset/IT admin).", ) result = await ApiKeyService(rt.db).rotate_key( assignment_id=_u(assignment_id), grace_days=grace_days, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="complete_api_key_rotation", description=( "Retire the OLD credential of an in-flight API-key rotation NOW " "('I've already switched to the new one'). Owner-only (or asset / " "IT admin). Shortens the dual-active exposure window. Fails with " "NO_ACTIVE_ROTATION if the key has no rotation in progress." ), ) async def complete_api_key_rotation(assignment_id: str) -> dict: async with tool_runtime( "complete_api_key_rotation", {"assignment_id": assignment_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_assets from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.service import ApiKeyService target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) # Collapse 404→403 for non-admins (no UUID-enumeration oracle); # admins keep the distinct 404. is_admin = can_manage_assets(rt.user.role_names) if target is None: if is_admin: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) raise ForbiddenError( "complete_api_key_rotation is owner-only (or asset/IT admin).", ) if target.assigned_to != rt.user.id and not is_admin: raise ForbiddenError( "complete_api_key_rotation is owner-only (or asset/IT admin).", ) result = await ApiKeyService(rt.db).force_complete_rotation( assignment_id=_u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="cancel_api_key_rotation", description=( "Abort an in-flight API-key rotation and revert to the ORIGINAL " "credential. Owner-only (or asset / IT admin). Invalidates the " "freshly-minted credential; your original keeps working. Fails " "with NO_ACTIVE_ROTATION if the key has no rotation in progress." ), ) async def cancel_api_key_rotation(assignment_id: str) -> dict: async with tool_runtime( "cancel_api_key_rotation", {"assignment_id": assignment_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_assets from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.service import ApiKeyService target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) # Collapse 404→403 for non-admins (no UUID-enumeration oracle); # admins keep the distinct 404. is_admin = can_manage_assets(rt.user.role_names) if target is None: if is_admin: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) raise ForbiddenError( "cancel_api_key_rotation is owner-only (or asset/IT admin).", ) if target.assigned_to != rt.user.id and not is_admin: raise ForbiddenError( "cancel_api_key_rotation is owner-only (or asset/IT admin).", ) result = await ApiKeyService(rt.db).cancel_rotation( assignment_id=_u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="release_my_api_key", description=( "Self-service release: ask your direct manager (or any IT Admin) " "to revoke an API key you own. The approval lives in Helix's Web " "UI; a Feishu / email push notifies the approver and links them " "back to /my/todos?tab=apikey-release — Feishu is the notification " "channel, not the approval surface. Unlike revoke_api_key this is " "callable by the assignee themselves and does NOT immediately tear " "down the cloud credential. The assignment parks in " "pending_release while the approval is open; reveal is blocked " "during that window. On approve, the same cloud teardown as " "revoke_api_key fires (Bedrock IAM user / Bailian RAM user / " "Azure OpenAI account deleted). On reject, the assignment reverts " "to its previous status and the assignee is notified. reason_code " "is required — one of no_longer_needed / project_ended / " "wrong_provider / other; reason_note is required only when " "reason_code='other'." ), ) async def release_my_api_key( assignment_id: str, reason_code: str, reason_note: str | None = None, ) -> dict: async with tool_runtime( "release_my_api_key", { "assignment_id": assignment_id, "reason_code": reason_code, "reason_note": reason_note, }, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.schemas import ApiKeyReleaseRequest from app.domains.apikey.service import ApiKeyService # Defense-in-depth ownership check at the MCP boundary — mirrors # the sibling pattern in extend_api_key (line 2321) / # update_api_key_assignment / reveal_api_key. The service layer # has the canonical owner check, but probing a stranger's # assignment_id should fail with 403 BEFORE acquiring a row # lock so a stolen PAT can't DoS the DB. ``release_my_api_key`` # is owner-only (admins use ``revoke_api_key`` instead). (R2 # logic finding 2026-05-28.) target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if target is None: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) if target.assigned_to != rt.user.id: raise ForbiddenError( "release_my_api_key is owner-only; admins should " "use revoke_api_key instead.", ) bounded_note = _bound_free_text( reason_note or "", field="reason_note", max_len=1000, ) or None body = ApiKeyReleaseRequest( reason_code=reason_code, reason_note=bounded_note, ) result = await ApiKeyService(rt.db).submit_release_request( _u(assignment_id), body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="retry_api_key_iam_retag", description=( "Admin retry for a transfer's AWS IAM tag rewrite that failed " "best-effort during apply. Re-invokes iam.tag_user with the " "current owner's email for Owner + helix-owner-email tags. " "Idempotent: a success path is a no-op upsert; only fires " "AWS errors when the IAM user is missing or credentials are " "broken. Admin-only. Surfaces as IAM_RETAG_FAILED 502 when " "the underlying call still fails; the per-attempt history " "lives on detail.last_transfer.retag_attempts." ), ) async def retry_api_key_iam_retag(assignment_id: str) -> dict: async with tool_runtime( "retry_api_key_iam_retag", {"assignment_id": assignment_id}, mutates=True, ) as rt: _require_asset_admin(rt) from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).retry_iam_retag( _u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="transfer_apikey_assignment_owner", description=( "**Asset-admin only.** Admin-direct ownership transfer of an API " "Key assignment — the reconciliation ownership / leaver-residual " "fix that re-homes a key whose owner departed or is wrong. " "Reuses the full apply_transfer rewrite (local flip + " "cost-allocation rule swap + AWS owner-tag rewrite). The new " "owner must be an active employee. Distinct from " "transfer_my_api_key (owner-initiated peer-accept flow); this is " "the one-click admin path." ), ) async def transfer_apikey_assignment_owner( assignment_id: str, assigned_to: str, ) -> dict: async with tool_runtime( "transfer_apikey_assignment_owner", {"assignment_id": assignment_id, "assigned_to": assigned_to}, mutates=True, ) as rt: _require_asset_admin(rt) from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).transfer_assignment_owner( _u(assignment_id), _u(assigned_to), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="re_enable_apikey_cost_rules", description=( "**Asset-admin only.** Re-enable the cost-allocation rules a " "prior revoke disabled on an API Key assignment — the " "reconciliation cost-lens fix. Inverse of the revoke-time " "auto-disable. Idempotent: rules already enabled report " "already_enabled, missing rules report not_found. Returns " "{rules_re_enabled, count, results}." ), ) async def re_enable_apikey_cost_rules(assignment_id: str) -> dict: async with tool_runtime( "re_enable_apikey_cost_rules", {"assignment_id": assignment_id}, mutates=True, ) as rt: _require_asset_admin(rt) from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).re_enable_allocation_rules( _u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="refresh_ai_spending_rollup", description=( "**IT-admin only.** Manually re-materialise the AI-spending " "closed-month rollup — the reconciliation cost-drift '重算物化' " "fix for stale_fingerprint / not_materialized findings. Pass " "period=YYYY-MM (must be a CLOSED month, strictly before the " "current month — open months are computed live, 400 otherwise) " "or omit to refresh the most-recent 2 closed months. Same " "compute path as the daily period_rollup_refresh cron." ), ) async def refresh_ai_spending_rollup(period: str | None = None) -> dict: async with tool_runtime( "refresh_ai_spending_rollup", {"period": period}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_ai_spending if not can_manage_ai_spending(rt.user.role_names): raise ForbiddenError( "Refreshing the AI-spending rollup is restricted to " "IT admin.", ) from app.domains.ai_spending.service import AiSpendingService result = await AiSpendingService(rt.db).manual_refresh_rollup( period=period, actor_id=rt.user.id, trace_id=_trace_str(), ) return serialize(result) @mcp_server.tool( name="list_my_handovers", description=( "List the offboarding handovers you personally hold — the " "departing colleagues whose data (Google Drive / Calendar " "content, email-group ownership, API keys) is routed to you. " "You are usually picked by rule (you are the leaver's lead), " "not by choice, so this is how you find out what is coming to " "you. Only handovers that can still change something are " "returned: open offboardings, plus completed ones whose Google " "/ Zoom accounts are still awaiting their terminal delete — " "those deletes are literally waiting on this handover. " "google_delete_pending / zoom_delete_pending tell you which. " "Read-only; use reassign_my_handover to hand one to a " "teammate." ), ) async def list_my_handovers() -> dict: async with tool_runtime("list_my_handovers", {}) as rt: from app.domains.offboarding.service import OffboardingService items = await OffboardingService(rt.db).list_my_handovers(rt.user.id) return {"items": serialize(items), "total": len(items)} @mcp_server.tool( name="reassign_my_handover", description=( "Hand an offboarding handover you currently hold to a " "colleague — use when the person who actually took over the " "leaver's work is not you. The new owner is notified and " "inherits everything that routes through the handover person " "(Drive / Calendar transfer at account-delete time, email " "group ownership, API keys); you get a confirmation. Only the " "CURRENT handover person may reassign (global_admin may also, " "via the offboarding console). new_person_id must be an " "active employee with a work email and can never be the " "leaver themselves — use search_directory_people to resolve a " "name to a UUID. process_id comes from list_my_handovers." ), ) async def reassign_my_handover(process_id: str, new_person_id: str) -> dict: async with tool_runtime( "reassign_my_handover", {"process_id": process_id, "new_person_id": new_person_id}, mutates=True, ) as rt: from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.domains.offboarding.service import OffboardingService result = await OffboardingService(rt.db).reassign_my_handover( _u(process_id), current_user_id=rt.user.id, new_person_id=_u(new_person_id), trace_id=_trace_str(), is_admin=has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE), ) return serialize(result) @mcp_server.tool( name="transfer_my_api_key", description=( "Owner-initiated transfer of an API Key to a colleague. The " "cloud credential is NOT torn down — the key keeps working " "through the handover so the recipient inherits a still-live " "credential. The recipient must accept via /my/transfers; on " "accept, Helix flips the assignment's assigned_to, swaps the " "auto-grant cost-allocation rule's assign_person_id, and " "pushes the new owner's email into the AWS IAM user's Owner " "tag (Bedrock only; Aliyun + Azure don't need a tag rewrite). " "Only the assignment owner may call this. recipient_person_id " "must be an active person and not the caller themselves — use " "search_directory_people to resolve their name to a UUID." ), ) async def transfer_my_api_key( assignment_id: str, recipient_person_id: str, note: str | None = None, ) -> dict: async with tool_runtime( "transfer_my_api_key", { "assignment_id": assignment_id, "recipient_person_id": recipient_person_id, "note": note, }, mutates=True, ) as rt: from app.domains.apikey.schemas import ApiKeyTransferRequestSubmit from app.domains.apikey.service import ApiKeyService bounded_note = _bound_free_text( note or "", field="note", max_len=500, ) or None body = ApiKeyTransferRequestSubmit( recipient_person_id=_u(recipient_person_id), note=bounded_note, ) service = ApiKeyService(rt.db) result = await service.submit_transfer_request( _u(assignment_id), body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) response = serialize(result) # The delegated OffboardingService only enqueues notification rows # while it holds the assignment/transfer locks. Commit those rows # before any provider I/O, then drain the exact service instance that # owns the in-memory envelopes (HTTP parity). trace_id = _trace_str() rt.after_commit( lambda: service.dispatch_transfer_notifications_after_commit( trace_id=trace_id, ), ) return response @mcp_server.tool( name="cancel_my_api_key_transfer", description=( "Withdraw a pending transfer request you submitted. The " "AssetTransferRequest flips to status='cancelled' and the " "recipient's pending card is invalidated; the assignment " "stays with you. Idempotent: a transfer that's already " "accepted/rejected/cancelled returns the current state with " "no side effects." ), ) async def cancel_my_api_key_transfer(assignment_id: str) -> dict: async with tool_runtime( "cancel_my_api_key_transfer", {"assignment_id": assignment_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.service import ApiKeyService # Defense-in-depth ownership check at the MCP boundary — mirrors # extend_api_key (line 2321). Service layer has the canonical # gate; this fails fast before the row lock so a stolen PAT # can't DoS the DB by iterating raw assignment_ids. (R2 logic # finding 2026-05-28.) target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if target is None: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) if target.assigned_to != rt.user.id: raise ForbiddenError( "cancel_my_api_key_transfer is owner-only.", ) result = await ApiKeyService(rt.db).cancel_transfer_request( _u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="list_api_key_members", description=( "List the shared-access roster on an API Key. Returns the team-" "mates the Owner has granted read access to — they see the same " "spend / detail / reveal surfaces the Owner sees. Auth: Owner, " "any roster member, or any admin / management / auditor. Each " "row carries id (membership row id), person_id, display_name, " "work_email, added_by, added_by_name, added_at. Leavers + " "soft-deleted persons are filtered out." ), ) async def list_api_key_members(assignment_id: str) -> dict: async with tool_runtime( "list_api_key_members", {"assignment_id": assignment_id}, ) as rt: from app.domains.apikey.service import ApiKeyService rows = await ApiKeyService(rt.db).list_members( _u(assignment_id), current_user=rt.user, ) return {"members": serialize(rows)} @mcp_server.tool( name="add_api_key_member", description=( "Owner adds a teammate to the API Key's shared-access roster. " "The new member sees the same spend / detail / reveal surfaces " "the Owner sees, but cannot perform Owner-only mutations " "(transfer / release / extend / revoke / edit budget / manage " "members). Owner-only: admins do not have this surface — they " "manage keys through the admin console's rebind / revoke / IAM " "tools instead. Idempotent on duplicate (assignment, person); " "the target person must be an active employee. The new member " "receives a Feishu / in-app notification linking to the key's " "detail page." ), ) async def add_api_key_member(assignment_id: str, person_id: str) -> dict: async with tool_runtime( "add_api_key_member", {"assignment_id": assignment_id, "person_id": person_id}, mutates=True, ) as rt: from app.core.dependencies import apply_sensitive_rate_limit from app.domains.apikey.service import ApiKeyService # Mirror the HTTP route's sensitive rate-limit so a PAT holder # can't spam roster-add → notify-bomb a colleague via MCP. await apply_sensitive_rate_limit(rt.user.id) result = await ApiKeyService(rt.db).add_member( _u(assignment_id), person_id=_u(person_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="remove_api_key_member", description=( "Owner removes a teammate from the API Key's shared-access " "roster. Idempotent: a non-existent membership row returns " "success with no side effect. The ex-member's next reveal / " "spend call returns 403 and the key drops off their /my/api-" "keys list. The ex-member receives a Feishu / in-app " "notification about the change." ), ) async def remove_api_key_member( assignment_id: str, person_id: str, ) -> dict: async with tool_runtime( "remove_api_key_member", {"assignment_id": assignment_id, "person_id": person_id}, mutates=True, ) as rt: from app.core.dependencies import apply_sensitive_rate_limit from app.domains.apikey.service import ApiKeyService await apply_sensitive_rate_limit(rt.user.id) await ApiKeyService(rt.db).remove_member( _u(assignment_id), person_id=_u(person_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return {"removed": True} @mcp_server.tool( name="cancel_my_api_key_release", description=( "Withdraw a pending release request you submitted. Reverts the " "assignment from pending_release back to its prior status " "(typically active) and marks the release-request row " "'cancelled'. Idempotent: a row that has already moved on " "returns the current state with no side effects. Only the " "assignee may call this on their own assignment." ), ) async def cancel_my_api_key_release(assignment_id: str) -> dict: async with tool_runtime( "cancel_my_api_key_release", {"assignment_id": assignment_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.service import ApiKeyService # Defense-in-depth ownership check at the MCP boundary — mirrors # extend_api_key (line 2321). Service layer has the canonical # gate; this fails fast before the row lock so a stolen PAT # can't DoS the DB by iterating raw assignment_ids. (R2 logic # finding 2026-05-28.) target = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if target is None: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) if target.assigned_to != rt.user.id: raise ForbiddenError( "cancel_my_api_key_release is owner-only.", ) result = await ApiKeyService(rt.db).cancel_release_request( _u(assignment_id), current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="list_my_api_key_release_approvals", description=( "List API-Key release requests waiting on you to decide. " "Returns one row per pending request whose requester reports " "to you (or every pending request if you are an IT Admin). " "Each row carries the requester's display name + email, the " "provider name, the assignment's project_name, and the " "requester's reason_code / reason_note so you can decide " "without a second round-trip. To act, call " "approve_my_api_key_release(request_id) or " "reject_my_api_key_release(request_id, note)." ), ) async def list_my_api_key_release_approvals( status: str = "pending", ) -> list[dict]: async with tool_runtime( "list_my_api_key_release_approvals", {"status": status}, mutates=False, ) as rt: from app.domains.apikey.service import ApiKeyService rows = await ApiKeyService(rt.db).list_release_requests_for_approver( current_user=rt.user, status=status or None, ) return [serialize(r) for r in rows] @mcp_server.tool( name="approve_my_api_key_release", description=( "Approve a pending API-Key release request as the requester's " "manager (or IT Admin). On approve we synchronously revoke the " "key — the cloud-side credential (AWS IAM user / DashScope sk- " "/ AOAI account / RAM user) is torn down immediately and the " "assignment lands in 'revoked' with " "actor_role='self_with_approval'. The requester gets a Feishu " "+ email notification that their release was approved. The " "decision_note is optional; if provided it lands on the " "release-request row for audit. Authz: caller must be the " "requester's direct manager OR hold global_admin." ), ) async def approve_my_api_key_release( request_id: str, decision_note: str | None = None, ) -> dict: async with tool_runtime( "approve_my_api_key_release", { "request_id": request_id, "decision_note": decision_note, }, mutates=True, ) as rt: # Cap free-text at the MCP boundary — service writes # ``decision_note`` straight into ``ApiKeyReleaseRequest # .decision_note`` (TEXT). HTTP Pydantic schema # ``ApiKeyReleaseDecisionRequest`` caps at 1000; MCP must mirror. # (R3 logic finding 2026-05-28: Class C cap.) decision_note = _bound_free_text( decision_note, field="decision_note", max_len=1000, ) from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).approve_release_request( _u(request_id), current_user=rt.user, decision_note=decision_note, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="reject_my_api_key_release", description=( "Reject a pending API-Key release request as the requester's " "manager (or IT Admin). The assignment is reverted to its " "pre-release status (typically 'active') and the requester is " "notified. The decision_note is optional but strongly " "encouraged on reject so the requester sees *why*. Authz: " "caller must be the requester's direct manager OR hold " "global_admin." ), ) async def reject_my_api_key_release( request_id: str, decision_note: str | None = None, ) -> dict: async with tool_runtime( "reject_my_api_key_release", { "request_id": request_id, "decision_note": decision_note, }, mutates=True, ) as rt: # Cap free-text at the MCP boundary — see approve_my_api_key_release # above. HTTP caps at 1000 via ``ApiKeyReleaseDecisionRequest``. # (R3 logic finding 2026-05-28: Class C cap.) decision_note = _bound_free_text( decision_note, field="decision_note", max_len=1000, ) from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).reject_release_request( _u(request_id), current_user=rt.user, decision_note=decision_note, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="update_api_key_assignment", description=( "Edit an API-key assignment's monthly_budget_usd and / or " "notify_recipients. Authz: caller must be the applicant OR an " "asset_admin / it_admin. notify_recipients is tri-state: " "omit / null = default (applicant + HRIS manager); empty list " "= admin suppression (no alerts); a list of identity_persons.id " "UUIDs = exactly those persons receive the 80% / 100% budget " "alerts. monthly_budget_usd >= 20 (platform floor). The action " "is audit-logged with before / after diff." ), ) async def update_api_key_assignment( assignment_id: str, monthly_budget_usd: float | None = None, notify_recipients: list[str] | None = None, reset_recipients_to_default: bool = False, ) -> dict: """Edit budget and/or recipients. ``notify_recipients`` semantics — the MCP surface needs an explicit "reset to default" flag because we can't distinguish "caller omitted the param" from "caller passed null" at the JSON-RPC boundary the way the HTTP layer can (via ``model_fields_set``). So: * ``notify_recipients`` omitted / null AND ``reset_recipients_to_default`` False → recipients column is left untouched (only budget gets updated, if supplied). * ``notify_recipients=[]`` → admin suppression (no alerts). * ``notify_recipients=[, ...]`` → exactly those persons. * ``reset_recipients_to_default=True`` → explicitly clear the column back to applicant+manager default, regardless of what ``notify_recipients`` is set to. """ async with tool_runtime( "update_api_key_assignment", { "assignment_id": assignment_id, "monthly_budget_usd": monthly_budget_usd, "notify_recipients": notify_recipients, "reset_recipients_to_default": reset_recipients_to_default, }, mutates=True, ) as rt: from decimal import Decimal from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_assets from app.domains.apikey.models import ApiKeyAssignment from app.domains.apikey.schemas import AssignmentUpdate from app.domains.apikey.service import ApiKeyService # Ownership pre-check at tool boundary — matches service-layer # authz in update_assignment. Fail fast before acquiring a lock. peek = await rt.db.get(ApiKeyAssignment, _u(assignment_id)) if peek is None: raise NotFoundError("ApiKeyAssignment", str(assignment_id)) if peek.assigned_to != rt.user.id and not can_manage_assets( rt.user.role_names ): raise ForbiddenError( "Only the assignment owner or an asset/IT admin may edit " "this assignment.", ) update_kwargs: dict[str, Any] = {} if monthly_budget_usd is not None: update_kwargs["monthly_budget_usd"] = Decimal( str(monthly_budget_usd), ) if reset_recipients_to_default: update_kwargs["notify_recipients"] = None elif notify_recipients is not None: update_kwargs["notify_recipients"] = notify_recipients # else: omit entirely so model_fields_set doesn't include it. body = AssignmentUpdate(**update_kwargs) result = await ApiKeyService(rt.db).update_assignment( assignment_id=_u(assignment_id), body=body, current_user=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(result) @mcp_server.tool( name="list_idle_api_keys", description=( "Admin ledger of API keys the monthly idle check found with $0 spend " "last calendar month (and the month before): holder, provider, " "project, how many months in a row, whether the holder was paged, " "and any 'keep' the holder recorded (reason + until when). Bailian " "keys and keys without a cost binding are not here — their per-key " "spend is unknown, not zero. Asset/IT admin." ), ) async def list_idle_api_keys(months: int = 2) -> dict: async with tool_runtime( "list_idle_api_keys", {"months": months}, mutates=False, ) as rt: _require_asset_admin(rt) from app.domains.apikey.idle_alerts import ( list_idle_assignments, load_idle_settings, ) rows = await list_idle_assignments(rt.db, months=max(1, min(12, int(months)))) settings = await load_idle_settings(rt.db) return {"items": rows, "total": len(rows), "settings": settings.to_setting()} @mcp_server.tool( name="keep_my_idle_api_key", description=( "Answer the monthly 'this key had no spend' question with KEEP: the " "key stays, the reason is recorded on it, and the question is not " "asked again for the configured number of months (default 6). Only " "the key's owner or a shared-access member may do this; a reason of " "at least a few words is required. To give the key up instead, use " "``release_my_api_key``." ), ) async def keep_my_idle_api_key(assignment_id: str, reason: str) -> dict: async with tool_runtime( "keep_my_idle_api_key", {"assignment_id": assignment_id, "reason": reason}, mutates=True, ) as rt: from app.domains.apikey.idle_alerts import ( keep_idle_assignment, load_idle_settings, ) from app.domains.apikey.service import ApiKeyService svc = ApiKeyService(rt.db) assignment = await svc._get_assignment(_u(assignment_id), lock=True) is_member = await svc.is_member(assignment.id, rt.user.id) settings = await load_idle_settings(rt.db) return await keep_idle_assignment( rt.db, assignment=assignment, actor=rt.user, reason=reason, settings=settings, trace_id=_trace_str(), is_member=is_member, ) @mcp_server.tool( name="update_api_key_idle_alert_config", description=( "Change how the monthly idle-key check reaches people. " "``enabled_for_employees`` is the real switch for paging holders " "(IT always gets the digest; employee cards ship OFF by default — " "2026-09-05 decision: new employee reach is opened per class from " "the admin console). ``min_age_days`` = how old a key must be before " "it is evaluated; ``keep_months`` = how long a holder's KEEP silences " "the question. Unset fields keep their value. Asset/IT admin." ), ) async def update_api_key_idle_alert_config( enabled_for_employees: bool | None = None, min_age_days: int | None = None, keep_months: int | None = None, ) -> dict: async with tool_runtime( "update_api_key_idle_alert_config", { "enabled_for_employees": enabled_for_employees, "min_age_days": min_age_days, "keep_months": keep_months, }, mutates=True, ) as rt: _require_asset_admin(rt) from app.audit.service import AuditService as _Audit from app.domains.apikey.idle_alerts import ( IdleSettings, load_idle_settings, save_idle_settings, ) current = await load_idle_settings(rt.db) updated = IdleSettings( enabled_for_employees=( enabled_for_employees if enabled_for_employees is not None else current.enabled_for_employees ), min_age_days=( max(7, min(365, int(min_age_days))) if min_age_days is not None else current.min_age_days ), keep_months=( max(1, min(24, int(keep_months))) if keep_months is not None else current.keep_months ), ) await save_idle_settings(rt.db, updated) await _Audit(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="apikey.idle.config_updated", resource_type="platform_setting", resource_id="apikey.idle_alert", status="success", detail={"before": current.to_setting(), "after": updated.to_setting()}, ) return updated.to_setting() @mcp_server.tool( name="get_api_key_spend", description=( "Return month-to-date cloud spend for THIS specific API-key " "assignment, dispatched per upstream product (PR #251). " "Foundry AKs sum cost_facts in the AK's dedicated Azure " "resource group, filtered to Foundry-family services; " "Bedrock AKs match the AWS cost-explorer principal-tag " "encoding the AK's IAM user; Vertex AI AKs sum cost_facts in " "the AK's GCP project scope filtered to the Vertex / " "Generative AI service family; Bailian (Alibaba Cloud Model " "Studio) sums ``bailian_usage_daily`` for the assignment's linked " "Bailian key (migration 299, per day × model), returning " "``data_available=False`` only when no Bailian key is synced to " "the assignment yet. " "Response shape: ``{bucket, data_available, data_caveat, " "budget_usd, mtd_usd, percent, threshold_state, daily_series, " "last_fact_date, applicant, ...}``. ``daily_series`` runs " "from month_start through ``last_fact_date`` — trailing days " "with no facts are omitted, NOT zero-filled (the cost-facts " "cron lags 1–2 days behind today; emitting $0 bars would " "mislead). Agents reporting a daily breakdown should cite " "``last_fact_date`` as the freshness anchor. " "``threshold_state`` is " "``unknown`` when ``data_available=False`` — agents must NOT " "answer \"under/over budget\" in that state; surface the " "caveat instead. Same shared helper as the apikey.budget_check " "cron, so the agent and the Feishu alerts agree to the cent. " "Auth: applicant themselves OR an asset_admin / it_admin. " "``month`` is optional (YYYY-MM, strict two-digit month); " "defaults to the current Asia/Shanghai month." ), ) async def get_api_key_spend( assignment_id: str, month: str | None = None, ) -> dict: async with tool_runtime( "get_api_key_spend", {"assignment_id": assignment_id, "month": month}, ) as rt: from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).get_assignment_spend( assignment_id=_u(assignment_id), current_user=rt.user, month=month, ) return serialize(result) @mcp_server.tool( name="get_api_key_spend_detail", description=( "Detail-page payload for an API-key assignment — superset of " "``get_api_key_spend`` plus two extra reductions: " "``monthly_series`` (6 months ending at the requested month, " "each row a calendar-month sum of amount_usd) and ``by_model`` " "(per-``usage_type`` breakdown for the requested month, sorted " "by mtd_usd desc, columns: model, mtd_usd, usage_quantity, " "usage_unit, effective_unit_price_usd, percent_of_month). " "``usage_type`` is what carries the model granularity in " "cost_facts — for Azure this is MeterSubCategory (e.g. \"Azure " "OpenAI - GPT-4o Input Tokens\"), for AWS this is " "line_item_usage_type (e.g. " "\"USE2-Bedrock:Claude-3-5-Sonnet-Inference-Input-Tokens\"), " "for GCP it's the SKU name. " "``effective_unit_price_usd`` is mtd_usd / usage_quantity — a " "row-effective average rate, NOT the official per-token list " "price; agents must NOT quote it as the public model rate. It " "is null when the bucket's ``usage_unit`` is AWS's literal " "\"N/A\" (the group spans input-tokens / output-tokens / " "requests, so the summed quantity has no unit) — do not " "recompute it yourself from mtd_usd / usage_quantity there. " "``attribution_axis`` (Bedrock only) says which AWS " "cost-allocation axis produced the numbers: \"owner\" — " "grouped by (SERVICE × iamPrincipal/Owner), which leaves Cost " "Explorer no meter dimension, so by_model is a single " "\"(uncategorised)\" row (the AMOUNT is exact; only the model " "split is unavailable, and it is a property of the axis, not " "missing data); \"per_key\" — the helix-request-number axis, " "whose model dimension carries real model names. " "``by_profile`` (Bedrock workload / UsedBy breakdown) is " "qualified by ``by_profile_scope``: \"self\" means the rows sum " "to this payload's mtd_usd. Since the per-key × UsedBy cross " "(migration 452) BOTH axes are self-scoped — a per_key key now " "carries its OWN workload split (two keys held by one person no " "longer share an identical table). (\"holder\" — rows dividing " "the key HOLDER's Bedrock month, which must NOT be added to " "mtd_usd — is legacy and no longer produced.) " "``by_profile_total_usd`` is " "the denominator percent_of_month was computed against; agents " "reporting these shares MUST name it rather than implying the " "shares are of this key. " "Buckets without per-AK data (Bailian, missing IAM binding, " "missing RG binding, unknown provider) return empty " "``by_model`` and a zero-filled ``monthly_series``. " "Auth: applicant themselves OR an asset_admin / it_admin. " "``month`` is optional (YYYY-MM, strict); defaults to the " "current Asia/Shanghai month." ), ) async def get_api_key_spend_detail( assignment_id: str, month: str | None = None, ) -> dict: async with tool_runtime( "get_api_key_spend_detail", {"assignment_id": assignment_id, "month": month}, ) as rt: from app.domains.apikey.service import ApiKeyService result = await ApiKeyService(rt.db).get_assignment_spend_detail( assignment_id=_u(assignment_id), current_user=rt.user, month=month, ) return serialize(result) # ════════════════════════════════════════════════════════════════════ # Software permissions (role / group memberships) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_permission_catalog", description=( "List the catalog of software permissions / roles the user " "could potentially request. Optional filters: software_id, " "active_only=true to hide deprecated rows." ), ) async def list_permission_catalog( software_id: str | None = None, active_only: bool = True, ) -> dict: async with tool_runtime( "list_permission_catalog", {"software_id": software_id, "active_only": active_only}, ) as rt: from app.domains.permission.service import PermissionService items = await PermissionService(rt.db).list_permissions( software_id=_u(software_id) if software_id else None, active_only=active_only, ) return {"items": serialize(items)} @mcp_server.tool( name="list_my_permissions", description=( "List the software permissions / roles the user currently holds " "or has requested. status filter accepts 'pending', 'granted', " "'denied', 'revoked'. Bounded pagination — max 200 rows per call." ), ) async def list_my_permissions( status: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_permissions", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.permission.service import PermissionService items, total = await PermissionService(rt.db).list_user_permissions( user_id=rt.user.id, status=status, page=page, page_size=page_size, ) return { "items": serialize(items), "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="request_permission", description=( "Request a software permission / role. permission_id from " "list_permission_catalog. reason is the business justification." ), ) async def request_permission(permission_id: str, reason: str) -> dict: async with tool_runtime( "request_permission", {"permission_id": permission_id, "reason": reason}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — HTTP route caps at 2000 # via Pydantic schema, MCP bypasses it. (R3 logic sweep.) reason = _bound_free_text(reason, field="reason", max_len=2000) or reason from app.domains.permission.service import PermissionService svc = PermissionService(rt.db) row = await svc.request_permission( rt.user.id, _u(permission_id), reason=reason, trace_id=_trace_str(), ) items, _ = await svc.list_user_permissions( user_id=rt.user.id, permission_id=_u(permission_id), ) match = next((i for i in items if i.id == row.id), None) # Operator-precedence fix: previously `match or items[0] if items # else {}` parsed as `match or (items[0] if items else {})`, so # when the just-created row didn't surface in the list (e.g. # filter race), the tool silently returned someone else's permission # row. Branch explicitly on the just-created id; if the round-trip # lookup misses it (which would be a service bug), prefer raising # over guessing. if match is not None: return serialize(match) from app.core.exceptions import AppException raise AppException( code="INTERNAL_ERROR", message=( "Permission request was created but the round-trip read " "could not locate it. Re-fetch via list_my_permissions." ), status_code=500, ) # ════════════════════════════════════════════════════════════════════ # Email groups + aliases + password reset # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_email_suffixes", description=( "List the email suffixes (corporate domains, e.g. pingcap.com, " "tidbcloud.com) that mail-group create / alias create may use." ), ) async def list_email_suffixes() -> dict: async with tool_runtime("list_email_suffixes", {}) as rt: from app.domains.email_group.service import EmailGroupService suffixes = await EmailGroupService(rt.db).get_suffix_whitelist() return {"suffixes": list(suffixes)} @mcp_server.tool( name="list_my_email_groups", description=( "List the email distribution groups the user owns. Use this to " "find the target email for create_email_alias against a group." ), ) async def list_my_email_groups(page: int = 1, page_size: int = 50) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_email_groups", {"page": page, "page_size": page_size}, ) as rt: from app.domains.email_group.service import EmailGroupService rows, total, _persons = await EmailGroupService(rt.db).list_groups( search=None, suffix=None, owner_person_id=rt.user.id, only_unowned=False, created_from=None, created_to=None, page=page, page_size=page_size, ) return { "items": [ { "id": str(g.id), "email": g.email, "name": g.name, "status": g.status, # Aliases (alternate addresses that route to the # same group) + direct_members_count synced from # Google's groups.list response — surface them on # MCP for symmetry with the HTTP directory API, # which now exposes the same fields. "aliases": sorted(g.aliases or []), "direct_members_count": g.direct_members_count, "created_at": _iso_utc(g.created_at), } for g in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="request_email_group", description=( "Request creation of a new email distribution group, e.g. " "team-eng@pingcap.com. local_part is the bit before @, suffix " "the domain (must be in list_email_suffixes), name is the " "human-readable name. Goes through approval." ), ) async def request_email_group( local_part: str, suffix: str, name: str, reason: str, description: str | None = None, additional_owner_ids: list[str] | None = None, ) -> dict: async with tool_runtime( "request_email_group", { "local_part": local_part, "suffix": suffix, "name": name, "reason": reason, "description": description, # Key must match the tool parameter name exactly so admins # filtering audit logs for ``additional_owner_ids`` see this # call. (R1 sweep finding: audit-arg key drift.) "additional_owner_ids": additional_owner_ids, }, mutates=True, ) as rt: from app.core.exceptions import ValidationError from app.domains.email_group.schemas import EmailGroupCreateRequest from app.domains.email_group.service import EmailGroupService # Bound the LLM-supplied owners list. Without a cap a runaway # client could pass 50k UUIDs, blowing up payload size and # downstream Person lookups before any service-layer check # fires. Mirrors the 500-id cap already enforced on # ``mark_my_notifications_read`` and ``recertify_my_dns_records``. if additional_owner_ids and len(additional_owner_ids) > 50: raise ValidationError( f"additional_owner_ids may contain at most 50 UUIDs " f"(got {len(additional_owner_ids)}).", ) body = EmailGroupCreateRequest( local_part=local_part, suffix=suffix, name=name, description=description, reason=reason, additional_owner_ids=[_u(x) for x in (additional_owner_ids or [])], ) result = await EmailGroupService(rt.db).create_mail_group( body=body, requester=rt.user, trace_id=_trace_str(), ) return { "request_id": str(result.request.id), "request_number": result.request.request_number, "group_email": result.group.email if result.group else None, } @mcp_server.tool( name="create_email_alias", description=( "Add an alias to an email mailbox (target_kind='user') or to " "an email group (target_kind='group'). target_email is the " "destination that should receive mail sent to the alias. " "alias_local_part + alias_suffix form the alias address. " "Regular employees must target their own email or a group " "they own; HR/IT-admin can target any address." ), ) async def create_email_alias( target_kind: str, target_email: str, alias_local_part: str, alias_suffix: str, reason: str, ) -> dict: async with tool_runtime( "create_email_alias", { "target_kind": target_kind, "target_email": target_email, "alias_local_part": alias_local_part, "alias_suffix": alias_suffix, "reason": reason, }, mutates=True, ) as rt: from app.domains.email_group.schemas import EmailAliasCreateRequest from app.domains.email_group.service import EmailGroupService body = EmailAliasCreateRequest( target_kind=target_kind, target_email=target_email, alias_local_part=alias_local_part, alias_suffix=alias_suffix, reason=reason, ) result = await EmailGroupService(rt.db).create_alias( body=body, requester=rt.user, requester_roles=rt.user.role_names, trace_id=_trace_str(), ) return { "request_id": str(result.request.id), "request_number": result.request.request_number, "alias_email": result.alias_email, } @mcp_server.tool( name="list_my_email_aliases", description=( "List the aliases on the user's own corporate Google mailbox. " "Each item has ``alias_email`` and ``editable`` (False for " "Google domain-level non-editable aliases — those cannot be " "deleted via delete_my_email_alias). Returns the primary " "work email so the caller can render ``alias → primary`` " "context." ), ) async def list_my_email_aliases() -> dict: async with tool_runtime("list_my_email_aliases", {}) as rt: # mutates=False: list_aliases_for_person opportunistically refreshes # ``identity_persons.email_aliases`` JSON via ``await db.flush()``, # but never commits. With mutates=False the runtime closes the # session WITHOUT commit, so the cache write rolls back silently # — the next call simply re-fetches from Google. That's the right # trade: a leaver_pending caller (whose web account is still valid # for the offboarding window) can read their own aliases via MCP # without tripping the leaver-mutation gate. Marking mutates=True # would 403 those users on a pure read. from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) if person is None: from app.core.exceptions import NotFoundError raise NotFoundError("User", str(rt.user.id)) result = await EmailGroupService(rt.db).list_aliases_for_person( person=person, ) return { "person_id": str(result.person_id), "primary_email": result.primary_email, "aliases": [ {"alias_email": a.alias_email, "editable": a.editable} for a in result.aliases ], } @mcp_server.tool( name="delete_my_email_alias", description=( "Delete one of the user's own email aliases. The alias must be " "an editable alias (not a Google domain-level alias) — call " "list_my_email_aliases first to verify ``editable=True``. The " "deletion is recorded as a completed EmployeeRequest of type " "``google_email_alias`` with ``detail.action='delete'`` so the " "user can audit it from /my/requests. ``reason`` is optional." ), ) async def delete_my_email_alias( alias_email: str, reason: str | None = None, ) -> dict: async with tool_runtime( "delete_my_email_alias", {"alias_email": alias_email, "reason": reason}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — service writes ``reason`` # straight to a TEXT column with no Pydantic length validator. # (R1 sweep finding: Class C free-text cap missing.) reason = _bound_free_text(reason, field="reason", max_len=2000) from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) if person is None: from app.core.exceptions import NotFoundError raise NotFoundError("User", str(rt.user.id)) er = await EmailGroupService(rt.db).delete_alias_for_person( person=person, alias_email=alias_email, actor=person, actor_roles=rt.user.role_names or [], reason=reason, trace_id=_trace_str(), ) return { "request_id": str(er.id), "request_number": er.request_number, "alias_email": alias_email.strip().lower(), } @mcp_server.tool( name="list_my_group_memberships", description=( "List the email distribution groups the user is a MEMBER of " "(read live from Google Workspace). Distinct from " "list_my_email_groups, which lists groups the user OWNS. Each " "item has ``email``, ``name``, ``is_owner`` (the user is an " "owner of this group), ``sole_owner`` (the user is the ONLY " "owner — leave_my_email_group will refuse, transfer ownership " "first) and ``is_dynamic`` (Cloud Identity dynamic group — " "membership is rule-computed, so leave_my_email_group will refuse " "and the user must contact IT). Use this to find the " "``group_email`` for leave_my_email_group." ), ) async def list_my_group_memberships() -> dict: async with tool_runtime("list_my_group_memberships", {}) as rt: # mutates=False: pure read — list_member_groups_for_person does # not write any rows (membership is read live from Google; owner # flags come from the already-synced owner table). A # leaver_pending caller can read their memberships without # tripping the leaver-mutation gate. from app.core.exceptions import NotFoundError from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) if person is None: raise NotFoundError("User", str(rt.user.id)) result = await EmailGroupService(rt.db).list_member_groups_for_person( person=person, ) return { "person_id": str(result.person_id), "primary_email": result.primary_email, "groups": [ { "email": g.email, "name": g.name, "is_owner": g.is_owner, "sole_owner": g.sole_owner, "is_dynamic": g.is_dynamic, "group_id": str(g.group_id) if g.group_id else None, } for g in result.groups ], } @mcp_server.tool( name="leave_my_email_group", description=( "Leave an email distribution group the user is a member of. " "``group_email`` is the group's address (call " "list_my_group_memberships first). Refuses if the user is the " "group's sole owner (transfer ownership first) or if the group " "is a Cloud Identity dynamic group (contact IT). The departure " "is recorded as a completed EmployeeRequest of type " "``google_group_leave`` and the group's remaining owners are " "notified. ``already_absent=true`` in the response means Google " "reported the user was not a direct member (likely an indirect / " "nested membership — the leave is a no-op; contact IT). " "``reason`` is optional." ), ) async def leave_my_email_group( group_email: str, reason: str | None = None, ) -> dict: async with tool_runtime( "leave_my_email_group", {"group_email": group_email, "reason": reason}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — service writes ``reason`` # straight to a TEXT column with no Pydantic length validator. reason = _bound_free_text(reason, field="reason", max_len=2000) from app.core.exceptions import NotFoundError from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) if person is None: raise NotFoundError("User", str(rt.user.id)) er = await EmailGroupService(rt.db).leave_group_for_person( person=person, group_email=group_email, actor=person, reason=reason, trace_id=_trace_str(), ) return { "request_id": str(er.id), "request_number": er.request_number, "group_email": group_email.strip().lower(), "already_absent": bool( (er.detail or {}).get("already_absent_on_google") ), } @mcp_server.tool( name="request_join_email_group", description=( "Request to JOIN an email distribution group. ``group_email`` " "is the group's address (browse the directory or search first). " "The request routes to the group's owner(s) for approval INSIDE " "Helix — the user is added to Google only after an owner " "approves (track via list_my_requests). Refuses if the user is " "already an owner/member, if the group is a Cloud Identity " "dynamic group, or if a request is already pending. ``reason`` " "is optional but helps the owner decide." ), ) async def request_join_email_group( group_email: str, reason: str | None = None, ) -> dict: async with tool_runtime( "request_join_email_group", {"group_email": group_email, "reason": reason}, mutates=True, ) as rt: from app.core.exceptions import NotFoundError from app.domains.email_group.service import EmailGroupService reason = _bound_free_text(reason, field="reason", max_len=500) service = EmailGroupService(rt.db) group = await service.find_group_by_email(group_email) if group is None: raise NotFoundError("EmailGroup", group_email) req, has_owner = await service.request_join( group_id=group.id, requester=rt.user, reason=reason, trace_id=_trace_str(), ) return { "request_id": str(req.id), "group_email": group.email, "status": req.status, # False → group has no owner; routed to the HR / IT-Admin # 待审批 queue rather than a named owner. "has_owner": has_owner, } @mcp_server.tool( name="list_pending_group_join_requests", description=( "List email-group JOIN requests awaiting the caller's decision. " "A group owner sees pending requests on groups they own; HR / " "IT-admin see every pending request. Each item has " "``id`` (pass to decide_group_join_request), ``group_email``, " "the ``requester`` (name + email) and their ``reason``." ), ) async def list_pending_group_join_requests() -> dict: async with tool_runtime("list_pending_group_join_requests", {}) as rt: from app.core.exceptions import NotFoundError from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) if person is None: raise NotFoundError("User", str(rt.user.id)) data = await EmailGroupService(rt.db).list_pending_join_requests_for_owner( person=person, actor_roles=rt.user.role_names or [], ) return {"requests": data, "total": len(data)} @mcp_server.tool( name="decide_group_join_request", description=( "Approve or reject an email-group join request (owner / HR / " "IT-admin only). ``request_id`` comes from " "list_pending_group_join_requests. ``decision`` is 'approve' or " "'reject'. On approve the requester is added to the Google group " "as a MEMBER. ``note`` is optional and shown to the requester." ), ) async def decide_group_join_request( request_id: str, decision: str, note: str | None = None, ) -> dict: async with tool_runtime( "decide_group_join_request", {"request_id": request_id, "decision": decision, "note": note}, mutates=True, ) as rt: from app.domains.email_group.service import EmailGroupService note = _bound_free_text(note, field="note", max_len=500) req = await EmailGroupService(rt.db).decide_join( request_id=_u(request_id), actor=rt.user, actor_roles=rt.user.role_names or [], decision=decision, note=note, trace_id=_trace_str(), ) return {"request_id": str(req.id), "status": req.status} @mcp_server.tool( name="reset_my_email_password", description=( "Reset the user's own corporate-email password. The new " "password is delivered via Feishu + email — it is NOT returned " "in the response. reason optional but recommended for audit." ), ) async def reset_my_email_password(reason: str | None = None) -> dict: async with tool_runtime( "reset_my_email_password", {"reason": reason}, mutates=True, ) as rt: from app.domains.email_group.schemas import PasswordResetRequest from app.domains.email_group.service import EmailGroupService body = PasswordResetRequest(reason=reason) result = await EmailGroupService(rt.db).reset_own_password( body=body, requester=rt.user, trace_id=_trace_str(), ) return { "request_id": str(result.request.id), "request_number": result.request.request_number, "delivered_via": ["feishu", "email"], } # ════════════════════════════════════════════════════════════════════ # Notifications inbox # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_my_notifications", description=( "List the user's recent notifications (the bell icon in the " "web UI). Bounded pagination — max 100 rows per call. Includes " "T-3 leaver reminders, asset transfer requests, approval " "outcome pings, etc. Optional filters: `q` (case-insensitive " "substring over title / template key), `read` ('read'/'unread'), " "`template_prefix` (template-key family prefix, e.g. 'cloud'), " "`bucket` ('action' / 'info' / 'social' — each row also carries its " "classified `category` + `bucket`)." ), ) async def list_my_notifications( page: int = 1, page_size: int = 20, q: str | None = None, read: str | None = None, template_prefix: str | None = None, bucket: str | None = None, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_notifications", { "page": page, "page_size": page_size, "q": q, "read": read, "template_prefix": template_prefix, "bucket": bucket, }, ) as rt: # Backed by the shared ``InboxService`` (2026-05-03) so this # tool, the HTTP /notifications/inbox endpoint, and the # /me/notifications page all return identical rows. Previously # the inline SQL here lacked ``read_at`` / ``delivered_at`` so # an LLM consuming the tool couldn't tell read from unread. from app.notifications.inbox_service import InboxService svc = InboxService(rt.db) return await svc.list_for_recipient( recipient_id=rt.user.id, page=page, page_size=page_size, q=(q or "")[:100] or None, read=read, template_prefix=(template_prefix or "")[:50] or None, bucket=bucket, ) # ════════════════════════════════════════════════════════════════════ # CMDB — cloud servers, domains, DNS, analytics # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="get_cmdb_stats", description="Aggregate counts: total servers, total domains, by-provider/region.", ) async def get_cmdb_stats() -> dict: async with tool_runtime("get_cmdb_stats", {}) as rt: # Mirror the HTTP /cmdb/stats gate. Without this any # authenticated PAT holder (every employee can mint one) # could pull tenant-wide aggregate counts — useful recon for # "how many AWS accounts does this company have". from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_cloud from app.domains.cmdb.service import CmdbService if not can_read_cloud(rt.user.role_names): raise ForbiddenError( "Cloud-stats access requires admin / auditor / " "cloud_admin / management role." ) return serialize(await CmdbService.get_resource_stats(rt.db)) @mcp_server.tool( name="get_my_cmdb_analytics", description=( "Analytics scoped to cloud resources owned by the user — " "by_provider, by_region, status counts. Pass an integration_id " "to scope further to one cloud account." ), ) async def get_my_cmdb_analytics(integration_id: str | None = None) -> dict: async with tool_runtime( "get_my_cmdb_analytics", {"integration_id": integration_id}, ) as rt: from app.domains.cmdb.service import CmdbService # Validate at the boundary: a malformed `integration_id` should # surface a typed VALIDATION_ERROR (422) rather than fall through # to a downstream SQL ProgrammingError that maps to INTERNAL_ERROR. # The owner_person_id filter forces self-scoping regardless, so # this validation is a UX nicety rather than a security control. if integration_id: _u(integration_id) from app.core.rbac import get_subordinate_person_ids from app.domains.cmdb.visibility import resolve_owner_visibility # Server counts use the shared visibility contract (owner ∪ # owned-subscription attribution + manager expansion) so this # tool reconciles with list_cloud_servers / the web overview. subs = await get_subordinate_person_ids(rt.db, str(rt.user.id)) person_ids = [str(rt.user.id), *subs] if subs else [str(rt.user.id)] result = await CmdbService.get_analytics( rt.db, owner_person_id=None if subs else str(rt.user.id), owner_person_ids=person_ids if subs else None, owner_email=rt.user.work_email, integration_id=integration_id, server_visibility=await resolve_owner_visibility( rt.db, person_ids, ), ) return serialize(result) @mcp_server.tool( name="get_feishu_helpdesk_analytics", description=( "飞书服务台 (Feishu Helpdesk) ticket analytics over a recent window: " "KPIs (total / open / solve-rate / avg first-response & resolution " "seconds / satisfaction), daily trend, breakdowns by status / channel " "/ type, top agents, and top requester departments. Restricted to " "global_admin + management. Pass `days` (1–366, default 30) and " "optionally `integration_id` to scope to one 服务台. Read-only; data " "is the local mirror refreshed every 12h by feishu.helpdesk_sync." ), ) async def get_feishu_helpdesk_analytics( days: int | None = None, integration_id: str | None = None, ) -> dict: async with tool_runtime( "get_feishu_helpdesk_analytics", {"days": days, "integration_id": integration_id}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.domains.feishu_helpdesk import service as fh_service if not has_role( list(rt.user.role_names), GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError( "This report is restricted to global admins / management.", ) scope_id = _u(integration_id) if integration_id else None result = await fh_service.get_analytics( rt.db, integration_id=scope_id, start=None, end=None, days=days, ) return serialize(result) @mcp_server.tool( name="get_feishu_helpdesk_ai_insights", description=( "AI 月度运营洞察 for the Feishu 服务台 (Lark Helpdesk): an LLM " "(百炼/Qwen) read of the last ~6 months of tickets — trend summary, " "hotspots by problem category, and actionable recommendations. " "Restricted to global_admin + management. Pass optional " "`integration_id` to scope to one 服务台. Read-only; returns " "available=false when no LLM key is configured. Cached ~7 days." ), ) async def get_feishu_helpdesk_ai_insights(integration_id: str | None = None) -> dict: async with tool_runtime( "get_feishu_helpdesk_ai_insights", {"integration_id": integration_id}, # Read-looking, but a cache-miss/stale path generates + commits the # cached insight row (ai_insights._persist). A non-mutating runtime # would roll that write back. [[feedback_mcp_read_with_cache_write_must_mutate]] mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.domains.feishu_helpdesk import ai_insights if not has_role( list(rt.user.role_names), GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError( "This report is restricted to global admins / management.", ) scope_id = _u(integration_id) if integration_id else None return serialize(await ai_insights.get_insights(rt.db, scope_id)) def _serialize_server(server) -> dict: return { "id": str(server.id), "instance_id": server.instance_id, "name": server.name, "status": server.status, "region": server.region, "vcpu": server.vcpu, "memory_mb": server.memory_mb, "public_ips": server.public_ips, "private_ips": server.private_ips, "os_type": server.os_type, "owner_person_id": ( str(server.owner_person_id) if server.owner_person_id else None ), "integration_id": ( str(server.integration_id) if server.integration_id else None ), } @mcp_server.tool( name="list_cloud_servers", description=( "Search cloud servers across configured cloud accounts. Filters: " "integration_id, status, region, search (matches name/instance_id), " "owner_person_id. Pass owner_person_id='me' to scope to the user. " "**Non-admin callers (no `can_read_cloud` role) are silently scoped " "to themselves, their transitive subordinates, AND servers " "attributed to cloud subscriptions they or their reports own** — " "the manager view returns the team's servers, not just the " "caller's. Any `owner_person_id` override is replaced by that " "scope. Mirrors the HTTP route. Returns paginated rows." ), ) async def list_cloud_servers( integration_id: str | None = None, status: str | None = None, region: str | None = None, search: str | None = None, owner_person_id: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_cloud_servers", { "integration_id": integration_id, "status": status, "region": region, "search": search, # Key matches the tool parameter name so audit-log filters # don't silently miss it. (R1 sweep: audit-arg key drift.) "owner_person_id": owner_person_id, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( can_read_cloud, can_view_cloud_resources, get_subordinate_person_ids, ) from app.domains.cmdb.service import CmdbService from app.domains.cmdb.visibility import resolve_owner_visibility # Mirror the HTTP route's two-stage gate: # (a) `can_view_cloud_resources` — hard gate, every authenticated # employee passes; offboarded / suspended users 403. # (b) `can_read_cloud` — when False, scope to the caller's # visibility set: self + transitive subordinates + servers # attributed to cloud subscriptions they own (the shared # ``visibility.py`` contract the HTTP twin uses) — without it # the manager loses their team's servers on the MCP path. if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") owner = ( str(rt.user.id) if owner_person_id == "me" else owner_person_id ) visibility = None if not can_read_cloud(rt.user.role_names): # Always scope to the caller's visibility regardless of the # `owner` arg an LLM might pass. Subordinate expansion is # bounded + cycle-safe in ``get_subordinate_person_ids``. subs = await get_subordinate_person_ids(rt.db, str(rt.user.id)) person_ids = [str(rt.user.id), *subs] if subs else [str(rt.user.id)] visibility = await resolve_owner_visibility(rt.db, person_ids) owner = None if owner is not None and owner != str(rt.user.id): _u(owner) if integration_id: _u(integration_id) rows, total = await CmdbService.list_servers( rt.db, integration_id=integration_id, status=status, region=region, search=search, owner_person_id=owner, visibility=visibility, page=page, page_size=page_size, ) return { "items": [_serialize_server(s) for s in rows], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="get_cloud_server", description=( "Return one cloud server's full detail by id. Available to every " "authenticated employee (`can_view_cloud_resources`) — but " "non-admin callers see ONLY their own servers (or those of " "direct subordinates). Use `list_cloud_servers(owner_person_id='me')` " "to enumerate visible rows, then pick an id from the result." ), ) async def get_cloud_server(server_id: str) -> dict: async with tool_runtime("get_cloud_server", {"server_id": server_id}) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_read_cloud, can_view_cloud_resources from app.domains.cmdb.models import CmdbServer if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") srv = await rt.db.get(CmdbServer, _u(server_id)) if srv is None or srv.deleted_at is not None: raise NotFoundError("Cloud server", server_id) # Per-row authorization mirrors the HTTP route at # ``cmdb/router.py:GET /servers/{id}``: owner OR manager-of-owner # OR admin. Without this an employee can iterate UUIDs and pull # every server one by one even though the list endpoint demotes # to owner=self. Found in the launch-readiness audit 2026-05-05. if not can_read_cloud(rt.user.role_names): is_owner = srv.owner_person_id == str(rt.user.id) if not is_owner: from app.core.rbac import get_subordinate_person_ids subs = set( await get_subordinate_person_ids(rt.db, str(rt.user.id)) ) if ( not srv.owner_person_id or srv.owner_person_id not in subs ): raise NotFoundError("Cloud server", server_id) return _serialize_server(srv) @mcp_server.tool( name="list_cloud_domains", description=( "List registered domains. Filters: integration_id, " "expiring_within_days (e.g. 30 → domains expiring soon), " "owner_person_id ('me' resolves to current user). Available to " "every authenticated employee (`can_view_cloud_resources`) — " "domains are company-public identifiers (pingcap.com / " "tidb-community.com etc.), so unlike servers / DNS / CDN there " "is NO silent owner auto-demote. Pass owner_person_id='me' if " "you want only your own rows; omit it for the full fleet view. " "Each row carries a `data_source` flag: 'synced' (last write " "from a cloud driver), 'migrated' (legacy CMDB import, " "expiry / auto_renew stale), 'manual' (admin override)." ), ) async def list_cloud_domains( integration_id: str | None = None, expiring_within_days: int | None = None, owner_person_id: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_cloud_domains", { "integration_id": integration_id, "expiring_within_days": expiring_within_days, # Key matches the tool parameter name so audit-log filters # don't silently miss it. (R1 sweep: audit-arg key drift.) "owner_person_id": owner_person_id, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_cloud_resources from app.domains.cmdb.service import CmdbService # Single-gate: domains are company-public. No owner demote, # no manager-scope expansion — see the HTTP route docstring # at cmdb/router.py::list_domains for the full rationale. if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") owner = ( str(rt.user.id) if owner_person_id == "me" else owner_person_id ) if owner is not None and owner != str(rt.user.id): _u(owner) if integration_id: _u(integration_id) rows, total = await CmdbService.list_domains( rt.db, integration_id=integration_id, expiring_within_days=expiring_within_days, owner_person_id=owner, owner_person_ids=None, page=page, page_size=page_size, ) return { "items": [ { "id": str(d.id), "domain_name": d.domain_name, "registrar": d.registrar, "expiry_date": ( _iso_utc(d.expiry_date) ), "auto_renew": d.auto_renew, "status": d.status, "owner_person_id": ( str(d.owner_person_id) if d.owner_person_id else None ), "data_source": d.data_source, } for d in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="get_cloud_domain", description=( "Return one domain's full detail by id. Available to every " "authenticated employee (`can_view_cloud_resources`) — domains " "are company-public identifiers, no owner gate (see the HTTP " "route docstring at cmdb/router.py::get_domain for the full " "rationale and the security split vs. servers / DNS / CDN). " "The returned `data_source` flag is 'synced' / 'migrated' / " "'manual' so the caller can tell whether expiry / auto_renew " "are live-from-cloud or potentially stale." ), ) async def get_cloud_domain(domain_id: str) -> dict: async with tool_runtime("get_cloud_domain", {"domain_id": domain_id}) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_view_cloud_resources from app.domains.cmdb.models import CmdbDomain if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") d = await rt.db.get(CmdbDomain, _u(domain_id)) if d is None or d.deleted_at is not None: raise NotFoundError("Domain", domain_id) return { "id": str(d.id), "domain_name": d.domain_name, "registrar": d.registrar, "expiry_date": _iso_utc(d.expiry_date), "icp_filing_no": d.icp_filing_no, "auto_renew": d.auto_renew, "status": d.status, "owner_person_id": ( str(d.owner_person_id) if d.owner_person_id else None ), "data_source": d.data_source, } @mcp_server.tool( name="list_certificates", description=( "List TLS/SSL certificates Helix tracks for expiry. Filters: " "status ('valid'/'expiring'/'expired'/'unreachable'), source " "('probed'/'cloud'/'manual'), owner_person_id ('me' → current " "user), environment, search (matches common_name / probe_host / " "issuer). Rows are ordered soonest-expiry first. Like DNS records " "(NOT like domains), certs carry probe hostnames + SAN lists " "(recon-sensitive), so non-privileged callers are silently " "auto-demoted to certs they own or their subordinates own; " "admins / auditors (`can_read_cloud`) see the full fleet. Each " "row's `not_after` is the expiry instant; `source='probed'` rows " "come from a live TLS handshake, `unreachable` means Helix could " "not reach the host (NOT that the cert lapsed)." ), ) async def list_certificates( status: str | None = None, source: str | None = None, owner_person_id: str | None = None, environment: str | None = None, search: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_certificates", { "status": status, "source": source, "owner_person_id": owner_person_id, "environment": environment, "search": search, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( can_read_cloud, can_view_cloud_resources, get_subordinate_person_ids, ) from app.domains.cmdb import cert_service if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") owner = ( str(rt.user.id) if owner_person_id == "me" else owner_person_id ) owner_ids_scope = None if not can_read_cloud(rt.user.role_names): subs = await get_subordinate_person_ids(rt.db, str(rt.user.id)) owner_ids_scope = [str(rt.user.id), *subs] owner = None elif owner is not None and owner != str(rt.user.id): _u(owner) rows, total, resolutions = await cert_service.list_certificates( rt.db, status=status, source=source, owner_person_id=owner, environment=environment, search=search, owner_ids_scope=owner_ids_scope, page=page, page_size=page_size, ) items = [] for c in rows: res = resolutions.get(str(c.id)) items.append({ "id": str(c.id), "source": c.source, "common_name": c.common_name, "probe_host": c.probe_host, "issuer": c.issuer, "not_after": _iso_utc(c.not_after), "status": c.status, "environment": c.environment, # Live-resolved owner (本人) + provenance, not the snapshot. "owner_person_id": res.owner_person_id if res else None, "owner_source": res.owner_source if res else None, "domain_owner_person_id": ( res.domain_owner_person_id if res else None ), }) return { "items": items, "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="list_my_expiring_resources", description=( "List the certificates + domains the current user is responsible " "for (resolved owner or zone domain owner) that expire within " "`within_days` (default 30) or are already expired, soonest first. " "Answers 'what of mine is about to expire?'. Production resources " "(environment='prod') are excluded — prod renewal is owned outside " "Helix self-service." ), ) async def list_my_expiring_resources(within_days: int = 30) -> dict: from datetime import UTC as _UTC from datetime import datetime as _dt async with tool_runtime( "list_my_expiring_resources", {"within_days": within_days}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_cloud_resources from app.domains.cmdb.expiry_alert_engine import ( build_cert_expiry_items, build_domain_expiry_items, ) if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") me = str(rt.user.id) today = _dt.now(_UTC).date() within = max(1, min(365, int(within_days))) out: list[dict] = [] for builder in (build_cert_expiry_items, build_domain_expiry_items): for it in await builder(rt.db): if it.expiry_date is None: continue mine = {str(it.primary_owner_id or ""), str(it.domain_owner_id or "")} if me not in mine: continue delta = (it.expiry_date - today).days if delta > within: continue out.append({ "resource_type": it.resource_type, "resource_id": it.resource_id, "label": it.label, "expiry_date": it.expiry_date.isoformat(), "days_left": delta, }) out.sort(key=lambda x: x["days_left"]) return {"items": out, "count": len(out)} @mcp_server.tool( name="acknowledge_expiry_renewal", description=( "Mark a certificate or domain as 'renewing' to snooze its expiry " "reminders (and last-mile escalation) for `days` days (default 14, " "max 90). resource_type ∈ {'certificate','domain'}; resource_id is " "the Helix resource id. Allowed for cloud admins or one of the " "resource's responsible people (owner / domain owner / lead)." ), ) async def acknowledge_expiry_renewal( resource_type: str, resource_id: str, days: int = 14, ) -> dict: async with tool_runtime( "acknowledge_expiry_renewal", {"resource_type": resource_type, "resource_id": resource_id, "days": days}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_cloud, can_view_cloud_resources from app.domains.cmdb.expiry_alert_engine import ( acknowledge_expiry, resolve_expiry_owners, ) if resource_type not in ("certificate", "domain"): raise NotFoundError("ExpiryResource", resource_type) if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") if not can_manage_cloud(rt.user.role_names): owners = await resolve_expiry_owners(rt.db, resource_type, resource_id) if str(rt.user.id) not in owners: raise ForbiddenError("Not a responsible owner of this resource.") row = await acknowledge_expiry( rt.db, resource_type=resource_type, resource_id=resource_id, actor_person_id=str(rt.user.id), days=max(1, min(90, int(days))), ) return { "acknowledged_until": row.acknowledged_until.isoformat(), "resource_type": resource_type, "resource_id": resource_id, } @mcp_server.tool( name="list_dns_records", description=( "List DNS records. Filters: integration_id, zone_name, " "record_type (A/AAAA/CNAME/TXT/MX/...), search (matches name), " "owner_person_id (pass ``'me'`` for the caller's own records — " "this is how you discover the ``record_ids`` that " "``transfer_my_dns_records`` / ``recertify_my_dns_records`` " "operate on). Every row carries ``owner_person_id`` + " "``owner_label`` so an unfiltered listing is still attributable. " "Gated on `can_view_cloud_resources` — every authenticated " "employee passes; suspended / deleted users do not." ), ) async def list_dns_records( integration_id: str | None = None, zone_name: str | None = None, record_type: str | None = None, search: str | None = None, owner_person_id: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_dns_records", { "integration_id": integration_id, "zone_name": zone_name, "record_type": record_type, "search": search, "owner_person_id": owner_person_id, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_cloud_resources from app.domains.cmdb.service import CmdbService if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") # DNS records are a company-public asset (ADR 2026-06-09): every # caller who passes the cloud-view gate sees the FULL list. Mirrors # the HTTP GET /dns-records endpoint — no owner auto-demote here. # ``owner_person_id`` is therefore a VIEW FILTER, not a security # boundary (same wording as the HTTP twin) — but without it an AI # client had no way to answer "which records do I own?", which is # the precondition for transfer_my_dns_records / # recertify_my_dns_records. ``'me'`` resolves to the caller so the # LLM never has to know its own UUID. owner = ( str(rt.user.id) if owner_person_id == "me" else owner_person_id ) if owner and owner != str(rt.user.id): _u(owner) if integration_id: _u(integration_id) rows, total = await CmdbService.list_dns_records( rt.db, integration_id=integration_id, zone_name=zone_name, record_type=record_type, search=search, owner_person_id=owner, page=page, page_size=page_size, ) # Owner labels in one batched query (no per-row N+1) — parity with # the HTTP twin, which populates ``owner_label`` for the same # my-cloud-dns surface. from app.domains.cmdb.router import _resolve_owner_labels owner_labels = await _resolve_owner_labels( rt.db, [r.owner_person_id for r in rows], ) return { "items": [ { "id": str(r.id), "zone_name": r.zone_name, "name": r.name, "record_type": r.record_type, "value": r.value, "ttl": r.ttl, "owner_person_id": r.owner_person_id, "owner_label": ( owner_labels.get(r.owner_person_id) if r.owner_person_id else None ), } for r in rows ], "total": total, "page": page, "page_size": page_size, } # ════════════════════════════════════════════════════════════════════ # Provisioning tasks (onboarding / offboarding) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_my_provisioning_tasks", description=( "List provisioning tasks assigned to the user — onboarding " "checklist items, account-creation tasks, etc. Filter by " "status (pending/in_progress/completed/failed)." ), ) async def list_my_provisioning_tasks( status: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_provisioning_tasks", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.provisioning.service import ProvisioningService rows, total = await ProvisioningService(rt.db).list_tasks( page=page, page_size=page_size, status=status, assignee_id=rt.user.id, ) return { "items": [ { "id": str(t.id), "user_id": str(t.user_id) if t.user_id else None, "target_type": t.target_type, "status": t.status, "assignee_id": str(t.assignee_id) if t.assignee_id else None, # Bug context (caught pre-launch 2026-05-04, second-pass # review): an earlier revision returned ``"phase": t.phase`` # — but ``ProvisioningTask`` has no ``phase`` column. The # closest semantic is ``action`` (provision / deprovision # / update). Surfacing both ``action`` and ``target_type`` # gives the LLM client enough to render the task name # ("provision google_account") without joining anything. "action": t.action, "execution_mode": t.execution_mode, "external_ref": t.external_ref, "error_message": t.error_message, "created_at": _iso_utc(t.created_at), } for t in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="list_provisioning_tasks", description=( "**Admin-only** counterpart to list_my_provisioning_tasks — lists " "any provisioning task in the queue, across all users. Supports " "the same filter set the Software Seat Cancellations overview " "uses: `action` (grant|revoke), `execution_modes` (CSV of " "auto_api,manual_notify,sso_auto), `statuses` (CSV of " "pending,in_progress,notified,completed,failed,cancelled), plus " "single-value `status` / `source` / `user_id` / `software_id` / " "`assignee_id` / `keyword` (case-insensitive substring against " "the target's display_name + work_email + software catalog " "name, capped at 100 chars, LIKE-metachars escaped) / " "`order_by` (`created_at` default, or `completed_at` for the " "Completed segment to surface most-recently-finished first). " "Mirrors `GET /api/v1/provisioning/tasks` with global_admin " "gate. Bounded pagination (max 200)." ), ) async def list_provisioning_tasks( status: str | None = None, statuses: str | None = None, source: str | None = None, user_id: str | None = None, software_id: str | None = None, assignee_id: str | None = None, action: str | None = None, execution_modes: str | None = None, keyword: str | None = None, order_by: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) # Mirror the HTTP route's keyword length cap. The HTTP layer # uses fastapi's Query(max_length=100); MCP calls bypass that # layer, so we re-apply the cap here to keep the LIKE-pattern # scan bounded regardless of caller. if keyword is not None and len(keyword) > 100: from app.core.exceptions import ValidationError raise ValidationError( "keyword exceeds 100 characters", ) async with tool_runtime( "list_provisioning_tasks", { "status": status, "statuses": statuses, "source": source, "user_id": user_id, "software_id": software_id, "assignee_id": assignee_id, "action": action, "execution_modes": execution_modes, "keyword": keyword, "order_by": order_by, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import AppException from app.core.rbac import GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role from app.domains.provisioning.service import ProvisioningService # Mirrors HTTP route: global_admin + management (read-only) # see all tasks. Employees use list_my_provisioning_tasks. if not has_role( rt.user.role_names, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, ): raise AppException( code="FORBIDDEN", message="list_provisioning_tasks 仅 IT 管理员 / 管理层可访问 — " "员工请用 list_my_provisioning_tasks", status_code=403, ) statuses_list = ( [s.strip() for s in (statuses or "").split(",") if s.strip()] or None ) exec_modes_list = ( [m.strip() for m in (execution_modes or "").split(",") if m.strip()] or None ) rows, total = await ProvisioningService(rt.db).list_tasks( page=page, page_size=page_size, status=status, statuses=statuses_list, source=source, user_id=_u(user_id) if user_id else None, software_id=_u(software_id) if software_id else None, assignee_id=_u(assignee_id) if assignee_id else None, action=action, execution_modes=exec_modes_list, keyword=keyword, order_by=order_by, ) return { "items": [ { "id": str(t.id), "user_id": str(t.user_id) if t.user_id else None, "user_name": t.user_name, "user_email": t.user_email, "software_id": ( str(t.software_id) if t.software_id else None ), "software_name": t.software_name, "target_type": t.target_type, "action": t.action, "status": t.status, "execution_mode": t.execution_mode, "source": t.source, "assignee_id": ( str(t.assignee_id) if t.assignee_id else None ), "external_ref": t.external_ref, "error_message": t.error_message, "attempt_count": t.attempt_count, "created_at": ( _iso_utc(t.created_at) ), "completed_at": ( _iso_utc(t.completed_at) ), } for t in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="complete_provisioning_task", description=( "Mark a manual provisioning task complete (e.g. 'I've set up " "the laptop'). **Callable by the task's assignee OR an IT/global " "admin** — matches the HTTP route gate. Other employees get 403. " "note describes what was done; external_ref is an optional " "pointer (URL, ticket id) for audit." ), ) async def complete_provisioning_task( task_id: str, note: str | None = None, external_ref: str | None = None, ) -> dict: async with tool_runtime( "complete_provisioning_task", {"task_id": task_id, "note": note, "external_ref": external_ref}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.domains.provisioning.service import ProvisioningService svc = ProvisioningService(rt.db) # Mirror the HTTP route's gate: assignee OR global-admin only. # Without this any employee with a PAT could mark anyone's # manual task complete. task = await svc._get_task(_u(task_id)) is_admin = has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE) is_assignee = task.assignee_id == rt.user.id if not (is_admin or is_assignee): raise ForbiddenError( "Only the task assignee or an IT admin can complete this task.", ) # Cap free-text at the MCP boundary — service writes ``note`` # into the provisioning-task row + audit detail. HTTP twin # ``TaskCompleteRequest`` caps at max_length=2000. ``external_ref`` # is typically a ticket id / URL but still capped defensively # at 512 to prevent abuse. (R4 logic sweep: Class C cap.) note = _bound_free_text(note, field="note", max_len=2000) external_ref = _bound_free_text( external_ref, field="external_ref", max_len=512, ) result = await svc.complete_manual_task( task_id=_u(task_id), note=note, external_ref=external_ref, trace_id=_trace_str(), actor_user_id=rt.user.id, actor_is_admin=is_admin, ) return serialize(result) @mcp_server.tool( name="execute_provisioning_task", description=( "**IT/global admin only.** Force-execute an automated provisioning " "task — triggers a real connector call (Google / Feishu / Slack / " "Jira / etc.) on the task's target user. Use only for tasks stuck " "in 'pending' you genuinely want to retry; the dispatcher runs " "them every 30s. Mirrors the HTTP route's `require_admin` gate." ), ) async def execute_provisioning_task(task_id: str) -> dict: async with tool_runtime( "execute_provisioning_task", {"task_id": task_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.domains.provisioning.models import ProvisioningTask from app.domains.provisioning.service import ProvisioningService # Mirror the HTTP route's `require_admin()` gate. Force-executing # a provisioning task triggers a real cloud-side write (e.g. # Google Workspace user creation, Feishu seat grant) on the # task's target user — must be admin-only. if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE): raise ForbiddenError( "Only IT/global admins can force-execute provisioning tasks.", ) task = await rt.db.get(ProvisioningTask, _u(task_id)) if task is None: raise NotFoundError("Provisioning task", task_id) result = await ProvisioningService(rt.db).execute_task( task=task, trace_id=_trace_str(), actor_user_id=rt.user.id, ) return serialize(result) @mcp_server.tool( name="get_my_onboarding_progress", description=( "Return the current user's onboarding checklist (used during " "the first weeks of employment): total tasks, completed, " "pending, failed counts plus per-task status." ), ) async def get_my_onboarding_progress() -> dict: async with tool_runtime("get_my_onboarding_progress", {}) as rt: from app.domains.provisioning.models import ProvisioningTask rows = list(( await rt.db.execute( select(ProvisioningTask) .where(ProvisioningTask.user_id == rt.user.id) .order_by(ProvisioningTask.created_at.desc()) .limit(500) # safety cap — prevents OOM on pathological data ) ).scalars().all()) total = len(rows) completed = sum(1 for t in rows if t.status == "completed") pending = sum(1 for t in rows if t.status in ("pending", "in_progress")) failed = sum(1 for t in rows if t.status == "failed") return { "total": total, "completed": completed, "pending": pending, "failed": failed, "tasks": [ { "id": str(t.id), "target_type": t.target_type, "status": t.status, # See list_my_provisioning_tasks — ``phase`` does not # exist on ProvisioningTask; surface ``action`` instead. "action": t.action, "completed_at": ( _iso_utc(t.completed_at) ), } for t in rows ], } # ════════════════════════════════════════════════════════════════════ # Dashboard summary (role-aware) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="get_dashboard_summary", description=( "Return the home-page dashboard summary scoped to the user's " "role. For an employee this is 'my open requests' + 'my " "pending tasks'; managers also see subordinate counts; admins " "see global KPIs. Mirror of the web UI's /dashboard page." ), ) async def get_dashboard_summary() -> dict: async with tool_runtime("get_dashboard_summary", {}) as rt: # The dashboard logic lives as private functions in the router # module (no DashboardService class — see app/dashboard/router.py). # We import and call them directly so the MCP path returns the # same shape as the web UI. from datetime import date as _date from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.dashboard.router import ( _admin_section, _employee_section, _manager_section, _subordinate_ids, ) today = _date.today() is_admin = has_role(rt.user.role_names or [], GLOBAL_ADMIN_ROLE) or has_role( rt.user.role_names or [], "it_admin", ) admin_section = await _admin_section(rt.db, today) if is_admin else None subordinate_ids = await _subordinate_ids(rt.db, rt.user.id) manager_section = ( await _manager_section(rt.db, subordinate_ids, today) if subordinate_ids else None ) employee_section = await _employee_section(rt.db, rt.user.id, today) return { "role": "admin" if is_admin else ("manager" if manager_section else "employee"), "admin": serialize(admin_section), "manager": serialize(manager_section), "employee": serialize(employee_section), } # ════════════════════════════════════════════════════════════════════ # Compliance — open incidents (read-only governance surface) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_compliance_incidents", description=( "**Global-admin only** — matches the HTTP route's " "`require_admin` gate. List compliance incidents (filters: " "status open/resolved/dismissed, severity info/warning/" "critical). Default ``status='open'`` mirrors the HTTP route " "so page-1 results match the web UI; pass ``status=None`` " "explicitly to see every status. Bounded pagination — max 200 " "rows." ), ) async def list_compliance_incidents( status: str | None = "open", severity: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_compliance_incidents", { "status": status, "severity": severity, "page": page, "page_size": page_size, }, ) as rt: # Compliance incidents carry ``person_id``, ``resource_id``, # ``title``, and free-text ``resolution_note`` (often containing # PII about the subject). HTTP gates with ``require_admin()`` — # mirror exactly so MCP can't leak compliance content to a role # HTTP would 403. (Previously used the wider audit-access gate # which let auditor / management roles through.) _require_global_admin(rt.user.role_names) from app.compliance.service import ComplianceIncidentService rows, total = await ComplianceIncidentService(rt.db).list_incidents( status=status, severity=severity, page=page, page_size=page_size, ) return { "items": [ { "id": str(r.id), "incident_type": r.incident_type, "severity": r.severity, "status": r.status, "person_id": str(r.person_id) if r.person_id else None, "resource_type": r.resource_type, "resource_id": r.resource_id, "title": r.title, "detected_at": ( _iso_utc(r.detected_at) ), "resolved_at": ( _iso_utc(r.resolved_at) ), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="get_compliance_incident", description=( "**Global-admin only.** Return full detail for one compliance " "incident (matches the HTTP route's `require_admin` gate). " "Non-admins get 403 — without this gate any PAT holder could " "read any incident by guessing UUIDs." ), ) async def get_compliance_incident(incident_id: str) -> dict: async with tool_runtime( "get_compliance_incident", {"incident_id": incident_id}, ) as rt: _require_global_admin(rt.user.role_names) from app.compliance.service import ComplianceIncidentService row = await ComplianceIncidentService(rt.db).get_incident(_u(incident_id)) return serialize(row) @mcp_server.tool( name="resolve_compliance_incident", description=( "**Global-admin only** — matches the HTTP route's `require_admin` " "gate. Close one compliance incident. ``accepted=False`` (default) " "means 'fixed — the offending resource is now properly owned / " "removed'; ``accepted=True`` means 'known & expected, intentional " "exception' (closed but kept distinct for auditing). Optional " "``resolution_note`` records why. Idempotent at the service layer. " "1:1 MCP twin of POST /compliance-incidents/{id}/resolve." ), ) async def resolve_compliance_incident( incident_id: str, accepted: bool = False, resolution_note: str | None = None, ) -> dict: async with tool_runtime( "resolve_compliance_incident", {"incident_id": incident_id, "accepted": accepted, "resolution_note": resolution_note}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.compliance.service import ComplianceIncidentService from app.mcp.context import get_or_init_trace_id row = await ComplianceIncidentService(rt.db).resolve_incident( _u(incident_id), resolved_by_person_id=rt.user.id, resolution_note=resolution_note, accepted=accepted, trace_id=str(get_or_init_trace_id()), ) await rt.db.flush() return serialize(row) # ════════════════════════════════════════════════════════════════════ # GRC posture — compliance frameworks & control catalog (read) # ════════════════════════════════════════════════════════════════════ # # The governance layer above the detective controls. Gated on # ``can_view_security`` to exactly mirror the HTTP ``/api/v1/compliance`` # routes (global_admin / auditor / management read). def _require_security_view(role_names) -> None: from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_security if not can_view_security(role_names): raise ForbiddenError("compliance read access required") def _require_security_manage(role_names) -> None: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_security if not can_manage_security(role_names): raise ForbiddenError("compliance manage access required") @mcp_server.tool( name="get_compliance_posture", description=( "GRC posture headline: for every enabled framework (SOC2, " "ISO27001, …) the readiness %, requirement count, and coverage " "breakdown (covered_pass / covered_partial / covered_fail / " "not_covered), plus a rollup of control statuses (pass / fail / " "stale / not_configured). Mirrors the 合规总览 dashboard. " "Read-only — global_admin / auditor / management." ), ) async def get_compliance_posture() -> dict: async with tool_runtime("get_compliance_posture", {}) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import compliance_service as svc return await svc.get_posture(rt.db) @mcp_server.tool( name="list_compliance_controls", description=( "List the compliance control catalog with each control's live " "status (pass / fail / stale / not_configured), score, category, " "backing signal, and the framework requirement refs it satisfies " "(e.g. 'soc2:CC6.1'). Read-only — global_admin / auditor / " "management." ), ) async def list_compliance_controls() -> dict: async with tool_runtime("list_compliance_controls", {}) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import compliance_service as svc return {"controls": await svc.list_controls(rt.db)} @mcp_server.tool( name="get_compliance_framework", description=( "Detail for one framework by key ('soc2' / 'iso27001'): every " "requirement clause, its coverage state, and the controls " "satisfying it with their live status. Use this to answer 'are " "we ready for SOC2 / ISO27001 and which clauses are failing'. " "Read-only — global_admin / auditor / management." ), ) async def get_compliance_framework(framework_key: str) -> dict: async with tool_runtime( "get_compliance_framework", {"framework_key": framework_key}, ) as rt: _require_security_view(rt.user.role_names) from app.core.exceptions import NotFoundError from app.domains.auditor import compliance_service as svc data = await svc.get_framework(rt.db, framework_key) if data is None: # NotFoundError takes (resource, resource_id) — a single # f-string arg raised TypeError → 500 instead of the # typed 404. (Launch audit 2026-06-10.) raise NotFoundError("compliance framework", framework_key) return data @mcp_server.tool( name="list_access_certifications", description=( "List access-certification campaigns (periodic access reviews) " "with each campaign's type, scope, status, and decided/revoked " "counts. Read-only — global_admin / auditor / management." ), ) async def list_access_certifications() -> dict: async with tool_runtime("list_access_certifications", {}) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import access_certification_service as acs return {"campaigns": await acs.list_campaigns(rt.db)} @mcp_server.tool( name="get_access_self_attestation_summary", description=( "Auditor roll-up of a half-yearly access self-attestation campaign: " "coverage %, per-decision / per-type counts, the flagged-removal " "disposition queue (what employees asked to clear + its enforcement " "status), and non-responders (holders with pending items, overdue " "flagged) — 「谁还没做访问权限自查」「哪些人没响应」 is answered " "here. The platform has a SECOND thing called 自查: " "`get_self_check_overview` / `list_self_check_records` are the " "周期性自我检查 control ledger, organised by check CATEGORY rather " "than by person. `certification_id` is from list_access_certifications " "(campaign_type='self_attestation'). Read-only — global_admin / " "auditor / management. Returns null if the id isn't a self-attestation " "campaign." ), ) async def get_access_self_attestation_summary(certification_id: str) -> dict: async with tool_runtime( "get_access_self_attestation_summary", {"certification_id": certification_id}, ) as rt: from uuid import UUID _require_security_view(rt.user.role_names) from app.domains.auditor import access_certification_service as acs data = await acs.build_self_attestation_summary( rt.db, certification_id=UUID(certification_id), ) return {"summary": data} @mcp_server.tool( name="vendor_assessment_overview", description=( "安全问卷 · 供应商安全评估态势: counts by status / risk rating / " "tier, plus high-risk vendors, assessments with an upcoming or " "overdue re-review, and responses overdue from the vendor. This " "is the 甲方侧 (we-assess-a-vendor) TPRM posture. Read-only — " "global_admin / auditor / management." ), ) async def vendor_assessment_overview() -> dict: async with tool_runtime("vendor_assessment_overview", {}) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import vendor_assessment_service as vsvc return await vsvc.get_overview(rt.db) @mcp_server.tool( name="list_vendor_assessments", description=( "List vendor security assessments (甲方侧供应商安全评估) with " "vendor, tier, status, score % and risk rating. Optional filters: " "status (draft/sent/answered/completed/archived/cancelled), tier " "(critical/high/medium/low), risk_rating, and a vendor-name search. " "Read-only — global_admin / auditor / management." ), ) async def list_vendor_assessments( status: str | None = None, tier: str | None = None, risk_rating: str | None = None, search: str | None = None, limit: int = 50, ) -> dict: async with tool_runtime( "list_vendor_assessments", {"status": status, "tier": tier, "risk_rating": risk_rating, "search": search, "limit": limit}, ) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import vendor_assessment_service as vsvc return await vsvc.list_assessments( rt.db, status=status, tier=tier, risk_rating=risk_rating, search=search, page=1, page_size=min(max(int(limit), 1), 200), ) @mcp_server.tool( name="get_vendor_assessment", description=( "Full detail of one vendor security assessment: metadata, score / " "risk rating, per-section answers with the vendor's responses and " "the auditor's review flags, evidence attachments, and finding " "counts. Read-only — global_admin / auditor / management." ), ) async def get_vendor_assessment(assessment_id: str) -> dict: async with tool_runtime( "get_vendor_assessment", {"assessment_id": assessment_id}, ) as rt: from uuid import UUID _require_security_view(rt.user.role_names) from app.domains.auditor import vendor_assessment_service as vsvc return await vsvc.get_assessment_detail(rt.db, UUID(assessment_id)) @mcp_server.tool( name="list_vendor_questionnaire_templates", description=( "List the reusable vendor security-questionnaire templates " "(ISO 27001:2022 + 27701 full / lite, plus any cloned custom ones) " "with their question counts — use a template_id from here to create " "an assessment. Read-only — global_admin / auditor / management." ), ) async def list_vendor_questionnaire_templates() -> dict: async with tool_runtime("list_vendor_questionnaire_templates", {}) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import vendor_assessment_service as vsvc return {"templates": await vsvc.list_templates(rt.db, include_disabled=False)} @mcp_server.tool( name="create_vendor_assessment", description=( "Start a new vendor security assessment from a template — " "materialises one answer row per template question. Provide " "template_id (from list_vendor_questionnaire_templates), the " "vendor_name, and a tier (critical/high/medium/low; drives the " "re-review cadence). Optionally a vendor contact + due date. " "Mutates Helix. global_admin / auditor only." ), ) async def create_vendor_assessment( template_id: str, vendor_name: str, tier: str = "medium", vendor_contact_name: str | None = None, vendor_contact_email: str | None = None, ) -> dict: async with tool_runtime( "create_vendor_assessment", {"template_id": template_id, "vendor_name": vendor_name, "tier": tier}, mutates=True, ) as rt: from uuid import UUID from app.audit.service import AuditService _require_security_manage(rt.user.role_names) from app.domains.auditor import vendor_assessment_service as vsvc a = await vsvc.create_assessment( rt.db, payload={ "template_id": UUID(template_id), "vendor_name": vendor_name, "tier": tier, "vendor_contact_name": vendor_contact_name, "vendor_contact_email": vendor_contact_email, }, actor_id=rt.user.id, ) await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="auditor.vendor_assessment.create", resource_type="auditor_vendor_assessment", resource_id=str(a.id), status="success", detail={"vendor": a.vendor_name, "tier": a.tier, "via": "mcp"}, ) await rt.db.commit() return {"id": str(a.id), "vendor_name": a.vendor_name, "status": a.status} @mcp_server.tool( name="complete_vendor_assessment", description=( "Finalize a vendor security assessment: re-score all answers, set " "the risk rating, schedule the next re-review, and promote any " "answers flagged 'finding' into tracked security findings. Provide " "the assessment_id and an optional conclusion summary. Mutates " "Helix. global_admin / auditor only." ), ) async def complete_vendor_assessment( assessment_id: str, summary: str | None = None, ) -> dict: async with tool_runtime( "complete_vendor_assessment", {"assessment_id": assessment_id}, mutates=True, ) as rt: from uuid import UUID from app.audit.service import AuditService _require_security_manage(rt.user.role_names) from app.domains.auditor import vendor_assessment_service as vsvc a = await vsvc.complete_assessment( rt.db, UUID(assessment_id), summary=summary, reviewer_person_id=rt.user.id, ) await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="auditor.vendor_assessment.complete", resource_type="auditor_vendor_assessment", resource_id=str(a.id), status="success", detail={"score_pct": a.score_pct, "risk_rating": a.risk_rating, "via": "mcp"}, ) await rt.db.commit() return {"id": str(a.id), "status": a.status, "score_pct": a.score_pct, "risk_rating": a.risk_rating} @mcp_server.tool( name="search_answer_library", description=( "搜索安全问卷答案库 — search the reusable bilingual Q&A knowledge base " "used to answer customer (乙方侧) security questionnaires. Full-text over " "question + answer (zh/en); optional category filter. Returns entries " "with their standard answer, owner, usage count and staleness flag. " "Read-only — global_admin / auditor / management." ), ) async def search_answer_library( search: str | None = None, category: str | None = None, limit: int = 20, ) -> dict: async with tool_runtime( "search_answer_library", {"search": search, "category": category, "limit": limit}, ) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import answer_library_service as svc return await svc.list_entries( rt.db, category=category, status="active", search=search, page=1, page_size=min(max(int(limit), 1), 200), ) @mcp_server.tool( name="list_inbound_questionnaires", description=( "List inbound customer security questionnaires (乙方侧 — questionnaires a " "customer sent US to fill) with customer, title, due date, owner, status " "and answered/reviewed progress. Optional status / owner / search filters. " "Read-only — global_admin / auditor / management." ), ) async def list_inbound_questionnaires( status: str | None = None, search: str | None = None, limit: int = 50, ) -> dict: async with tool_runtime( "list_inbound_questionnaires", {"status": status, "search": search, "limit": limit}, ) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import inbound_service as svc return await svc.list_questionnaires( rt.db, status=status, search=search, page=1, page_size=min(max(int(limit), 1), 200), ) @mcp_server.tool( name="get_inbound_questionnaire", description=( "Full detail of one inbound customer questionnaire: metadata, every " "question with its drafted/final answer + review status + matched " "answer-library entry, and the archived exported versions. Read-only — " "global_admin / auditor / management." ), ) async def get_inbound_questionnaire(questionnaire_id: str) -> dict: async with tool_runtime( "get_inbound_questionnaire", {"questionnaire_id": questionnaire_id}, ) as rt: from uuid import UUID _require_security_view(rt.user.role_names) from app.domains.auditor import inbound_service as svc return await svc.get_detail(rt.db, UUID(questionnaire_id)) @mcp_server.tool( name="list_evidence_documents", description=( "List OUR security evidence documents (证据文档库) — the artifacts " "customers ask for: ISO 27701 / SOC 2 certs, DR plan, pentest reports, " "policies. Shows category, version, validity date + expiry, and whether " "each is shareable. Optional category / shareable-only / search filters. " "Read-only — global_admin / auditor / management." ), ) async def list_evidence_documents( category: str | None = None, shareable_only: bool = False, search: str | None = None, ) -> dict: async with tool_runtime( "list_evidence_documents", {"category": category, "shareable_only": shareable_only, "search": search}, ) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import evidence_doc_service as svc return {"items": await svc.list_docs( rt.db, category=category, shareable_only=shareable_only, search=search, )} @mcp_server.tool( name="create_inbound_questionnaire", description=( "Register a new inbound customer security questionnaire (乙方侧). Provide " "the customer_name, a title, and optionally a due date (ISO yyyy-mm-dd). " "Add questions afterwards via the web UI (paste / xlsx import) then " "ai_draft_inbound_answers. Mutates Helix. global_admin / auditor only." ), ) async def create_inbound_questionnaire( customer_name: str, title: str, due_date: str | None = None, ) -> dict: async with tool_runtime( "create_inbound_questionnaire", {"customer_name": customer_name, "title": title, "due_date": due_date}, mutates=True, ) as rt: from app.audit.service import AuditService _require_security_manage(rt.user.role_names) from app.domains.auditor import inbound_service as svc q = await svc.create_questionnaire( rt.db, payload={"customer_name": customer_name, "title": title, "due_date": due_date}, actor_id=rt.user.id, ) await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="auditor.inbound.create", resource_type="auditor_inbound_questionnaire", resource_id=str(q.id), status="success", detail={"customer": q.customer_name, "via": "mcp"}, ) await rt.db.commit() return {"id": str(q.id), "customer_name": q.customer_name, "status": q.status} @mcp_server.tool( name="ai_draft_inbound_answers", description=( "AI-draft answers for a customer questionnaire's still-unanswered " "questions by matching them against our answer library (Bailian/Qwen). " "Each drafted answer stays UNREVIEWED until a human confirms it in the " "web UI. Requires the platform LLM key (503 if unconfigured). Mutates " "Helix. global_admin / auditor only." ), ) async def ai_draft_inbound_answers(questionnaire_id: str) -> dict: async with tool_runtime( "ai_draft_inbound_answers", {"questionnaire_id": questionnaire_id}, mutates=True, ) as rt: from uuid import UUID _require_security_manage(rt.user.role_names) from app.domains.auditor import inbound_service as svc result = await svc.ai_draft_pending(rt.db, UUID(questionnaire_id)) await rt.db.commit() return result @mcp_server.tool( name="create_access_certification", description=( "Open a new access-certification campaign (snapshots the in-scope " "entitlements now). ``campaign_type`` defaults to 'software_seat' " "(reviews every software seat held by someone in the department " "subtree named in ``scope_config.department``; reviewer = the " "holder's manager). ``due_in_days`` 1–180. Returns the created " "campaign incl. snapshot_item_count. **Not** for " "``self_attestation`` — that type is rejected here and must be " "opened via the dedicated opener, which holds the open lock, is " "idempotent by period label, and notifies every holder. 1:1 with " "POST /compliance/certifications. Requires can_manage_security " "(global_admin / auditor)." ), ) async def create_access_certification( label: str, scope_config: dict | None = None, campaign_type: str = "software_seat", due_in_days: int = 14, ) -> dict: from datetime import UTC, datetime, timedelta label = (label or "").strip() async with tool_runtime( "create_access_certification", {"label": label, "scope_config": scope_config, "campaign_type": campaign_type, "due_in_days": due_in_days}, mutates=True, ) as rt: # RBAC first, then input validation (so a non-admin gets 403, not a # 422 that would confirm the param shape). _require_security_manage(rt.user.role_names) from app.core.exceptions import AppException if not (1 <= len(label) <= 128): raise AppException( code="INVALID_LABEL", message="label must be 1–128 characters", status_code=422, ) if not (1 <= int(due_in_days) <= 180): raise AppException( code="INVALID_DUE_IN_DAYS", message="due_in_days must be between 1 and 180", status_code=422, ) from app.domains.auditor import access_certification_service as acs cert = await acs.create_campaign( rt.db, label=label, campaign_type=campaign_type, scope_config=scope_config or {}, due_at=datetime.now(UTC) + timedelta(days=int(due_in_days)), actor_person_id=rt.user.id, ) await rt.db.flush() return await acs.get_campaign(rt.db, cert.id) @mcp_server.tool( name="decide_access_certification_item", description=( "Record a reviewer decision on one access-certification item: " "'keep' (re-attest the access) or 'revoke' (remove it — this " "triggers real downstream fulfilment via the provisioning queue, " "not just an attestation). A note is optional. 1:1 with POST " "/compliance/certifications/items/{id}/decide. Requires " "can_manage_security (global_admin / auditor)." ), ) async def decide_access_certification_item( item_id: str, decision: str, note: str | None = None, ) -> dict: async with tool_runtime( "decide_access_certification_item", {"item_id": item_id, "decision": decision, "note": note}, mutates=True, ) as rt: _require_security_manage(rt.user.role_names) from app.domains.auditor import access_certification_service as acs from app.mcp.context import get_or_init_trace_id item = await acs.decide_item( rt.db, item_id=_u(item_id), decision=decision, note=note, actor_person_id=rt.user.id, trace_id=get_or_init_trace_id(), ) await rt.db.flush() return { "id": str(item.id), "decision": item.decision, "revocation_status": item.revocation_status, } @mcp_server.tool( name="complete_access_certification", description=( "Close an access-certification campaign. Blocked (409 " "CAMPAIGN_HAS_PENDING) while any item is still undecided. 1:1 with " "POST /compliance/certifications/{id}/complete. Requires " "can_manage_security (global_admin / auditor)." ), ) async def complete_access_certification(certification_id: str) -> dict: async with tool_runtime( "complete_access_certification", {"certification_id": certification_id}, mutates=True, ) as rt: _require_security_manage(rt.user.role_names) from app.domains.auditor import access_certification_service as acs from app.mcp.context import get_or_init_trace_id cert = await acs.complete_campaign( rt.db, certification_id=_u(certification_id), actor_person_id=rt.user.id, trace_id=get_or_init_trace_id(), ) await rt.db.flush() return {"id": str(cert.id), "status": cert.status} @mcp_server.tool( name="list_security_findings", description=( "The unified security worklist: actionable findings across every " "detective control (MFA gaps, overdue secrets, public exposures, " "sign-in anomalies, risky tokens, expiring certs) with a uniform " "lifecycle (open / acknowledged / accepted_risk / resolved). " "Defaults to needs-attention items (open + acknowledged), ordered " "critical-first. Filter by severity / control_key. Returns the " "rows plus a severity/control summary. Read-only — global_admin / " "auditor / management." ), ) async def list_security_findings( severity: str | None = None, control_key: str | None = None, open_only: bool = True, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_security_findings", {"severity": severity, "control_key": control_key, "open_only": open_only, "page": page, "page_size": page_size}, ) as rt: _require_security_view(rt.user.role_names) from app.domains.auditor import findings_service as fs result = await fs.list_findings( rt.db, severity=severity, control_key=control_key, open_only=open_only, page=min(max(page, 1), 1000), page_size=min(max(page_size, 1), 200), ) summary = await fs.findings_summary(rt.db) return { "findings": result["items"], "total": result["total"], "summary": summary, } @mcp_server.tool( name="decide_security_finding", description=( "Act on a security finding: 'acknowledge' (triaged, still open), " "'accept_risk' (knowingly accept, optional accepted_days expiry), " "'resolve' (fixed), or 'reopen'. A note is required. Public-" "exposure findings accept accept_risk/resolve/reopen; sign-in " "findings accept acknowledge/resolve/reopen. Mirrors the decision " "onto the underlying control row. Requires can_manage_security " "(global_admin / auditor)." ), ) async def decide_security_finding( finding_id: str, decision: str, note: str, accepted_days: int | None = None, ) -> dict: async with tool_runtime( "decide_security_finding", {"finding_id": finding_id, "decision": decision, "note": note, "accepted_days": accepted_days}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_security if not can_manage_security(rt.user.role_names): raise ForbiddenError("can_manage_security required") from app.domains.auditor import findings_service as fs from app.mcp.context import get_or_init_trace_id row = await fs.decide_finding( rt.db, finding_id=_u(finding_id), decision=decision, note=note, accepted_days=accepted_days, actor_user_id=rt.user.id, trace_id=get_or_init_trace_id(), ) await rt.db.flush() return await fs.view_finding(rt.db, row) # ════════════════════════════════════════════════════════════════════ # Notification inbox actions (read state, summary) # ════════════════════════════════════════════════════════════════════ # # ``list_my_notifications`` already returns inbox rows. The two tools # below complete the bell-icon contract: the unread badge count for # the dropdown, and a way to mark items read once the AI surfaces a # notification to the user. Both are scoped to the calling user via # ``recipient_person_id``; a malicious caller cannot mark another # user's rows even by guessing IDs. @mcp_server.tool( name="get_my_notifications_summary", description=( "Return inbox totals for the calling user: total delivered, " "unread, and the most recent created_at. Mirrors the bell-icon " "badge count in the web UI and is useful as a first call before " "list_my_notifications so the AI knows whether anything new " "needs surfacing." ), ) async def get_my_notifications_summary() -> dict: async with tool_runtime("get_my_notifications_summary", {}) as rt: # Route through the shared InboxService so this MCP surface stays in # lock-step with the HTTP /inbox/summary — same visibility rules and # the same secret-row exclusion (a recorded first-login-password row # must not inflate the recipient's unread badge here either). from app.notifications.inbox_service import InboxService summary = await InboxService(rt.db).summary_for_recipient( recipient_id=rt.user.id, ) return { "total": int(summary["total"]), "unread": int(summary["unread"]), "last_created_at": summary.get("last_created_at"), } @mcp_server.tool( name="mark_my_notifications_read", description=( "Mark notifications as read. Pass either ``ids`` (a list of " "notification UUIDs) OR ``mark_all=True`` — exactly one mode, " "not both. Already-read rows are skipped so the original " "read_at timestamp is preserved. Returns ``{marked: N}``, the " "actual number of rows updated. Only the caller's own rows are " "ever touched." ), ) async def mark_my_notifications_read( ids: list[str] | None = None, mark_all: bool = False, ) -> dict: async with tool_runtime( "mark_my_notifications_read", {"ids": ids, "mark_all": mark_all}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.notifications.inbox_service import InboxService # Cap the list at 500 — without a cap an LLM passing # ``ids=[uuid] * 1_000_000`` would (a) parse 1M UUIDs in # Python, (b) generate a `WHERE id IN (...)` of 1M elements # which TiDB's planner OOMs on, (c) blow up the audit row. if ids is not None and len(ids) > 500: raise AppException( code="VALIDATION_ERROR", message=( "ids may not exceed 500 entries per call; got " f"{len(ids)}. Split into batches or use mark_all=True." ), status_code=422, ) normalized_ids = [_u(x) for x in (ids or [])] # The HTTP endpoint enforces "exactly one of ids/all"; mirror # the gate so the AI can't accidentally mark the wrong batch # by sending both. Empty ``ids`` with ``mark_all=False`` is # also rejected — silently doing nothing would mask a buggy # caller. if mark_all and normalized_ids: raise AppException( code="VALIDATION_ERROR", message="Pass either `mark_all=True` OR `ids`, not both.", status_code=422, ) if not mark_all and not normalized_ids: raise AppException( code="VALIDATION_ERROR", message=( "Either `mark_all=True` or a non-empty `ids` is required." ), status_code=422, ) svc = InboxService(rt.db) if mark_all: marked = await svc.mark_all_read(recipient_id=rt.user.id) else: marked = await svc.mark_read( recipient_id=rt.user.id, ids=normalized_ids, ) return {"marked": marked} @mcp_server.tool( name="resend_notification", description=( "Admin: resend a notification-outbox row as a fresh dispatch " "('再次发送'). Clones the row's envelope (recipient, title, body, " "channels, template) into a brand-new outbox row and delivers it " "now — the original delivery record is left untouched. Unlike a " "retry, this works on ANY source status, DELIVERED included, so " "it is the way to send an already-delivered message again. A " "departed / deleted / person-less recipient is skipped (returns " "status='skipped' with a reason) rather than re-notifying an " "ex-employee. Restricted to global_admin. Pass the outbox row id " "(get it from the admin notification records page / export)." ), ) async def resend_notification(outbox_id: str) -> dict: async with tool_runtime( "resend_notification", {"outbox_id": outbox_id}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.notifications.service import NotificationService svc = NotificationService(rt.db) return await svc.resend_outbox_row( _u(outbox_id), actor_user_id=rt.user.id, ) @mcp_server.tool( name="get_my_notification_preferences", description=( "Get the caller's notification preferences and the category catalog. " "Returns quiet-hours window (local-time HH:MM bounds), the per-" "category muted push channels, the governable channels, and every " "category (key + inbox bucket + whether it is mutable). The in-app " "inbox is never suppressed; critical alerts always push." ), ) async def get_my_notification_preferences() -> dict: async with tool_runtime("get_my_notification_preferences", {}) as rt: from app.notifications.preferences_service import ( NotificationPreferenceService, ) svc = NotificationPreferenceService(rt.db) return svc.serialize(await svc.get(rt.user.id)) @mcp_server.tool( name="set_my_notification_preferences", description=( "Replace the caller's notification preferences. `muted_channels` maps " "a category key to the push channels to mute for it (e.g. " '{"social": ["feishu", "email"]}). Quiet hours are local-time HH:MM ' "bounds (start > end wraps past midnight); during the window non-" "critical push is held and delivered when it lifts. Unknown " "categories/channels and unparseable times are dropped; critical " "alerts and the in-app inbox are never affected. This is a full " "replace — send the complete desired state. Returns the saved prefs." ), ) async def set_my_notification_preferences( muted_channels: dict[str, list[str]] | None = None, quiet_hours_enabled: bool = False, quiet_hours_start: str | None = None, quiet_hours_end: str | None = None, ) -> dict: async with tool_runtime( "set_my_notification_preferences", { "muted_channels": muted_channels, "quiet_hours_enabled": quiet_hours_enabled, "quiet_hours_start": quiet_hours_start, "quiet_hours_end": quiet_hours_end, }, mutates=True, ) as rt: from app.notifications.preferences_service import ( NotificationPreferenceService, ) svc = NotificationPreferenceService(rt.db) row = await svc.upsert( person_id=rt.user.id, muted_channels=muted_channels or {}, quiet_hours_enabled=quiet_hours_enabled, quiet_hours_start=quiet_hours_start, quiet_hours_end=quiet_hours_end, ) return svc.serialize(row) # ════════════════════════════════════════════════════════════════════ # Asset transfer (peer-to-peer hand-off, also drives offboarding) # ════════════════════════════════════════════════════════════════════ # # Mirrors ``/api/v1/transfers`` — every flow the web Transfers page # supports. The same authorization rules apply: only the initiator # can cancel, only the recipient can accept/reject, and creating a # transfer requires that the initiator currently holds the asset. def _serialize_transfer(item) -> dict: """Serialize an :class:`AssetTransferResponse` (Pydantic model returned by ``OffboardingService.list_transfers_for_user``) or a raw ``AssetTransferRequest`` ORM row to a JSON-safe dict. The list endpoint returns Pydantic models; the create / accept / reject / cancel endpoints return ORM rows wrapped via ``_to_transfer_response`` (also a Pydantic model). We accept both because the create path runs after a flush but before the people- map lookup, and we want the same shape regardless. """ from pydantic import BaseModel as _Base if isinstance(item, _Base): return item.model_dump(mode="json") return serialize(item) @mcp_server.tool( name="create_asset_transfer", description=( "Initiate an asset transfer to another employee. ``asset_type`` " "is one of ``hardware`` (the canonical case — laptops, monitors, " "phones), ``api_key`` (an API-key assignment), or ``mail_group`` " "(an email distribution group). Software seats and software " "permissions are NOT transferable — the recipient applies on " "their own; the original holder's seat is auto-revoked at " "offboarding. ``asset_ref_id`` is the target asset / key / group " "UUID. The initiator must currently hold the asset, and the " "recipient must be an active employee (leavers are rejected). " "The recipient gets a Feishu + email notification and either " "accepts (transfer completes, ownership flips) or rejects " "(transfer dies). ``offboarding_process_id`` is optional — pass " "it to link the transfer to a leaver's blocker so the offboarding " "pipeline marks that resource resolved on accept." ), ) async def create_asset_transfer( recipient_id: str, asset_type: str, asset_ref_id: str, reason: str | None = None, offboarding_process_id: str | None = None, ) -> dict: async with tool_runtime( "create_asset_transfer", { "recipient_id": recipient_id, "asset_type": asset_type, "asset_ref_id": asset_ref_id, "reason": reason, "offboarding_process_id": offboarding_process_id, }, mutates=True, ) as rt: # Cap free-text at the MCP boundary — service writes ``reason`` # to ``asset_transfer.reason`` with no Pydantic length validator. # (R1 sweep finding: Class C free-text cap missing.) reason = _bound_free_text(reason, field="reason", max_len=2000) from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) trace_id = _trace_str() xfer = await svc.create_transfer( rt.user.id, recipient_id=_u(recipient_id), asset_type=asset_type, asset_ref_id=_u(asset_ref_id), reason=reason, offboarding_process_id=( _u(offboarding_process_id) if offboarding_process_id else None ), trace_id=trace_id, ) people = await svc._load_people({xfer.initiator_id, xfer.recipient_id}) response = _serialize_transfer(svc._to_transfer_response(xfer, people)) rt.after_commit( lambda: svc.dispatch_transfer_notifications_after_commit( trace_id=trace_id, ), ) return response @mcp_server.tool( name="list_my_incoming_transfers", description=( "List asset transfers where the calling user is the recipient — " "i.e. transfers awaiting the user's accept/reject. Filter by " "status (pending/completed/rejected/cancelled). Bounded " "pagination — max 100 rows; returns ``total`` so the caller " "can page through historical rejections." ), ) async def list_my_incoming_transfers( status: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_incoming_transfers", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) # Push pagination into the query (limit/offset) so we don't load a # long-tenured user's entire transfer history; ``total`` comes from a # cheap count so the caller can still page. offset = (page - 1) * page_size items = await svc.list_transfers_for_user( rt.user.id, direction="incoming", status=status, limit=page_size, offset=offset, ) total = await svc.count_transfers_for_user( rt.user.id, direction="incoming", status=status, ) return { "items": [_serialize_transfer(i) for i in items], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="list_my_outgoing_transfers", description=( "List asset transfers initiated by the calling user — i.e. " "transfers the user offered to someone else. Filter by status " "(pending/completed/rejected/cancelled). Bounded pagination — " "max 100 rows; returns ``total`` so the caller can page through." ), ) async def list_my_outgoing_transfers( status: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_outgoing_transfers", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) offset = (page - 1) * page_size items = await svc.list_transfers_for_user( rt.user.id, direction="outgoing", status=status, limit=page_size, offset=offset, ) total = await svc.count_transfers_for_user( rt.user.id, direction="outgoing", status=status, ) return { "items": [_serialize_transfer(i) for i in items], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="accept_asset_transfer", description=( "Accept an incoming asset transfer. Only the recipient can " "accept, and only when the transfer is still pending. On " "acceptance the asset's ownership flips atomically and any " "linked offboarding blocker is marked transferred. response_note " "is shown to the initiator in the audit trail." ), ) async def accept_asset_transfer( transfer_id: str, response_note: str | None = None, ) -> dict: async with tool_runtime( "accept_asset_transfer", {"transfer_id": transfer_id, "response_note": response_note}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — written to # ``asset_transfer.response_note`` (TEXT) with no Pydantic # length validator anywhere (the HTTP schema itself misses # max_length on response_note; capping here at the MCP layer # also implicitly tightens that surface for AI clients). (R3 # logic sweep.) response_note = _bound_free_text( response_note, field="response_note", max_len=2000, ) from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) trace_id = _trace_str() xfer = await svc.respond_to_transfer( _u(transfer_id), accept=True, response_note=response_note, acting_user_id=rt.user.id, trace_id=trace_id, ) people = await svc._load_people({xfer.initiator_id, xfer.recipient_id}) response = _serialize_transfer(svc._to_transfer_response(xfer, people)) rt.after_commit( lambda: svc.dispatch_transfer_notifications_after_commit( trace_id=trace_id, ), ) return response @mcp_server.tool( name="reject_asset_transfer", description=( "Reject an incoming asset transfer. Only the recipient can " "reject, and only when the transfer is still pending. The " "asset stays with the initiator; response_note is recorded for " "audit and shown to the initiator." ), ) async def reject_asset_transfer( transfer_id: str, response_note: str | None = None, ) -> dict: async with tool_runtime( "reject_asset_transfer", {"transfer_id": transfer_id, "response_note": response_note}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — see accept_asset_transfer # for rationale. (R3 logic sweep.) response_note = _bound_free_text( response_note, field="response_note", max_len=2000, ) from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) xfer = await svc.respond_to_transfer( _u(transfer_id), accept=False, response_note=response_note, acting_user_id=rt.user.id, trace_id=_trace_str(), ) people = await svc._load_people({xfer.initiator_id, xfer.recipient_id}) return _serialize_transfer(svc._to_transfer_response(xfer, people)) @mcp_server.tool( name="cancel_asset_transfer", description=( "Cancel an outgoing asset transfer. Only the initiator can " "cancel, and only while the transfer is still pending." ), ) async def cancel_asset_transfer(transfer_id: str) -> dict: async with tool_runtime( "cancel_asset_transfer", {"transfer_id": transfer_id}, mutates=True, ) as rt: from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) xfer = await svc.cancel_transfer( _u(transfer_id), acting_user_id=rt.user.id, trace_id=_trace_str(), ) people = await svc._load_people({xfer.initiator_id, xfer.recipient_id}) return _serialize_transfer(svc._to_transfer_response(xfer, people)) @mcp_server.tool( name="admin_cancel_asset_transfer", description=( "Admin-override cancel of any pending asset transfer (round-2 " "MCP-sync rule for the new HTTP `/transfers/{id}/admin-cancel` " "endpoint). Use this to clear orphaned peer-to-peer transfers " "where the initiator has left the company and ownership " "transfer no longer makes sense. Requires `it_admin` role; " "writes audit row `transfer.cancelled_admin_override`." ), ) async def admin_cancel_asset_transfer(transfer_id: str) -> dict: async with tool_runtime( "admin_cancel_asset_transfer", {"transfer_id": transfer_id}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE): raise AppException( code="FORBIDDEN", message="Admin-override cancel requires it_admin role", status_code=403, ) from app.domains.offboarding.service import OffboardingService svc = OffboardingService(rt.db) xfer = await svc.cancel_transfer( _u(transfer_id), acting_user_id=rt.user.id, trace_id=_trace_str(), is_admin_override=True, ) people = await svc._load_people({xfer.initiator_id, xfer.recipient_id}) return _serialize_transfer(svc._to_transfer_response(xfer, people)) # ════════════════════════════════════════════════════════════════════ # HR lifecycle aggregate (employee self view) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_role_change_events", description=( "List recent employee role-change events with person metadata and " "aggregate cloud-grant review counts. Admin-read only (global_admin, " "auditor, or management). Uses a signed keyset cursor: pass the " "returned next_cursor unchanged for the next page; changing filters " "starts a fresh traversal. Mirrors GET /api/v1/role-changes." ), ) async def list_role_change_events( days: int = 30, page_size: int = 20, only_pending: bool = False, q: str | None = None, cursor: str | None = None, ) -> dict: args = { "days": days, "page_size": page_size, "only_pending": only_pending, "q": q, "cursor": cursor, } async with tool_runtime("list_role_change_events", args) as rt: _require_admin_read(rt.user.role_names) from app.hr_lifecycle.role_change_service import build_role_change_list return await build_role_change_list( rt.db, days=days, page_size=page_size, only_pending=only_pending, q=q, cursor=cursor, cursor_subject=str(rt.user.id), ) @mcp_server.tool( name="list_my_role_change_reviews", description=( "List the mover review packets assigned to ME as reviewer — the " "colleagues who just moved into my team and whose entitlements and " "ownerships somebody has to judge as still-needed or not. Any " "employee may call this; it returns only packets where the caller " "is the named reviewer. Mirrors GET /api/v1/role-changes/reviews " "with mine=true." ), ) async def list_my_role_change_reviews( status: str | None = "open", limit: int = 20, ) -> dict: args = {"status": status, "limit": limit} async with tool_runtime("list_my_role_change_reviews", args) as rt: from sqlalchemy import select as _select from app.domains.hr.models import HrRoleChangeReview from app.identity.person_models import IdentityPerson as _Person stmt = ( _select(HrRoleChangeReview, _Person) .join(_Person, _Person.id == HrRoleChangeReview.person_id) .where(HrRoleChangeReview.reviewer_person_id == rt.user.id) ) if status: stmt = stmt.where(HrRoleChangeReview.status == status) rows = ( await rt.db.execute( stmt.order_by(HrRoleChangeReview.created_at.desc()) .limit(max(1, min(int(limit), 100))) ) ).all() return { "reviews": [ { "id": str(r.id), "person": p.display_name, "effective_date": r.effective_date.isoformat(), "move_kind": r.move_kind, "status": r.status, "from": r.before_dept_path or r.before_dept_code, "to": r.after_dept_path or r.after_dept_code, "reviewable_items": r.reviewable_items, "decided_items": r.decided_items, "ai_summary": r.ai_summary_en or r.ai_summary_zh, } for r, p in rows ], } @mcp_server.tool( name="list_my_cloud_scope_grant_reviews", description=( "List the cloud access grants awaiting MY decision as the OWNER of " "the cloud scope they sit on — grants a colleague's role change " "flagged for review. Any employee may call this; it returns only " "grants on scopes where the caller is the recorded owner. Mirrors " "GET /api/v1/role-changes/my-scope-grants, which backs the " "\u300c\u8f6c\u5c97\u540e\u7684\u4e91\u6388\u6743\u300d tab in the personal inbox. Read-only: " "deciding keep or revoke is a human action, and revoke tears down " "a live cloud IAM binding." ), ) async def list_my_cloud_scope_grant_reviews(limit: int = 50) -> dict: args = {"limit": limit} async with tool_runtime("list_my_cloud_scope_grant_reviews", args) as rt: import uuid as _uuid # noqa: PLC0415 from sqlalchemy import select as _select # noqa: PLC0415 from app.core.coordinators.role_change_coordinator import ( # noqa: PLC0415 REVIEW_STATUS_PENDING, ) from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCloudAccessGrant as _Grant, ) from app.domains.cmdb.models import ( # noqa: PLC0415 CmdbCloudScope as _Scope, ) from app.identity.person_models import ( # noqa: PLC0415 IdentityPerson as _Person, ) owned = ( await rt.db.execute( _select(_Scope.id, _Scope.display_name, _Scope.external_id) .where( _Scope.owner_person_id == str(rt.user.id), _Scope.deleted_at.is_(None), ) ) ).all() if not owned: return {"grants": []} meta = {str(sid): (name, ext) for sid, name, ext in owned} rows = ( await rt.db.execute( _select(_Grant) .where( # CHAR(36) string column with no GUID adapter — bind # strings or the IN silently matches nothing. _Grant.scope_id.in_(list(meta.keys())), _Grant.review_status == REVIEW_STATUS_PENDING, _Grant.status.in_(("active", "pending")), ) .order_by(_Grant.review_status_changed_at.asc()) .limit(max(1, min(int(limit), 200))) ) ).scalars().all() holder_ids = [] for g in rows: try: holder_ids.append(_uuid.UUID(str(g.person_id))) except (TypeError, ValueError): continue holder_names: dict[str, str] = {} if holder_ids: holder_names = { str(pid): (nm or "") for pid, nm in ( await rt.db.execute( _select(_Person.id, _Person.display_name) .where(_Person.id.in_(holder_ids)) ) ).all() } return { "grants": [ { "id": str(g.id), "scope": meta.get(str(g.scope_id), (None, None))[0] or meta.get(str(g.scope_id), (None, None))[1], "role": g.role_display_name or g.role_key, "holder": holder_names.get(str(g.person_id)), "awaiting_since": ( g.review_status_changed_at.isoformat() if g.review_status_changed_at else None ), } for g in rows ], } @mcp_server.tool( name="get_role_change_review", description=( "One mover review packet: the move, the per-category tallies, and " "which categories still need a decision. Readable by the named " "reviewer, or by global_admin / auditor / management. Returns " "categories rather than every item because a real packet runs to " "hundreds of items across ~10 categories — drill into one with " "resource_type. Mirrors GET /api/v1/role-changes/reviews/{id}." ), ) async def get_role_change_review( review_id: str, resource_type: str | None = None, limit: int = 50, ) -> dict: args = { "review_id": review_id, "resource_type": resource_type, "limit": limit, } async with tool_runtime("get_role_change_review", args) as rt: from sqlalchemy import select as _select from app.core.exceptions import NotFoundError # noqa: PLC0415 from app.core.rbac import ( # noqa: PLC0415 AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role, ) from app.domains.hr.models import ( HrRoleChangeReview, HrRoleChangeReviewItem, ) from app.hr_lifecycle.mover_review_ai import ( build_facts, deterministic_priority, ) review = await rt.db.get(HrRoleChangeReview, _u(review_id)) if review is None: raise NotFoundError("HrRoleChangeReview", review_id) privileged = has_role( rt.user.role_names or [], GLOBAL_ADMIN_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ) if not privileged and review.reviewer_person_id != rt.user.id: # 404 not 403: that a named colleague moved is itself a # disclosure, and a status code should not make it. raise NotFoundError("HrRoleChangeReview", review_id) facts = await build_facts(rt.db, review) by_type = {c.resource_type: c for c in facts.categories} categories = [ { "resource_type": rt_key, "total": by_type[rt_key].total, "undecided": by_type[rt_key].undecided, "examples": by_type[rt_key].examples, } for rt_key in deterministic_priority(facts) if rt_key in by_type ] items: list[dict] = [] if resource_type: rows = ( await rt.db.execute( _select(HrRoleChangeReviewItem) .where( HrRoleChangeReviewItem.review_id == review.id, HrRoleChangeReviewItem.resource_type == resource_type, ) .order_by(HrRoleChangeReviewItem.label.asc()) .limit(max(1, min(int(limit), 200))) ) ).scalars().all() items = [ { "id": str(i.id), "label": i.label, "disposition": i.disposition, "decision": i.decision, "ai_recommendation": i.ai_recommendation, "ai_reason": i.ai_reason, } for i in rows ] return { "review": { "id": str(review.id), "effective_date": review.effective_date.isoformat(), "move_kind": review.move_kind, "status": review.status, "from": review.before_dept_path or review.before_dept_code, "to": review.after_dept_path or review.after_dept_code, "reviewable_items": review.reviewable_items, "decided_items": review.decided_items, "failed_scan_types": review.failed_scan_types or [], "ai_summary": review.ai_summary_en or review.ai_summary_zh, "ai_failure_code": review.ai_failure_code, }, "categories": categories, "items": items, } @mcp_server.tool( name="decide_role_change_review_category", description=( "Record ONE decision (keep / revoke / transfer) for every " "still-undecided holding in one category of a mover review packet " "— e.g. 'keep all 111 DNS records'. Only the named reviewer or a " "global_admin may call it, and it NEVER touches an item somebody " "already answered. Records the decision only: Helix performs no " "revoke or transfer from here (issue #623 — a human decides, and " "IT acts on the record from the resource's own admin surface). " "Mirrors PUT /api/v1/role-changes/reviews/{id}/categories." ), ) async def decide_role_change_review_category( review_id: str, resource_type: str, decision: str, note: str | None = None, ) -> dict: args = { "review_id": review_id, "resource_type": resource_type, "decision": decision, "note": note, } async with tool_runtime( "decide_role_change_review_category", args, mutates=True, ) as rt: from sqlalchemy import select as _select from app.core.exceptions import ( # noqa: PLC0415 AppException, NotFoundError, ) from app.core.rbac import ( # noqa: PLC0415 GLOBAL_ADMIN_ROLE, has_role, ) from app.domains.hr.models import ( ITEM_DECISIONS, HrRoleChangeReview, HrRoleChangeReviewItem, ) from app.hr_lifecycle.mover_holdings import REVIEW as _REVIEW from app.hr_lifecycle.mover_review_service import ( record_decision, refresh_counts, ) if decision not in ITEM_DECISIONS: raise AppException( code="INVALID_DECISION", message=f"decision must be one of {list(ITEM_DECISIONS)}", status_code=400, ) review = await rt.db.get(HrRoleChangeReview, _u(review_id)) if review is None: raise NotFoundError("HrRoleChangeReview", review_id) if ( not has_role(rt.user.role_names or [], GLOBAL_ADMIN_ROLE) and review.reviewer_person_id != rt.user.id ): raise NotFoundError("HrRoleChangeReview", review_id) rows = ( await rt.db.execute( _select(HrRoleChangeReviewItem).where( HrRoleChangeReviewItem.review_id == review.id, HrRoleChangeReviewItem.resource_type == resource_type, HrRoleChangeReviewItem.disposition == _REVIEW, HrRoleChangeReviewItem.decision.is_(None), ) ) ).scalars().all() for item in rows: await record_decision( rt.db, item, decision=decision, decided_by=rt.user.id, note=note, ) await refresh_counts(rt.db, review) return { "review_id": str(review.id), "resource_type": resource_type, "decision": decision, "affected": len(rows), "status": review.status, "decided_items": review.decided_items, "reviewable_items": review.reviewable_items, } @mcp_server.tool( name="get_my_lifecycle", description=( "Return the calling user's full lifecycle flow — onboarding or " "offboarding track depending on employment status. Includes " "HRIS events, provisioning tasks, offboarding process + " "blockers + reminders, recent notifications (last 20), and a " "computed flow timeline (nodes with status: done/active/" "blocked/pending). Mirrors GET /api/v1/lifecycle/me — useful " "for AI assistants helping new hires or leavers track progress." ), ) async def get_my_lifecycle() -> dict: async with tool_runtime("get_my_lifecycle", {}) as rt: # Reuse the router's pure payload builder so the shape stays # 1:1 with the web UI. ``_person_flow_payload`` wraps its # result in the standard ``success_response`` envelope (a # plain dict — NOT a JSONResponse); we unwrap the ``data`` # field so MCP callers get the payload directly without the # extra ``meta``/``errors`` shell that's only meaningful to # HTTP clients. from starlette.requests import Request as _StReq from app.hr_lifecycle.router import _person_flow_payload # Build a minimal Starlette Request. ``success_response`` # reads ``request.state.trace_id`` via getattr-with-default, # so an empty state is fine. Bare ``object()`` would # AttributeError on attribute access; a real Request with an # empty scope is the smallest object that satisfies the # contract. Same pattern as ``get_dashboard_summary`` above. scope = { "type": "http", "headers": [], "method": "GET", "path": "/api/v1/lifecycle/me", "query_string": b"", } req = _StReq(scope) # `_person_flow_payload(is_admin=True)` is the default and # surfaces admin-only sections (compliance incidents, raw # notification bodies) the HTTP `/api/v1/lifecycle/me` route # explicitly hides via `is_admin=False`. Mirror the /me route's # gate so the MCP tool returns the same payload an employee # sees in the UI — not the admin view. envelope = await _person_flow_payload( person_id=rt.user.id, request=req, db=rt.db, is_admin=False, ) # ``envelope`` is ``{"success": True, "data": , ...}``. # MCP clients only want the payload; raise loudly if the shape # ever drifts so a regression in ``response.py`` surfaces # here as a real error rather than a silent ``None`` return. payload = envelope.get("data") if isinstance(envelope, dict) else None if payload is None: from app.core.exceptions import AppException raise AppException( code="INTERNAL_ERROR", message="Lifecycle helper returned an unexpected shape.", status_code=500, ) return payload # ════════════════════════════════════════════════════════════════════ # Audit log access (governance / auditor / global admin only) # ════════════════════════════════════════════════════════════════════ # # All four tools require :func:`can_access_audit` against the caller's # roles. Without it the tool 403s — same gate as ``/api/v1/audit/*``. # Auditing the audit page is itself recorded (every MCP call writes # its own audit row, so a security_auditor running queries via Claude # leaves a trail just like clicking through the web UI would). def _require_audit_access(role_names) -> None: """Audit log gate — INTENTIONALLY NARROWER than HTTP. HTTP ``require_audit_access()`` (see ``dependencies.py``) admits ``GLOBAL_ADMIN_ROLE + AUDITOR_ROLE + MANAGEMENT_ROLE`` — PR #428 widened it 2026-05-20 to give management a read-only governance surface in the web UI. MCP keeps the original admin-tier-only pair (global_admin + auditor): programmatic / AI-driven access to raw audit content is a different threat model from a person scrolling the web page, and management's leadership read-only brief does not need a scriptable audit-log API. If a management user genuinely needs MCP audit access, widen this explicitly — don't drift into it by accident via HTTP parity. """ from app.core.exceptions import ForbiddenError from app.core.rbac import ( AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role, ) if not has_role(role_names or [], GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError( "Audit log access requires global_admin or auditor role.", ) def _can_view_invoice_for_audit(rt) -> bool: """Whether this MCP caller may see invoice-basis amounts in audit detail. `_enrich_audit_logs` redacts absolute cloud-cost amounts out of `audit_logs.detail` unless the reader is in the invoice circle (`can_view_invoice_measure` = finance / cloud_admin / global_admin), and its `can_view_invoice` parameter defaults to **False** so a caller that forgets fails closed. That default is why this helper exists: the audit gate above admits `auditor`, who is NOT in the circle, so the two predicates genuinely differ and the narrower one has to be evaluated separately rather than inferred from having passed the gate. """ from app.domains.cmdb.cost_view import can_view_invoice_measure return can_view_invoice_measure(rt.user.role_names or []) @mcp_server.tool( name="query_audit_logs", description=( "Search the append-only audit log. Requires global_admin or " "auditor role. Filters: action (e.g. 'mcp.claim_asset'), " "actor_user_id (UUID), resource_type (e.g. 'asset'), search " "(matches trace_id / resource_id / action substring), " "actor_type (user/system), status (success/failure), date_from " "/ date_to (ISO-8601). Bounded pagination — max 200 rows. " "When no date range is given, only the last 30 days are " "searched — pass date_from/date_to explicitly for older " "history. The response reports the effective window actually " "searched (`effective_date_from` / `effective_date_to`)." ), ) async def query_audit_logs( action: str | None = None, actor_user_id: str | None = None, resource_type: str | None = None, search: str | None = None, actor_type: str | None = None, status: str | None = None, date_from: str | None = None, date_to: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "query_audit_logs", { "action": action, "actor_user_id": actor_user_id, "resource_type": resource_type, "search": search, "actor_type": actor_type, "status": status, "date_from": date_from, "date_to": date_to, "page": page, "page_size": page_size, }, ) as rt: _require_audit_access(rt.user.role_names) from datetime import datetime as _dt from app.audit.service import AuditService from app.core.exceptions import AppException def _parse_iso(label: str, raw: str | None) -> _dt | None: """ISO-8601 parser that raises a clear 422 instead of letting the raw ValueError fall through to the catch-all INTERNAL_ERROR. Naive datetimes are normalised to UTC so downstream arithmetic against ``datetime.now(UTC)`` doesn't raise the "can't compare offset-naive and offset-aware" TypeError on inputs without a Z suffix.""" if raw is None: return None try: parsed = _dt.fromisoformat(raw) except ValueError as exc: raise AppException( code="VALIDATION_ERROR", message=( f"`{label}` must be ISO-8601 (e.g. " f"'2026-04-29T00:00:00Z'), got " f"{_short_repr(raw)}" ), status_code=422, ) from exc if parsed.tzinfo is None: parsed = parsed.replace(tzinfo=UTC) return parsed svc = AuditService(rt.db) actor_id = _u(actor_user_id) if actor_user_id else None df = _parse_iso("date_from", date_from) dt_to = _parse_iso("date_to", date_to) # Cap the date window. An LLM walking pagination over a wide # window would burn DB time on COUNT(*) over the full filtered # set every page. 90 days covers the realistic forensics window # and bounds worst-case query cost. If neither bound is set, # default to the last 30 days. from datetime import timedelta as _td _now = _dt.now(UTC) if df is None and dt_to is None: df = _now - _td(days=30) if df is None and dt_to is not None: df = dt_to - _td(days=90) if dt_to is None and df is not None: dt_to = df + _td(days=90) if dt_to > _now: dt_to = _now # At this point both df and dt_to are non-None. Enforce the cap. if df is not None and dt_to is not None: if df > dt_to: raise AppException( code="VALIDATION_ERROR", message="date_from must be <= date_to.", status_code=422, ) if (dt_to - df) > _td(days=90): raise AppException( code="VALIDATION_ERROR", message=( "date_from / date_to span must not exceed 90 days. " "Page through narrower windows for older history." ), status_code=422, ) logs, total = await svc.query_logs( page=page, page_size=page_size, action=action, actor_user_id=actor_id, resource_type=resource_type, search=search, actor_type=actor_type, status=status, date_from=df, date_to=dt_to, ) # Match the HTTP route: enrich rows with actor_display_name + # actor_email so the LLM doesn't have to chase UUIDs through # whoami / get_my_profile to render "who did this". from app.audit.router import _enrich_audit_logs return { "items": serialize(await _enrich_audit_logs( rt.db, list(logs), can_view_invoice=_can_view_invoice_for_audit(rt), )), "total": total, "page": page, "page_size": page_size, # Surface the window actually searched — the implicit # 30-day default would otherwise look like "no such event # ever happened" to an LLM asking about older history. "effective_date_from": df.isoformat() if df else None, "effective_date_to": dt_to.isoformat() if dt_to else None, } @mcp_server.tool( name="get_audit_log", description=( "Return one audit log row by id. Requires global_admin or " "auditor role." ), ) async def get_audit_log(log_id: str) -> dict: async with tool_runtime("get_audit_log", {"log_id": log_id}) as rt: _require_audit_access(rt.user.role_names) from app.audit.router import _enrich_audit_logs from app.audit.service import AuditService from app.core.exceptions import NotFoundError log = await AuditService(rt.db).get_by_id(_u(log_id)) if log is None: raise NotFoundError("Audit log", log_id) [enriched] = await _enrich_audit_logs( rt.db, [log], can_view_invoice=_can_view_invoice_for_audit(rt), ) return serialize(enriched) @mcp_server.tool( name="get_audit_trace", description=( "Return every audit row sharing a trace_id, ordered by " "created_at ascending. Useful for reconstructing a full request " "chain (e.g. 'why did this provisioning task fail?'). Requires " "global_admin or auditor role." ), ) async def get_audit_trace(trace_id: str) -> dict: # trace_id is an OPAQUE string, not necessarily a UUID: large # parts of the platform write tag-shaped traces # (``provisioning_task:{id}``, ``feishu_callback:{...}``, # ``system:cron:...``) which ``AuditService._coerce_trace_id`` # hashes into the UUID column via uuid5 — and ``get_by_trace_id`` # applies the same coercion on lookup. A previous revision # validated with ``_u()`` here, which 422-rejected every # tag-shaped trace and broke the tool's advertised primary use # case ("why did this provisioning task fail?"). Validate only # non-empty + bounded. (Launch audit 2026-06-10.) from app.core.exceptions import ValidationError as _VErr parsed_trace = (trace_id or "").strip() if not parsed_trace or len(parsed_trace) > 256: raise _VErr( "trace_id must be a non-empty string of at most 256 chars " "(UUID or tag-shaped, e.g. 'provisioning_task:').", ) async with tool_runtime( "get_audit_trace", {"trace_id": parsed_trace}, ) as rt: _require_audit_access(rt.user.role_names) from app.audit.router import _enrich_audit_logs from app.audit.service import AuditService # Cap at 1000 rows. A trace_id can in theory have arbitrarily # many audit rows (e.g. a long-running offboarding) — without a # cap a pathological trace ID could OOM the response. 1000 is # well above realistic depth (~10-50 rows for normal traces). logs = await AuditService(rt.db).get_by_trace_id(parsed_trace) truncated = False if len(logs) > 1000: logs = logs[:1000] truncated = True return { "items": serialize(await _enrich_audit_logs( rt.db, list(logs), can_view_invoice=_can_view_invoice_for_audit(rt), )), "truncated": truncated, } @mcp_server.tool( name="get_audit_stats", description=( "Return audit log statistics: total_logs, logs_today, " "unique_actors, top actions / resource types / statuses, and " "the 10 most recent rows. Requires global_admin or auditor role." ), ) async def get_audit_stats() -> dict: async with tool_runtime("get_audit_stats", {}) as rt: _require_audit_access(rt.user.role_names) from app.audit.service import AuditService return await AuditService(rt.db).get_stats() # ════════════════════════════════════════════════════════════════════ # Email group management (delete + transfer-owner — beyond create) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="delete_email_group", description=( "Delete a mail distribution group. Authorization: must be one " "of the group's owners, or HR / global_admin. Real Google calls " "happen — local soft-delete + Google hard-delete; if Google " "fails the local change rolls back so the user can retry. " "``reason`` is required (≥4 chars) and surfaces in audit + the " "remaining-owner notification." ), ) async def delete_email_group(group_id: str, reason: str) -> dict: async with tool_runtime( "delete_email_group", {"group_id": group_id, "reason": reason}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.email_group.service import EmailGroupService if not reason or len(reason.strip()) < 4: raise AppException( code="VALIDATION_ERROR", message="reason must be at least 4 characters.", status_code=422, ) # Upper-bound cap at the MCP boundary — pair with the 4-char # minimum above. Same as transfer_email_group_owner (line 5026). # (R3 logic sweep.) reason = _bound_free_text(reason, field="reason", max_len=2000) or reason # ``acknowledge_blocking`` is deliberately NOT exposed here. # A BLOCKING signal means something else depends on this # address — a live BigQuery dataset grant, another group's # delivery hop — and overriding that on an irreversible delete # is a judgement call that needs the evidence in front of a # human. An LLM client gets the 409 and the signal names, and # the operator makes the call on the workbench. group = await EmailGroupService(rt.db).delete_mail_group( group_id=_u(group_id), actor=rt.user, actor_roles=list(rt.user.role_names or []), trace_id=_trace_str(), reason=reason, ) return { "id": str(group.id), "email": group.email, "status": group.status, } @mcp_server.tool( name="transfer_email_group_owner", description=( "Transfer one owner seat on a mail group from from_person_id " "to to_person_id. Authorization: must be a current owner or " "HR / global_admin. Reason is required (≥4 chars). Real " "Google calls happen — new owner added FIRST, then old " "removed, so the 'at least one owner' invariant never trips." ), ) async def transfer_email_group_owner( group_id: str, from_person_id: str, to_person_id: str, reason: str, ) -> dict: async with tool_runtime( "transfer_email_group_owner", { "group_id": group_id, "from_person_id": from_person_id, "to_person_id": to_person_id, "reason": reason, }, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.email_group.service import EmailGroupService if not reason or len(reason.strip()) < 4: raise AppException( code="VALIDATION_ERROR", message="reason must be at least 4 characters.", status_code=422, ) # Upper-bound cap at the MCP boundary — service writes the # raw text to a TEXT column with no max-length validator. # The 4-char minimum above is the only existing guard, leaving # the upper bound open. (R1 sweep finding: Class C cap.) reason = _bound_free_text(reason, field="reason", max_len=2000) or reason group = await EmailGroupService(rt.db).transfer_owner_on_demand( group_id=_u(group_id), from_person_id=_u(from_person_id), to_person_id=_u(to_person_id), actor=rt.user, actor_roles=list(rt.user.role_names or []), reason=reason, trace_id=_trace_str(), ) return { "id": str(group.id), "email": group.email, "status": group.status, } # ════════════════════════════════════════════════════════════════════ # Dynamic email groups (HR / global_admin — Cloud Identity rule-computed # mail groups). All gated by can_manage_dynamic_email_groups inside the # service; a non-HR PAT owner gets a 403 AppException surfaced cleanly. # 1:1 with the internal /api/v1/email-groups/dynamic/* admin API. # ════════════════════════════════════════════════════════════════════ # ════════════════════════════════════════════════════════════════════ # 邮件组管理 estate surface (HR / global_admin). Read-only twins of the # /api/v1/email-groups/manage/* admin API — the estate list, the # operation log, and the log's action catalog. Gated by # can_manage_email_groups inside the service. # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_email_groups_estate", description=( "List EVERY Google mail group Helix mirrors — both the " "hand-maintained (static) ones and the rule-driven (dynamic) " "ones — with governance signals per row: owner list, Google's " "direct member count, real aliases, and the group's kind.\n" "Two facts that change how you must read the result:\n" "• Google counts OWNERS as direct members, so " "``direct_members_count == 0`` already implies the group has no " "owner. Never report those as two independent findings.\n" "• ``direct_members_count == null`` means NEVER SYNCED, not " "zero. Do not treat a null as an empty group.\n" "``kind='unknown'`` means Cloud Identity's dynamic-group " "detection never ran determinately for that row (usually a " "missing service-account scope) — such a group must NOT be " "described as static.\n" "Filters: kind (all|static|dynamic|unknown), members " "(all|zero|nonzero|unsynced), owners (all|none|some), search " "(address / name / alias), suffix (exact domain), " "include_deleted. Restricted to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/manage/groups." ), ) async def list_email_groups_estate( search: str | None = None, suffix: str | None = None, kind: str = "all", members: str = "all", owners: str = "all", include_deleted: bool = False, sort: str = "email", order: str = "asc", page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_email_groups_estate", { "search": search, "suffix": suffix, "kind": kind, "members": members, "owners": owners, "include_deleted": include_deleted, "sort": sort, "order": order, "page": page, "page_size": page_size, }, ) as rt: from app.domains.email_group.manage_service import ( # noqa: PLC0415 EmailGroupManageService, ) # Validate the enums at the MCP boundary. The service falls back # to a safe default for an unknown value, which would silently # answer a DIFFERENT question than the model asked ("static # only" quietly becoming "everything") — worse than an error. _enum_guard("kind", kind, ("all", "static", "dynamic", "unknown")) _enum_guard("members", members, ("all", "zero", "nonzero", "unsynced")) _enum_guard("owners", owners, ("all", "none", "some")) _enum_guard( "sort", sort, ("email", "name", "members", "created_at", "last_synced_at"), ) _enum_guard("order", order, ("asc", "desc")) page, page_size = _clamp_pagination(page, page_size, max_size=200) # The HTTP twin caps these with ``Query(max_length=255)``; MCP # calls the service directly, so the cap is replicated here or # an LLM client can drive an unbounded LIKE pattern. search = _bound_free_text(search, field="search", max_len=255) suffix = _bound_free_text(suffix, field="suffix", max_len=255) rows, total = await EmailGroupManageService(rt.db).list_groups( actor_roles=list(rt.user.role_names or []), search=search, suffix=suffix, kind=kind, members=members, owners=owners, include_deleted=include_deleted, sort=sort, order=order, page=page, page_size=page_size, ) result: dict = serialize({ "groups": rows, "total": total, "page": page, "page_size": page_size, }) return result @mcp_server.tool( name="get_email_group_estate_overview", description=( "Estate-level counters for the company's mail groups: active " "total, static vs dynamic split, groups whose kind could not be " "determined, groups that have members but NO owner (a " "stewardship gap — the fix is assigning an owner, never " "deletion), static groups with zero members (the cleanup pool), " "soft-deleted tombstones, and the last successful sync time. " "Restricted to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/manage/overview." ), ) async def get_email_group_estate_overview() -> dict: async with tool_runtime("get_email_group_estate_overview", {}) as rt: from app.domains.email_group.manage_service import ( # noqa: PLC0415 EmailGroupManageService, ) data = await EmailGroupManageService(rt.db).overview( actor_roles=list(rt.user.role_names or []), ) result: dict = serialize(data) return result @mcp_server.tool( name="list_email_group_operation_log", description=( "The mail-group operation log: every audited event on any mail " "group, newest first. Covers BOTH human actions (create / " "delete / member removal / ownership transfer / dynamic rule " "edits / bulk cleanup) AND Google-side changes discovered by " "the hourly sync job (a group deleted in the Google console, an " "owner swapped there, a rename). Rows written by the sync job " "carry ``actor_type='system'`` and no person — do not attribute " "them to anyone.\n" "``search`` matches the GROUP by address / name / alias and " "INCLUDES soft-deleted groups, which is how you investigate a " "group that no longer exists; a term matching more than 200 " "groups is rejected rather than silently truncated, so narrow " "it instead of trusting a partial answer. Filter by " "``group_id`` for one group's full timeline, ``actions`` (list, " "OR'd — see list_email_group_operation_log_actions for valid " "values), ``category``, ``actor_person_id``, ``actor_type``, or " "a ``date_from``/``date_to`` (YYYY-MM-DD, inclusive) window. " "Restricted to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/manage/audit-log." ), ) async def list_email_group_operation_log( group_id: str | None = None, search: str | None = None, actions: list[str] | None = None, category: str | None = None, actor_person_id: str | None = None, actor_type: str | None = None, date_from: str | None = None, date_to: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_email_group_operation_log", { "group_id": group_id, "search": search, "actions": actions, "category": category, "actor_person_id": actor_person_id, "actor_type": actor_type, "date_from": date_from, "date_to": date_to, "page": page, "page_size": page_size, }, ) as rt: from app.domains.email_group.manage_service import ( # noqa: PLC0415 EmailGroupManageService, ) if actor_type is not None: _enum_guard("actor_type", actor_type, ("user", "system")) if category is not None: # ``other`` included: the HTTP twin's pattern accepts it (it # selects rows whose action is not in the registry), and a # tool that claims to be 1:1 with that endpoint must not # reject a value the endpoint takes. _enum_guard( "category", category, ("group", "member", "request", "dynamic", "sync", "cleanup", "export", "other"), ) page, page_size = _clamp_pagination(page, page_size, max_size=200) # Bounded like the HTTP twin's ``Query(max_length=...)``: MCP # bypasses Pydantic, and ``search`` becomes a LIKE while # ``actions`` becomes an IN-list. search = _bound_free_text(search, field="search", max_len=255) if actions: if len(actions) > 100: from app.core.exceptions import ValidationError # noqa: PLC0415 raise ValidationError( f"actions accepts at most 100 entries (got {len(actions)}).", ) actions = [ _bound_free_text(a, field="actions[]", max_len=128) or "" for a in actions ] rows, total = await EmailGroupManageService(rt.db).audit_log( actor_roles=list(rt.user.role_names or []), group_id=_u(group_id) if group_id else None, search=search, actions=list(actions) if actions else None, category=category, actor_person_id=_u(actor_person_id) if actor_person_id else None, actor_type=actor_type, date_from=_day_start(date_from), date_to=_day_end(date_to), page=page, page_size=page_size, ) result: dict = serialize({ "entries": rows, "total": total, "page": page, "page_size": page_size, }) return result @mcp_server.tool( name="list_email_group_cleanup_candidates", description=( "Empty STATIC mail groups — the cleanup pool — each with " "triage signals. Pool predicate: active, not rule-driven, and " "Google reports ZERO direct members.\n" "**'Zero members' does NOT mean abandoned, and you must not " "report it that way.** A memberless group can still be a live " "inbound address: an externally published contact (careers@, " "jobs@), a robot / alert destination, a nested delivery hop, or " "a cloud IAM principal. Deletion is a Google HARD delete — the " "archive goes with it, later mail bounces, and there is no " "undelete.\n" "Each row carries ``signals`` with a ``level``:\n" "• ``blocking`` — a concrete reason not to delete (nested " "inside another group, carries a real second address, or Helix's " "own config points at it).\n" "• ``indeterminate`` — a probe that could NOT be run. Treat " "this as UNKNOWN, never as clean; the row's ``risk`` stays " "``review`` for exactly this reason.\n" "• ``warning`` — heuristic only, e.g. an infrastructure-shaped " "name.\n" "• ``info`` — someone already marked it deprecated / it looks " "like a test group.\n" "``known_blind_spots`` in the response lists what this surface " "CANNOT see (cloud IAM group bindings, mail traffic). Repeat " "those limits when you summarise; do not present the list as a " "safety clearance. Restricted to HR / global_admin. 1:1 with " "GET /api/v1/email-groups/manage/cleanup-candidates." ), ) async def list_email_group_cleanup_candidates( probe_nesting: bool = True, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_email_group_cleanup_candidates", { "probe_nesting": probe_nesting, "page": page, "page_size": page_size, }, ) as rt: from app.domains.email_group.cleanup_service import ( # noqa: PLC0415 MAX_CANDIDATE_PAGE, EmailGroupCleanupService, ) page, page_size = _clamp_pagination( page, page_size, max_size=MAX_CANDIDATE_PAGE, ) data = await EmailGroupCleanupService(rt.db).list_candidates( actor_roles=list(rt.user.role_names or []), probe_nesting=bool(probe_nesting), page=page, page_size=page_size, ) result: dict = serialize(data) return result @mcp_server.tool( name="cleanup_email_groups", description=( "Delete empty static mail groups in batch. **IRREVERSIBLE**: " "Google hard-deletes each group, its message archive is lost, " "and mail to the address bounces afterwards. There is no " "undelete.\n" "Never call this on a list you assembled yourself. Read " "list_email_group_cleanup_candidates first, present the rows " "WITH their signals and the response's ``known_blind_spots``, " "and let a human choose which ids to pass. A row whose signals " "include a ``blocking`` or ``indeterminate`` level must be " "called out explicitly before it is included.\n" "Safety behaviour you can rely on: at most 20 ids per call; a " "reason of at least 10 characters is required and is recorded; " "each group's LIVE Google membership is re-checked immediately " "before deleting, so one that gained a member since the list " "was drawn is REFUSED rather than swept; and one refusal or " "failure never aborts the rest. The result carries a per-group " "``outcome`` (deleted / already_absent / refused / failed / " "not_found) — report refusals and failures individually, never " "as an overall success. Every deletion writes its own audit row " "under a shared ``batch_id``. Restricted to HR / global_admin. " "1:1 with POST /api/v1/email-groups/manage/cleanup." ), ) async def cleanup_email_groups(group_ids: list[str], reason: str) -> dict: async with tool_runtime( "cleanup_email_groups", {"group_ids": group_ids, "reason": reason}, mutates=True, ) as rt: # ``reason`` is copied verbatim into the batch audit row AND # into one per-group row for every address in the batch, so an # unbounded value here is amplified up to 21×. The HTTP twin # caps it at 500 (``EmailGroupCleanupRequest``). reason = _bound_free_text(reason, field="reason", max_len=500) or "" from app.core.exceptions import ValidationError # noqa: PLC0415 from app.domains.email_group.cleanup_service import ( # noqa: PLC0415 MAX_CLEANUP_BATCH, EmailGroupCleanupService, ) if not group_ids: raise ValidationError("group_ids must contain at least one id.") if len(group_ids) > MAX_CLEANUP_BATCH: raise ValidationError( f"group_ids may contain at most {MAX_CLEANUP_BATCH} entries " f"(got {len(group_ids)}). Split the sweep into batches.", ) data = await EmailGroupCleanupService(rt.db).cleanup( group_ids=[_u(g) for g in group_ids], reason=reason, actor=rt.user, actor_roles=list(rt.user.role_names or []), trace_id=_trace_str(), ) result: dict = serialize(data) return result @mcp_server.tool( name="add_email_group_members", description=( "Add one or more addresses to a STATIC (hand-maintained) mail " "group, as MEMBER (default), MANAGER or OWNER.\n" "Per-address isolation: one rejected address never discards the " "ones that worked — the result carries a row per address with " "``outcome`` in added / already_member / role_changed / failed. " "Report those individually; do not summarise a partial success " "as a success.\n" "External (non-employee) addresses ARE allowed — company groups " "routinely carry vendor and contractor addresses. Only " "addresses that resolve to a Helix person get a notification; " "the rest are added silently, which is correct, not a failure.\n" "Refused for a DYNAMIC group (membership is rule-computed and " "Google reverts manual edits — change the rule instead) and for " "a group whose kind is still undetermined. Idempotent: adding " "an existing member reports ``already_member`` and re-roles " "rather than duplicating or silently demoting an owner. " "Restricted to HR / global_admin. 1:1 with POST " "/api/v1/email-groups/manage/{id}/members." ), ) async def add_email_group_members( group_id: str, emails: list[str], role: str = "MEMBER", reason: str | None = None, ) -> dict: async with tool_runtime( "add_email_group_members", { "group_id": group_id, "emails": emails, "role": role, "reason": reason, }, mutates=True, ) as rt: # ``reason`` lands verbatim in the audit row and the # notification body; the HTTP twin caps it at 500. reason = _bound_free_text(reason, field="reason", max_len=500) from app.core.exceptions import ValidationError # noqa: PLC0415 from app.domains.email_group.admin_service import ( # noqa: PLC0415 MAX_BATCH_MEMBERS, EmailGroupAdminService, ) # Normalise the same way the service does, then reject an # out-of-vocabulary value at the boundary so the model gets a # named error instead of a permissive fallback. role = (role or "").strip().upper() _enum_guard("role", role, ("MEMBER", "MANAGER", "OWNER")) if not emails: raise ValidationError("emails must contain at least one address.") if len(emails) > MAX_BATCH_MEMBERS: raise ValidationError( f"emails may contain at most {MAX_BATCH_MEMBERS} entries " f"(got {len(emails)}).", ) data = await EmailGroupAdminService(rt.db).add_members( group_id=_u(group_id), emails=list(emails), role=role, actor=rt.user, actor_roles=list(rt.user.role_names or []), reason=reason, trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="remove_email_group_members", description=( "Remove one or more addresses from a STATIC mail group.\n" "OWNERS are deliberately REFUSED here (the row comes back " "``failed`` naming the reason): dropping an owner is a different " "operation with a different guard — use " "remove_email_group_owner, which refuses to leave the group " "with no owner at all. Google requires ≥1 owner per group and " "Helix treats ownership as an offboarding blocker, so an " "ownerless group is a governance hole rather than a neutral " "state.\n" "Per-address isolation and idempotency as in " "add_email_group_members: an address that is not in the group " "reports ``already_absent`` rather than failing. Restricted to " "HR / global_admin. 1:1 with POST " "/api/v1/email-groups/manage/{id}/members/remove." ), ) async def remove_email_group_members( group_id: str, emails: list[str], reason: str | None = None, ) -> dict: async with tool_runtime( "remove_email_group_members", {"group_id": group_id, "emails": emails, "reason": reason}, mutates=True, ) as rt: # ``reason`` lands verbatim in the audit row and the # notification body; the HTTP twin caps it at 500. reason = _bound_free_text(reason, field="reason", max_len=500) from app.core.exceptions import ValidationError # noqa: PLC0415 from app.domains.email_group.admin_service import ( # noqa: PLC0415 MAX_BATCH_MEMBERS, EmailGroupAdminService, ) if not emails: raise ValidationError("emails must contain at least one address.") if len(emails) > MAX_BATCH_MEMBERS: raise ValidationError( f"emails may contain at most {MAX_BATCH_MEMBERS} entries " f"(got {len(emails)}).", ) data = await EmailGroupAdminService(rt.db).remove_members( group_id=_u(group_id), emails=list(emails), actor=rt.user, actor_roles=list(rt.user.role_names or []), reason=reason, trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="grant_email_group_owner", description=( "Make an address an OWNER of a static mail group (insert if not " "a member, promote if already one).\n" "This is the ONLY path that can fix an OWNERLESS group — " "transfer_email_group_owner needs an existing owner to transfer " "FROM, so it cannot rescue a group that has none. Use " "get_email_group_estate_overview / list_email_groups_estate " "with owners='none' and members='nonzero' to find them.\n" "Idempotent: an existing owner reports ``already_owner``. " "Restricted to HR / global_admin. 1:1 with POST " "/api/v1/email-groups/manage/{id}/owners." ), ) async def grant_email_group_owner( group_id: str, email: str, reason: str | None = None, ) -> dict: async with tool_runtime( "grant_email_group_owner", {"group_id": group_id, "email": email, "reason": reason}, mutates=True, ) as rt: # The HTTP twin caps this at 255 (``schemas.py``); MCP calls # the service directly, so the cap is replicated here. # (It reaches ``_person_by_email`` — a BOUND EQUALITY, not a # LIKE — and a Google request body. An earlier version of this # comment claimed a LIKE, which is only true of # ``alias_email``; the bound is right either way, the rationale # was not, and a wrong rationale costs the next reader a # pointless hunt.) email = _bound_free_text( email, field="email", max_len=255, ) or "" # ``reason`` lands verbatim in the audit row and the # notification body; the HTTP twin caps it at 500. reason = _bound_free_text(reason, field="reason", max_len=500) from app.domains.email_group.admin_service import ( # noqa: PLC0415 EmailGroupAdminService, ) data = await EmailGroupAdminService(rt.db).grant_owner( group_id=_u(group_id), email=email, actor=rt.user, actor_roles=list(rt.user.role_names or []), reason=reason, trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="remove_email_group_owner", description=( "Drop one OWNER of a static mail group.\n" "REFUSES (409 LAST_OWNER) when they are the only owner — " "Google requires ≥1 owner per group and an ownerless group is " "an offboarding blocker. Grant the replacement FIRST, then " "revoke. The owner count is read LIVE from Google, so an " "external co-owner (invisible in Helix's owner mirror) " "correctly counts as cover.\n" "``demote_to_member=true`` (default) keeps them in the group as " "a plain member — usually the intent behind 'they should not be " "responsible for this any more'; ``false`` removes them from " "the group entirely. Restricted to HR / global_admin. 1:1 with " "POST /api/v1/email-groups/manage/{id}/owners/remove." ), ) async def remove_email_group_owner( group_id: str, email: str, demote_to_member: bool = True, reason: str | None = None, ) -> dict: async with tool_runtime( "remove_email_group_owner", { "group_id": group_id, "email": email, "demote_to_member": demote_to_member, "reason": reason, }, mutates=True, ) as rt: # The HTTP twin caps this at 255 (``schemas.py``); MCP calls # the service directly, so the cap is replicated here. # (It reaches ``_person_by_email`` — a BOUND EQUALITY, not a # LIKE — and a Google request body. An earlier version of this # comment claimed a LIKE, which is only true of # ``alias_email``; the bound is right either way, the rationale # was not, and a wrong rationale costs the next reader a # pointless hunt.) email = _bound_free_text( email, field="email", max_len=255, ) or "" # ``reason`` lands verbatim in the audit row and the # notification body; the HTTP twin caps it at 500. reason = _bound_free_text(reason, field="reason", max_len=500) from app.domains.email_group.admin_service import ( # noqa: PLC0415 EmailGroupAdminService, ) data = await EmailGroupAdminService(rt.db).revoke_owner( group_id=_u(group_id), email=email, demote_to_member=bool(demote_to_member), actor=rt.user, actor_roles=list(rt.user.role_names or []), reason=reason, trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="add_email_group_alias", description=( "Add an alias address to a STATIC mail group — a second address " "that delivers to the same group.\n" "The suffix must be on the tenant's whitelist. Refused when the " "address is already another group's primary (the error NAMES the " "holder, so report which group it is rather than retrying) and " "when the group is dynamic or of undetermined kind. Idempotent: " "an alias already present reports ``already_present``.\n" "Note Google auto-creates a `@.test-google-a.com` " "mirror for every group; those are filtered out of every alias " "list Helix returns and are not real addresses — never report " "one as an alias somebody added. Restricted to HR / " "global_admin. 1:1 with POST " "/api/v1/email-groups/manage/{id}/aliases." ), ) async def add_email_group_alias(group_id: str, alias_email: str) -> dict: async with tool_runtime( "add_email_group_alias", {"group_id": group_id, "alias_email": alias_email}, mutates=True, ) as rt: # The HTTP twin caps this at 255 (``schemas.py``); # MCP calls the service directly, so the cap is # replicated here. ``alias_email`` reaches a LIKE pattern and # a Google request body. alias_email = _bound_free_text( alias_email, field="alias_email", max_len=255, ) or "" from app.domains.email_group.admin_service import ( # noqa: PLC0415 EmailGroupAdminService, ) data = await EmailGroupAdminService(rt.db).add_alias( group_id=_u(group_id), alias_email=alias_email, actor=rt.user, actor_roles=list(rt.user.role_names or []), trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="remove_email_group_alias", description=( "Remove an alias address from a STATIC mail group.\n" "**Mail sent to that address stops being delivered.** Refused " "when the address is the group's PRIMARY (that is not an alias " "— removing the group is a different, irreversible operation) " "and when the group does not carry the alias at all, so a typo " "cannot produce a green 'removed' answer against a group that " "never had it.\n" "An ``already_absent`` outcome means Google had no such editable " "alias — nothing was removed, and Helix's alias mirror is " "deliberately left untouched, because Google's non-editable " "aliases cannot be deleted yet DO still route mail. Report that " "outcome as 'nothing changed', not as a removal. Restricted to " "HR / global_admin. 1:1 with DELETE " "/api/v1/email-groups/manage/{id}/aliases." ), ) async def remove_email_group_alias(group_id: str, alias_email: str) -> dict: async with tool_runtime( "remove_email_group_alias", {"group_id": group_id, "alias_email": alias_email}, mutates=True, ) as rt: # The HTTP twin caps this at 255 (``schemas.py``); # MCP calls the service directly, so the cap is # replicated here. ``alias_email`` reaches a LIKE pattern and # a Google request body. alias_email = _bound_free_text( alias_email, field="alias_email", max_len=255, ) or "" from app.domains.email_group.admin_service import ( # noqa: PLC0415 EmailGroupAdminService, ) data = await EmailGroupAdminService(rt.db).remove_alias( group_id=_u(group_id), alias_email=alias_email, actor=rt.user, actor_roles=list(rt.user.role_names or []), trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="update_email_group_display", description=( "Rename a static mail group or edit its description. Both " "fields are optional; omitting one leaves it untouched, while " "passing an empty description CLEARS it. The group's ADDRESS " "cannot be changed — Google has no rename-address operation for " "a group, so a different address means a new group. An " "unchanged edit is a no-op and writes no audit row. Refused for " "dynamic groups (use the dynamic-group tools). Restricted to HR " "/ global_admin. 1:1 with PUT " "/api/v1/email-groups/manage/{id}/display." ), ) async def update_email_group_display( group_id: str, name: str | None = None, description: str | None = None, ) -> dict: async with tool_runtime( "update_email_group_display", {"group_id": group_id, "name": name, "description": description}, mutates=True, ) as rt: from app.domains.email_group.admin_service import ( # noqa: PLC0415 EmailGroupAdminService, ) data = await EmailGroupAdminService(rt.db).update_display( group_id=_u(group_id), name=name, description=description, actor=rt.user, actor_roles=list(rt.user.role_names or []), trace_id=_trace_str(), ) await rt.db.commit() result: dict = serialize(data) return result @mcp_server.tool( name="list_email_group_operation_log_actions", description=( "The catalog of audit actions the mail-group operation log can " "contain, each with its category. Served from a hand-kept " "registry, NOT a DISTINCT over the log, so an action nobody has " "performed yet is still listed and therefore still filterable. " "Call this before passing ``actions`` to " "list_email_group_operation_log rather than guessing action " "names. Restricted to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/manage/audit-log/actions." ), ) async def list_email_group_operation_log_actions() -> dict: async with tool_runtime("list_email_group_operation_log_actions", {}) as rt: from app.domains.email_group.manage_actions import ( # noqa: PLC0415 EMAIL_GROUP_ACTIONS, ) from app.domains.email_group.manage_service import ( # noqa: PLC0415 EmailGroupManageService, ) EmailGroupManageService(rt.db).require_manage( list(rt.user.role_names or []), ) result: dict = serialize({ "actions": [ {"action": a, "category": c} for a, c in EMAIL_GROUP_ACTIONS.items() ], }) return result @mcp_server.tool( name="list_dynamic_email_groups", description=( "List Google Cloud Identity dynamic email groups (membership " "computed from a rule over synced HR attributes like department " "/ office / country). Each item includes the compiled + " "structured rule, direct member count, and a health block whose " "``dead_values`` lists rule terms that currently match zero " "active users (the root cause of silent all-hands drops). " "Restricted to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/dynamic." ), ) async def list_dynamic_email_groups() -> dict: async with tool_runtime("list_dynamic_email_groups", {}) as rt: from app.domains.email_group.dynamic_service import ( DynamicEmailGroupService, ) data = await DynamicEmailGroupService(rt.db).list_groups( actor_roles=list(rt.user.role_names or []), ) return serialize(data) @mcp_server.tool( name="get_dynamic_email_group", description=( "Get one dynamic email group's detail, including the " "``field_catalog`` — the list of rule-eligible attribute fields " "with their live value options and per-value member counts. Use " "this before preview_dynamic_email_group_rule / " "upsert_dynamic_email_group_rule to build a rule that references " "only real, populated values (avoids dead values). Restricted " "to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/dynamic/{id}." ), ) async def get_dynamic_email_group(group_id: str) -> dict: async with tool_runtime( "get_dynamic_email_group", {"group_id": group_id}, ) as rt: from app.domains.email_group.dynamic_service import ( DynamicEmailGroupService, ) data = await DynamicEmailGroupService(rt.db).get_detail( group_id=_u(group_id), actor_roles=list(rt.user.role_names or []), ) return serialize(data) @mcp_server.tool( name="reconcile_dynamic_email_group", description=( "Reconcile a dynamic email group: compare the membership the " "rule expects (evaluated locally over synced HR attributes) " "against the actual Google group members, returning the " "added/removed delta. Read-only diagnostic — does not change " "membership. Restricted to HR / global_admin. 1:1 with GET " "/api/v1/email-groups/dynamic/{id}/reconcile." ), ) async def reconcile_dynamic_email_group(group_id: str) -> dict: async with tool_runtime( "reconcile_dynamic_email_group", {"group_id": group_id}, ) as rt: from app.domains.email_group.dynamic_service import ( DynamicEmailGroupService, ) data = await DynamicEmailGroupService(rt.db).reconcile( group_id=_u(group_id), actor_roles=list(rt.user.role_names or []), ) return serialize(data) @mcp_server.tool( name="list_dynamic_email_group_members", description=( "List a dynamic email group's ACTUAL Google members (the live " "roster the rule currently resolves to), each enriched with the " "matching Helix person's name, employee number, job title / " "function, office location, country, and employment status. " "Members that don't map to a known person (nested groups, " "external addresses) are still returned with blank person " "fields. Read-only. This is the JSON behind the members Excel " "export (GET /api/v1/email-groups/dynamic/{id}/members/" "export.xlsx). Restricted to HR / global_admin." ), ) async def list_dynamic_email_group_members(group_id: str) -> dict: async with tool_runtime( "list_dynamic_email_group_members", {"group_id": group_id}, ) as rt: from app.domains.email_group.dynamic_service import ( DynamicEmailGroupService, ) data = await DynamicEmailGroupService(rt.db).list_members( group_id=_u(group_id), actor_roles=list(rt.user.role_names or []), ) result: dict = serialize(data) return result @mcp_server.tool( name="preview_dynamic_email_group_rule", description=( "Preview a candidate membership rule WITHOUT persisting it. " "Returns the compiled Cloud Identity query, the expected-vs-" "actual member delta, and a ``large_drop`` flag when the rule " "would remove more than 30% of current members. ``field`` is one " "attribute key (see field_catalog from get_dynamic_email_group), " "``match_values`` are the values OR'd together on that field, " "``include_emails`` are extra addresses OR'd in, ``negate`` wraps " "the whole clause in NOT(...). Pass ``group_id`` to diff against " "an existing group's live membership, or omit it to preview for a " "brand-new group. Restricted to HR / global_admin. 1:1 with POST " "/api/v1/email-groups/dynamic[/{id}]/preview." ), ) async def preview_dynamic_email_group_rule( field: str | None = None, match_values: list[str] | None = None, include_emails: list[str] | None = None, negate: bool = False, group_id: str | None = None, ) -> dict: async with tool_runtime( "preview_dynamic_email_group_rule", { "group_id": group_id, "field": field, "match_values": match_values, "include_emails": include_emails, "negate": negate, }, ) as rt: import types from app.core.exceptions import ValidationError from app.domains.email_group.dynamic_service import ( DynamicEmailGroupService, ) match_values = list(match_values or []) include_emails = list(include_emails or []) # Bound the LLM-supplied lists at the MCP boundary — a runaway # client could otherwise pass tens of thousands of values before # the service-layer validator fires. Mirrors the 50-cap used on # request_email_group.additional_owner_ids. if len(match_values) > 500: raise ValidationError( f"match_values may contain at most 500 entries " f"(got {len(match_values)}).", ) if len(include_emails) > 500: raise ValidationError( f"include_emails may contain at most 500 entries " f"(got {len(include_emails)}).", ) rule_in = types.SimpleNamespace( field=field, match_values=match_values, include_emails=include_emails, negate=bool(negate), ) data = await DynamicEmailGroupService(rt.db).preview_rule( group_id=_u(group_id) if group_id else None, rule_in=rule_in, actor_roles=list(rt.user.role_names or []), ) return serialize(data) @mcp_server.tool( name="upsert_dynamic_email_group_rule", description=( "Persist a dynamic email group's membership rule (HR edits take " "effect immediately — real Cloud Identity patch, audited, IT-" "admin-notified; no Feishu approval). ``field`` / ``match_values`` " "/ ``include_emails`` / ``negate`` define the rule exactly as in " "preview_dynamic_email_group_rule. If the change would remove more " "than 30% of current members the call is refused with " "LARGE_MEMBERSHIP_DROP unless ``confirm_large_drop=true`` — always " "preview first. Restricted to HR / global_admin. 1:1 with PUT " "/api/v1/email-groups/dynamic/{id}/rule." ), ) async def upsert_dynamic_email_group_rule( group_id: str, field: str | None = None, match_values: list[str] | None = None, include_emails: list[str] | None = None, negate: bool = False, confirm_large_drop: bool = False, ) -> dict: async with tool_runtime( "upsert_dynamic_email_group_rule", { "group_id": group_id, "field": field, "match_values": match_values, "include_emails": include_emails, "negate": negate, "confirm_large_drop": confirm_large_drop, }, mutates=True, ) as rt: import types from app.core.exceptions import ValidationError from app.domains.email_group.dynamic_service import ( DynamicEmailGroupService, ) match_values = list(match_values or []) include_emails = list(include_emails or []) if len(match_values) > 500: raise ValidationError( f"match_values may contain at most 500 entries " f"(got {len(match_values)}).", ) if len(include_emails) > 500: raise ValidationError( f"include_emails may contain at most 500 entries " f"(got {len(include_emails)}).", ) rule_in = types.SimpleNamespace( field=field, match_values=match_values, include_emails=include_emails, negate=bool(negate), ) data = await DynamicEmailGroupService(rt.db).update_rule( group_id=_u(group_id), rule_in=rule_in, confirm_large_drop=bool(confirm_large_drop), actor=rt.user, actor_roles=list(rt.user.role_names or []), trace_id=_trace_str(), ) return serialize(data) # ════════════════════════════════════════════════════════════════════ # Asset search (admin-grade list with filters; complements list_my_assets) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="search_assets", description=( "Search the full asset catalog with admin-grade filters — 全部资产 / " "库存查询 / 某个人手里有什么设备. " "Requires asset_admin / global_admin / auditor (read-only). A " "regular employee should use list_my_assets instead. " "Recipes: 「有哪些没人认领/空闲的设备」 → status=['idle'] (in stock, " "no holder; 'available' is its accepted legacy twin). " "「离职的人手里还有什么设备」 → status=['held_by_leaver']. " "「办公室共用的设备」 → status=['held_by_office'] — an office admin " "is responsible for these, so they are NOT unclaimed. " "「张三的设备」 → resolve the name with search_directory_people, then " "pass that UUID as assigned_to. " "Filters: " "search (matches asset_tag/brand/model/serial/asset_name), " "status (list — the vocabulary is idle / available / claimed / " "assigned / borrowed / held_by_office / held_by_leaver / disposed " "plus the in-flight *_pending states. There is NO 'in_stock': " "asking for it matches nothing, which reads as 'nothing is in " "stock' while 491 idle assets sit on the shelf), category (list), " "location (list), assigned_to (single UUID — pass 'me' to " "scope to caller), holder_kind (list, 'personal' | 'office' — " "scope to office-held vs personally-held devices), " "holder_office_id (list of UUIDs — scope to specific office " "addresses). Bounded pagination — max 200 rows." ), ) async def search_assets( search: str | None = None, status: list[str] | None = None, category: list[str] | None = None, location: list[str] | None = None, assigned_to: str | None = None, holder_kind: list[str] | None = None, holder_office_id: list[str] | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "search_assets", { "search": search, "status": status, "category": category, "location": location, "assigned_to": assigned_to, "holder_kind": holder_kind, "holder_office_id": holder_office_id, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_all_assets if not can_read_all_assets(rt.user.role_names or []): raise ForbiddenError( "search_assets requires asset_admin / global_admin / " "auditor — employees should use list_my_assets.", ) from app.domains.asset.service import AssetService owner = ( rt.user.id if assigned_to == "me" else (_u(assigned_to) if assigned_to else None) ) office_uuids = ( [_u(x) for x in holder_office_id] if holder_office_id else None ) rows, total = await AssetService(rt.db).list_assets( page=page, page_size=page_size, status=status, category=category, location=location, assigned_to=owner, holder_kind=holder_kind, holder_office_id=office_uuids, search=search, ) return { "items": [_serialize_asset(a) for a in rows], "total": total, "page": page, "page_size": page_size, } # ════════════════════════════════════════════════════════════════════ # Cloud efficiency / cost-optimization (read-only insight) # ════════════════════════════════════════════════════════════════════ async def _efficiency_owner_scope(rt: Any) -> Any: """Mirror efficiency_router._owner_scope: None = org-wide (admin-tier); else the resolved OwnerVisibility (self + subordinates + servers attributed to cloud subscriptions they own); else Forbidden.""" from app.core.exceptions import ForbiddenError from app.core.rbac import ( can_read_cloud, can_view_cloud_resources, get_subordinate_person_ids, ) from app.domains.cmdb.visibility import resolve_owner_visibility roles = list(rt.user.role_names or []) if can_read_cloud(roles): return None if not can_view_cloud_resources(roles): raise ForbiddenError( "Cloud efficiency requires a cloud read role " "(global_admin / cloud_admin / finance / auditor / management), " "or you can only see servers you own.", ) subs = await get_subordinate_person_ids(rt.db, str(rt.user.id)) person_ids = [str(rt.user.id), *subs] if subs else [str(rt.user.id)] return await resolve_owner_visibility(rt.db, person_ids) async def _efficiency_owner_scope_for_write(rt: Any) -> Any: """Mirror efficiency_router._owner_scope_for_write: the org-wide (``None``) branch requires cloud WRITE authority (global_admin / cloud_admin). Read-only admin tiers (auditor / finance / management) fall through to their own visibility so a triage write via MCP is confined to servers they answer for — same contract as the HTTP route (previously this tool used the READ scope, letting an auditor triage any server).""" from app.core.exceptions import ForbiddenError from app.core.rbac import ( can_manage_cloud, can_view_cloud_resources, get_subordinate_person_ids, ) from app.domains.cmdb.visibility import resolve_owner_visibility roles = list(rt.user.role_names or []) if can_manage_cloud(roles): return None if not can_view_cloud_resources(roles): raise ForbiddenError( "Cloud efficiency triage requires cloud visibility " "(you may triage servers you own or that belong to " "subscriptions you own).", ) subs = await get_subordinate_person_ids(rt.db, str(rt.user.id)) person_ids = [str(rt.user.id), *subs] if subs else [str(rt.user.id)] return await resolve_owner_visibility(rt.db, person_ids) @mcp_server.tool( name="get_cloud_efficiency_summary", description=( "Return the cloud cost-optimization rollup: totals (servers " "assessed, actionable count, estimated monthly savings USD, " "attributed monthly cost USD), counts by verdict " "(idle/oversized/healthy/busy/insufficient_data/no_metrics), and a " "per-cloud breakdown. Read-only. Admin-tier readers " "(global_admin/cloud_admin/finance/auditor/management) see the whole " "fleet; an employee sees servers they own (self + reports) plus " "every server attributed to a cloud subscription they or their " "reports own. Mirrors GET /api/v1/cmdb/efficiency/summary." ), ) async def get_cloud_efficiency_summary(integration_id: str | None = None) -> dict: async with tool_runtime( "get_cloud_efficiency_summary", {"integration_id": integration_id}, ) as rt: from app.domains.cmdb import efficiency_service if integration_id: integration_id = str(_u(integration_id)) visibility = await _efficiency_owner_scope(rt) return await efficiency_service.efficiency_summary( rt.db, visibility=visibility, integration_id=integration_id, ) @mcp_server.tool( name="list_cloud_efficiency_instances", description=( "List cloud instances with their efficiency verdict, recommendation, " "utilization (CPU p95/avg), monthly cost, and estimated monthly " "savings — the machines a FinOps/IT reviewer should act on. Filter by " "verdict (idle/oversized/busy/healthy/insufficient_data/no_metrics), " "recommendation (stop/downsize/keep/investigate/none), integration_id, " "or min_savings; sort by savings|cost|cpu. Paginated (page_size ≤ 100). " "Read-only, same scope as the summary tool. Mirrors " "GET /api/v1/cmdb/efficiency/instances." ), ) async def list_cloud_efficiency_instances( verdict: str | None = None, recommendation: str | None = None, integration_id: str | None = None, min_savings: float | None = None, sort: str = "savings", page: int = 1, page_size: int = 20, ) -> dict: async with tool_runtime( "list_cloud_efficiency_instances", { "verdict": verdict, "recommendation": recommendation, "integration_id": integration_id, "min_savings": min_savings, "sort": sort, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import AppException from app.domains.cmdb import efficiency_service if sort not in ("savings", "cost", "cpu"): raise AppException( code="VALIDATION_ERROR", message="`sort` must be one of savings|cost|cpu", status_code=422, ) if integration_id: integration_id = str(_u(integration_id)) page = max(1, int(page)) page_size = min(100, max(1, int(page_size))) visibility = await _efficiency_owner_scope(rt) items, total = await efficiency_service.list_instances( rt.db, visibility=visibility, integration_id=integration_id, verdict=verdict, recommendation=recommendation, min_savings=min_savings, sort=sort, page=page, page_size=page_size, ) return {"items": items, "total": total, "page": page, "page_size": page_size} @mcp_server.tool( name="triage_cloud_efficiency_recommendation", description=( "Record a NON-DESTRUCTIVE owner decision on a server's efficiency " "recommendation so it leaves the '待处理' queue. ``action`` is " "'keep' (keep as-is — a ``reason`` is required), 'acknowledge' " "(seen, will handle), or 'snooze' (remind later — optional " "``snooze_days`` 1–180, default 30). This does NOT stop, " "decommission, or downsize anything — machine lifecycle stays in " "the management surface; this only records intent. You may triage a " "server you own or one attributed to a cloud subscription you own; " "only cloud WRITE admins (global_admin/cloud_admin) may triage any " "server. " "Mirrors POST /api/v1/cmdb/efficiency/instances/{server_id}/triage." ), ) async def triage_cloud_efficiency_recommendation( server_id: str, action: str, reason: str | None = None, snooze_days: int | None = None, ) -> dict: async with tool_runtime( "triage_cloud_efficiency_recommendation", { "server_id": server_id, "action": action, "reason": reason, "snooze_days": snooze_days, }, mutates=True, ) as rt: from app.audit.service import AuditService from app.core.exceptions import AppException, NotFoundError from app.domains.cmdb import efficiency_service if action not in ("keep", "acknowledge", "snooze"): raise AppException( code="VALIDATION_ERROR", message="`action` must be one of keep|acknowledge|snooze", status_code=422, ) if action == "keep" and not (reason and reason.strip()): raise AppException( code="VALIDATION_ERROR", message="A `reason` is required to keep a flagged server", status_code=422, ) server_id = str(_u(server_id)) from app.domains.cmdb.visibility import is_server_visible visibility = await _efficiency_owner_scope_for_write(rt) if visibility is not None and not await is_server_visible( rt.db, server_id, visibility, ): raise NotFoundError("Server", server_id) row = await efficiency_service.submit_triage( rt.db, server_id=server_id, action=action, reason=reason, snooze_days=snooze_days, actor_person_id=str(rt.user.id), ) if row is None: raise NotFoundError("Server", server_id) await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="cmdb.efficiency.triage", status="success", resource_type="cmdb_server", resource_id=server_id, detail={ "action": action, "reason": reason, "snooze_days": snooze_days, "verdict": row.get("verdict"), "via": "mcp", }, ) return row @mcp_server.tool( name="get_cloud_savings_outcomes", description=( "Leadership OUTCOME view of cloud cost-optimization: money ACTUALLY " "saved (realized = pre-action cost − actual post-action cost for " "servers that were flagged idle/oversized and then destroyed, " "stopped, or downsized — whether executed inside Helix or done in " "the cloud console and detected by the daily sync), split by method " "+ source (helix/console) + department, a monthly trend, and the " "still-open estimated opportunity. This answers 'did we really save " "money, how " "much, and how' — distinct from the estimate-only summary/instances " "tools. Read-only, whole-fleet; requires a cloud-read role " "(global_admin/cloud_admin/finance/auditor/management). Optional " "``since_days`` limits realized events to a recent window. Mirrors " "GET /api/v1/cmdb/efficiency/savings-outcomes." ), ) async def get_cloud_savings_outcomes(since_days: int | None = None) -> dict: async with tool_runtime( "get_cloud_savings_outcomes", {"since_days": since_days}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_cloud from app.domains.cmdb import savings_service if not can_read_cloud(list(rt.user.role_names or [])): raise ForbiddenError( "Cloud savings outcomes require a cloud-read role " "(global_admin / cloud_admin / finance / auditor / management).", ) if since_days is not None: since_days = max(1, min(1830, int(since_days))) return await savings_service.savings_outcomes(rt.db, since_days=since_days) # ════════════════════════════════════════════════════════════════════ # Cloud billing summary (cloud_admin / finance read-only) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="get_cmdb_billing_summary", description=( "Return aggregated multi-cloud spend for one month: total_cost_usd, " "by_account breakdown, top services. Requires global_admin / " "cloud_admin / finance / auditor (any read-cloud or finance " "role). ``month`` is YYYY-MM; defaults to the previous full " "month. ``integration_id`` narrows to one cloud tenant; omit " "for the all-clouds aggregate. ``measure``: ``list`` (default, " "business pre-discount / engineering basis, aggregated from " "cmdb_cloud_billing_daily) or ``invoice`` (net paid, reads " "cmdb_billing_monthly — finance / cloud_admin / global_admin " "only; other roles get 403, never a silent downgrade)." ), ) async def get_cmdb_billing_summary( month: str | None = None, integration_id: str | None = None, measure: str | None = None, ) -> dict: async with tool_runtime( "get_cmdb_billing_summary", { "month": month, "integration_id": integration_id, "measure": measure, }, ) as rt: from datetime import date as _date from datetime import timedelta as _td from decimal import Decimal as _Decimal from types import SimpleNamespace from app.core.exceptions import AppException, ForbiddenError, NotFoundError from app.core.rbac import can_read_cloud, can_view_finance from app.domains.cmdb.cost_view import ( COST_MEASURE_INVOICE as _MEASURE_INVOICE, ) from app.domains.cmdb.cost_view import resolve_measure from app.domains.cmdb.models import CmdbBillingMonthly from app.integrations.models import Integration roles = list(rt.user.role_names or []) if not (can_read_cloud(roles) or can_view_finance(roles)): raise ForbiddenError( "Cloud billing requires global_admin / cloud_admin / " "finance / auditor.", ) # 口径闸 —— 与 HTTP ``/cmdb/billing/summary`` 1:1(MCP-sync # 规则)。默认 list;invoice 仅保密圈,无权限 → 403,绝不静默 # 降级。缺了这道闸,同一个 auditor 用 AI 客户端读 invoice、看 # 网页读 list,月粒度折扣率就出来了。 try: resolved_measure = resolve_measure(measure, roles) except ValueError as exc: raise AppException( code="VALIDATION_ERROR", message=str(exc), status_code=422, ) from exc if resolved_measure is None: raise ForbiddenError( "measure=invoice (net paid) is restricted to finance / " "cloud_admin / global_admin.", ) if month: try: target = _date.fromisoformat(f"{month}-01") except ValueError as exc: raise AppException( code="VALIDATION_ERROR", message=f"`month` must be YYYY-MM, got {month!r}", status_code=422, ) from exc else: first_this = _date.today().replace(day=1) target = (first_this - _td(days=1)).replace(day=1) narrow_id: str | None = None if integration_id: # Validate as UUID early so a typo'd id surfaces a 422 # rather than silently returning an empty aggregate. narrow_id = str(_u(integration_id)) if resolved_measure == _MEASURE_INVOICE: stmt = select(CmdbBillingMonthly).where( CmdbBillingMonthly.month == target, ) if narrow_id: stmt = stmt.where( CmdbBillingMonthly.integration_id == narrow_id, ) rows: list = list((await rt.db.execute(stmt)).scalars().all()) else: # list 口径:本月日表按 (integration, currency) 聚合,与 # HTTP ``/cmdb/billing/summary`` 同一 SQL 形状。currency 进 # 分组键 —— CN 云行是原生 CNY(列名骗人),下面的 converter # 逐行折算。服务榜不从这里取(见下方 facts 聚合块)。 from sqlalchemy import func as _func from app.domains.cmdb.cost_view import daily_engineering_amount from app.domains.cmdb.models import CmdbCloudBillingDaily _next_month = (target + _td(days=32)).replace(day=1) agg_q = ( select( CmdbCloudBillingDaily.integration_id, CmdbCloudBillingDaily.currency, _func.coalesce( _func.sum(daily_engineering_amount()), 0, ).label("total"), ) .where( CmdbCloudBillingDaily.date >= target, CmdbCloudBillingDaily.date < _next_month, ) .group_by( CmdbCloudBillingDaily.integration_id, CmdbCloudBillingDaily.currency, ) ) if narrow_id: agg_q = agg_q.where( CmdbCloudBillingDaily.integration_id == narrow_id, ) rows = [ SimpleNamespace( integration_id=r.integration_id, total_cost_usd=r.total or 0, currency=r.currency, ) for r in (await rt.db.execute(agg_q)).all() ] if integration_id and not rows: # Distinguish "no billing rows for this tenant in this # month" from "tenant doesn't exist" — the latter should # 404 so a stale id doesn't masquerade as a quiet month. integ = await rt.db.get(Integration, _u(integration_id)) if integ is None: raise NotFoundError("Integration", integration_id) # CNY-billed tenants store native CNY in ``total_cost_usd`` with # currency='CNY'; convert per-row so this matches the # /billing/summary HTTP card (raw-summing would mix USD with # ~7×-inflated CNY-as-USD). from app.core.fx_rate import CloudCostUsdConverter cost_conv = CloudCostUsdConverter(rt.db) total = _Decimal("0") # integration_id → 折算后 USD 行。list 路径同一 integration 可能 # 有多个币种组(CNY + USD),折算后必须合并成一行。 by_account_acc: dict[str, dict] = {} service_totals: dict[str, float] = {} integrations_cache: dict[str, Integration | None] = {} # 任一币种组折不出汇率的 integration —— 整个账号从 by_account # 与合计里剔除(与 HTTP ``/billing/summary`` / ``/billing/monthly`` # 一致:偏低的数标成完整,比缺行更容易误导)。 partial_integration_ids: set[str] = set() for r in rows: key = str(r.integration_id) if key not in integrations_cache: integrations_cache[key] = await rt.db.get( Integration, _u(key), ) integ = integrations_cache[key] if integ is None: # 孤儿账单行(integration 被硬删、账单历史留存)——跳过, # 与 HTTP 面一致,否则 by_account 里出现以 UUID 当名字的 # 幽灵账号。 continue cost_usd, converted = await cost_conv.to_usd_checked( r.total_cost_usd, r.currency, key, ) if not converted: partial_integration_ids.add(key) continue cost = _Decimal(str(cost_usd)) total += cost acct = by_account_acc.setdefault(key, { "integration_id": key, "integration_name": integ.name, "provider": integ.provider, "total_cost_usd": 0.0, }) acct["total_cost_usd"] += float(cost) for bad_id in partial_integration_ids: dropped = by_account_acc.pop(bad_id, None) if dropped is not None: total -= _Decimal(str(dropped["total_cost_usd"])) # 服务榜:**两个口径都**从 ``cmdb_cost_facts`` 按 # ``fact_amount_expr`` 聚合,与 HTTP ``/billing/summary`` 逐字 # 对齐(理由见那里的注释:list 不能挂实付拆分; # ``cmdb_billing_monthly.cost_by_service`` 的币种读侧不可判定)。 # 限制在实际进了合计的 integration 上 —— 此前无论是否传 # ``integration_id`` 都扫全部租户,榜单跨了合计没覆盖的云。 counted_integration_ids = set(by_account_acc) if counted_integration_ids: from sqlalchemy import func as _svc_func from app.domains.cmdb.cost_view import fact_amount_expr from app.domains.cmdb.models import CmdbCostFact _next_month_svc = (target + _td(days=32)).replace(day=1) svc_q = ( select( CmdbCostFact.service, _svc_func.coalesce( _svc_func.sum(fact_amount_expr(resolved_measure)), 0, ).label("usd"), ) .where( CmdbCostFact.date >= target, CmdbCostFact.date < _next_month_svc, CmdbCostFact.integration_id.in_(counted_integration_ids), ) .group_by(CmdbCostFact.service) ) for r in (await rt.db.execute(svc_q)).all(): if not r.service: continue service_totals[r.service] = ( service_totals.get(r.service, 0) + float(r.usd or 0) ) if cost_conv.unconverted_by_currency: logger.warning( "get_cmdb_billing_summary: %s rows had no FX rate and were " "excluded from the USD total: %s", month or "prev-month", cost_conv.unconverted_by_currency, ) top_services = sorted( ({"service": k, "cost_usd": v} for k, v in service_totals.items()), key=lambda x: x["cost_usd"], reverse=True, )[:10] return { "month": target.isoformat(), "measure": resolved_measure, # 覆盖面基准 —— invoice 是租户级 provider 月汇总,list 是日表 # 逐 scope 行 + 未登记账号对账行之和。切口径看到的差额含这部分 # 覆盖差,不可整体当折扣读。 "aggregate_basis": ( "tenant_monthly" if resolved_measure == _MEASURE_INVOICE else "billing_daily_sum" ), # 服务榜与合计不同源(facts vs 日表/月表)——榜单之和 < 合计 # 时读者据此判断那是覆盖差而不是折扣。 "top_services_basis": ( "cost_facts_invoice" if resolved_measure == _MEASURE_INVOICE else "cost_facts_list" ), "total_cost_usd": float(total), "by_account": sorted( by_account_acc.values(), key=lambda x: x["total_cost_usd"], reverse=True, ), "top_services": top_services, } # ════════════════════════════════════════════════════════════════════ # Cloud cost analysis (production-grade FinOps surface, migration 139) # ════════════════════════════════════════════════════════════════════ # # These four tools mirror the ``/api/v1/cmdb/cost/*`` HTTP surface # (per the MCP-sync rule: every self-service flow ships with its 1:1 # MCP tool in the same PR). RBAC: ``can_view_finance`` — global_admin / # finance / auditor / management. def _mcp_withhold_cloud(rt) -> bool: """MCP twin of ``finance/router.py::_withhold_cloud_for``. The finance-view gate admits auditor + management, who are outside the invoice-measure circle, so the ledger's cloud rows (materialised from ``cmdb_cost_facts.amount_usd`` = 实付) must be withheld from them here exactly as they are over HTTP (#2121). Two surfaces onto one ledger answering differently is how the first leak survived. """ from app.core.rbac import can_view_invoice_measure return not can_view_invoice_measure(list(rt.user.role_names or [])) def _require_finance_view(rt) -> None: """RBAC gate for the cost-analysis MCP tools. Mirrors :func:`app.core.rbac.can_view_finance` exactly — keep the role list on the error message in lockstep with that function (currently global_admin / finance / auditor / management). ``cloud_admin`` is NOT included; managing cloud integrations doesn't grant cost-data read access by design (finance separation). """ from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_finance roles = list(rt.user.role_names or []) if not can_view_finance(roles): raise ForbiddenError( "Cost analysis requires global_admin / finance / " "auditor / management role.", ) def _finance_periods( period: str | None, start: str | None, end: str | None, ) -> list[str]: """Month scope for the range-capable finance reads — mirrors the HTTP layer's ``_periods_from_query`` (same validation, same 400 codes) so the MCP twin can't accept a range the endpoint would reject.""" from app.core.exceptions import ValidationError from app.domains.finance import read_service from app.domains.finance import service as fsvc if start or end: if not (start and end): raise ValidationError("start and end must be given together") return read_service.enumerate_periods( fsvc.validate_period(start), fsvc.validate_period(end), ) if period: return [fsvc.validate_period(period)] raise ValidationError( "pass either period=YYYY-MM or start=YYYY-MM + end=YYYY-MM", ) def _finance_vendor_filter( vendor: str | list[str] | None, ) -> str | list[str] | None: """Normalise the ``vendor`` filter, which accepts one name or a list. An LLM client will hand over either shape; both reach the service as an exact-match filter (a list becomes an ``IN``). Empty/blank entries are dropped so ``vendor=[""]`` doesn't silently match nothing. """ if isinstance(vendor, (list, tuple)): cleaned = [str(v).strip() for v in vendor if str(v or "").strip()] # #2238: same 500-vendor ceiling the HTTP surface enforces. The cap # also lives inside ``_apply_filters`` (so no path can slip past it); # calling it here fails fast with the same TOO_MANY_VENDORS error # before any DB work. from app.domains.finance import read_service as _frs _frs.enforce_vendor_cap(cleaned) return cleaned or None if isinstance(vendor, str): return vendor.strip() or None return None def _validate_cost_window(start_dt, end_dt, *, max_days: int = 365) -> None: """Parity with the HTTP layer's ``_parse_window`` cap. Without this, an LLM client could sweep five years of cost facts in one tool call and stall the MCP runtime; the HTTP surface is capped at 365 days for the same reason. """ from app.core.exceptions import ValidationError if end_dt < start_dt: raise ValidationError("end < start") if (end_dt - start_dt).days > max_days: raise ValidationError( f"max {max_days}-day window per request", ) def _validate_backfill_floor(start_dt) -> None: """Parity with the HTTP backfill endpoint's 425-day earliest-allowed floor. Cloud billing APIs cap retention at the tightest of: * AWS Cost Explorer ~14 months (~425 days) * Azure Cost Management ~13 months (~395 days) * Aliyun BSS 6-12 months * GCP BigQuery export = export-table retention (admin-controlled, typically 12-24 months) Without this gate an AI agent could call ``cost_backfill`` with ``start_date=2020-01-01`` and burn vendor billing-API quota on multi-year empty windows. The HTTP endpoint at ``/cmdb/cost/backfill`` enforces the same floor; this MCP tool must mirror it. """ from datetime import UTC, datetime, timedelta from app.core.exceptions import ValidationError today = datetime.now(UTC).date() earliest_allowed = today - timedelta(days=425) if start_dt < earliest_allowed: raise ValidationError( f"start_date {start_dt} is before the supported backfill " f"horizon ({earliest_allowed}, ~14 months). Cloud billing " "APIs (AWS CE / Azure CM / Aliyun BSS) do not retain " "detail older than this.", ) def _validate_uuid(value: str | None, *, label: str) -> None: """Reject malformed UUIDs early so SQL doesn't see a typo'd id.""" if value is None: return import uuid as _uuid from app.core.exceptions import ValidationError try: _uuid.UUID(value) except (TypeError, ValueError) as exc: raise ValidationError( f"{label} must be a valid UUID", ) from exc @mcp_server.tool( name="cost_breakdown", description=( "Aggregate cloud cost over a date window broken out by one " "dimension. ``group_by`` ∈ {service, resource_group, " "resource_id, region, resource_type, cost_center, department, " "owner, integration}. ``start`` / ``end`` ISO dates default to " "the last 30 days. ``integration_id`` narrows to one tenant; " "``scope_id`` narrows to a single subscription / account / " "project (pair with group_by=resource_id for per-resource, e.g. " "per-Foundry, spend within one subscription). " "Returns top-N + 'other' bucket + an explicit unallocated KPI. " "Read-only; requires finance-view role." ), ) async def cost_breakdown( group_by: str, start: str | None = None, end: str | None = None, integration_id: str | None = None, scope_id: str | None = None, top: int = 20, ) -> dict: async with tool_runtime( "cost_breakdown", { "group_by": group_by, "start": start, "end": end, "integration_id": integration_id, "scope_id": scope_id, "top": top, }, ) as rt: from datetime import UTC from datetime import datetime as _dt from datetime import timedelta as _td from app.core.exceptions import ValidationError from app.domains.cmdb.cost_service import ( GROUP_BY_COLUMNS, breakdown, ) _require_finance_view(rt) if group_by not in GROUP_BY_COLUMNS: raise ValidationError( f"unknown group_by={group_by!r}; " f"supported: {sorted(GROUP_BY_COLUMNS)}", ) if top < 1 or top > 200: raise ValidationError("top must be between 1 and 200") _validate_uuid(integration_id, label="integration_id") _validate_uuid(scope_id, label="scope_id") end_dt = ( _parse_iso_date(end, field="end") if end else (_dt.now(UTC).date() - _td(days=1)) ) start_dt = ( _parse_iso_date(start, field="start") if start else (end_dt - _td(days=29)) ) _validate_cost_window(start_dt, end_dt) try: result = await breakdown( rt.db, start=start_dt, end=end_dt, group_by=group_by, integration_id=integration_id, scope_id=scope_id, top=top, ) except ValueError as exc: raise ValidationError(str(exc)) from exc return serialize({ "start": start_dt.isoformat(), "end": end_dt.isoformat(), "group_by": group_by, **result, }) @mcp_server.tool( name="cost_top_resources", description=( "Top resources by cost over a date window with 14-day spark " "series. Each row carries ``tags`` (the user-defined tags " "harvested from the upstream CUR / BSS bill — Owner, " "Environment, Application, etc — surfaces the creator / owner " "without a separate CloudTrail lookup). Filters: " "``integration_id``, ``resource_group``, ``search`` (matches " "resource_id / name / type), and ``tag_key`` + ``tag_value`` " "(narrows to rows where ``tags[tag_key]==tag_value``). " "Filter pair must be both-or-neither — passing only one " "raises ``ValidationError`` (HTTP twin returns 400 with " "``code=INVALID_TAG_FILTER``). ``tag_key`` " "must match ``^[A-Za-z0-9_\\-.:/+@]{1,128}$`` (AWS / " "Aliyun / GCP / Azure tag charset); ``tag_value`` max 256 " "chars. ``limit`` 1-500 (default 100). Read-only; requires " "finance-view role." ), ) async def cost_top_resources( start: str | None = None, end: str | None = None, integration_id: str | None = None, resource_group: str | None = None, search: str | None = None, tag_key: str | None = None, tag_value: str | None = None, limit: int = 100, ) -> dict: async with tool_runtime( "cost_top_resources", { "start": start, "end": end, "integration_id": integration_id, "resource_group": resource_group, "search": search, "tag_key": tag_key, "tag_value": tag_value, "limit": limit, }, ) as rt: from datetime import UTC from datetime import datetime as _dt from datetime import timedelta as _td from app.core.exceptions import ValidationError from app.domains.cmdb.cost_service import top_resources _require_finance_view(rt) if limit < 1 or limit > 500: raise ValidationError("limit must be between 1 and 500") _validate_uuid(integration_id, label="integration_id") end_dt = ( _parse_iso_date(end, field="end") if end else (_dt.now(UTC).date() - _td(days=1)) ) start_dt = ( _parse_iso_date(start, field="start") if start else (end_dt - _td(days=29)) ) _validate_cost_window(start_dt, end_dt) try: rows = await top_resources( rt.db, start=start_dt, end=end_dt, integration_id=integration_id, resource_group=resource_group, search=search, tag_key=tag_key, tag_value=tag_value, limit=limit, ) except ValueError as exc: raise ValidationError(str(exc)) from exc return serialize({ "start": start_dt.isoformat(), "end": end_dt.isoformat(), "resources": rows, }) @mcp_server.tool( name="cost_tag_keys", description=( "Discover the set of user-defined tag keys observed in the " "cost facts within a date window, sorted by per-key fact " "count (most-used first). Companion to ``cost_top_resources`` " "— call this first to learn what tag keys exist in the " "tenant, then pass one as ``tag_key`` + a value to filter " "the top resources to a single owner / project / " "environment. ``integration_id`` narrows to one tenant. " "``limit`` 1-500 (default 200). Read-only; requires " "finance-view role." ), ) async def cost_tag_keys( start: str | None = None, end: str | None = None, integration_id: str | None = None, limit: int = 200, ) -> dict: async with tool_runtime( "cost_tag_keys", { "start": start, "end": end, "integration_id": integration_id, "limit": limit, }, ) as rt: from datetime import UTC from datetime import datetime as _dt from datetime import timedelta as _td from app.core.exceptions import ValidationError from app.domains.cmdb.cost_service import list_tag_keys _require_finance_view(rt) if limit < 1 or limit > 500: raise ValidationError("limit must be between 1 and 500") _validate_uuid(integration_id, label="integration_id") end_dt = ( _parse_iso_date(end, field="end") if end else (_dt.now(UTC).date() - _td(days=1)) ) start_dt = ( _parse_iso_date(start, field="start") if start else (end_dt - _td(days=29)) ) _validate_cost_window(start_dt, end_dt) keys = await list_tag_keys( rt.db, start=start_dt, end=end_dt, integration_id=integration_id, limit=limit, ) return serialize({ "start": start_dt.isoformat(), "end": end_dt.isoformat(), "keys": keys, }) # ─── Finance unified spend ledger (reconciliation centre) ─────── @mcp_server.tool( name="finance_spend_summary", description=( "Unified finance spend summary for a month ('YYYY-MM'): " "consumption total USD (cloud+software+ai), procurement total " "(separate basis), per-cost-type breakdown with %, MoM change, " "unallocated (no-department) amount, and whether the period is " "provisional or closed. The ONE place that sums all spend " "streams without double-counting. Read-only; finance-view role." ), ) async def finance_spend_summary(period: str) -> dict: async with tool_runtime("finance_spend_summary", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize(await read_service.get_summary( rt.db, fsvc.validate_period(period), withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_netsuite_gl_summary", description=( "NetSuite general-ledger actuals (the book of record) for a month " "('YYYY-MM'): signed GL movement in USD sliced by account type, " "subsidiary (legal entity), department, and the largest accounts, " "plus whether the NetSuite period is closed and the data freshness. " "This is the top-down 'actual' that Helix's bottom-up spend ledger " "reconciles against. Read-only; finance-view role." ), ) async def finance_netsuite_gl_summary(period: str) -> dict: async with tool_runtime("finance_netsuite_gl_summary", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize( await read_service.netsuite_gl_summary(rt.db, fsvc.validate_period(period)) ) @mcp_server.tool( name="finance_netsuite_sync_status", description=( "Data-presence + freshness of the NetSuite GL mirror: per-period GL " "row counts, last-pulled timestamp, closed flag, and master-data " "counts (subsidiaries / accounts / accounting periods). Use to check " "whether GL actuals have been synced for a month before relying on " "them. Read-only; finance-view role." ), ) async def finance_netsuite_sync_status() -> dict: async with tool_runtime("finance_netsuite_sync_status", {}) as rt: from app.domains.finance import read_service _require_finance_view(rt) return serialize(await read_service.netsuite_sync_status(rt.db)) @mcp_server.tool( name="finance_gl_reconcile", description=( "Reconcile Helix bottom-up spend vs NetSuite booked GL for a month " "('YYYY-MM'): per cost_type (cloud/software/ai/hardware/procurement/" "expense) internal-vs-GL USD with diff + match flag, plus the " "unclassified-GL-account exception list (booked but not yet mapped " "to a cost_type). The trust loop — does our operational spend tie to " "the books. Read-only; finance-view role." ), ) async def finance_gl_reconcile(period: str) -> dict: async with tool_runtime("finance_gl_reconcile", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize( await read_service.gl_reconcile(rt.db, fsvc.validate_period(period)) ) @mcp_server.tool( name="finance_close_cockpit", description=( "Controller month-close readiness for a period ('YYYY-MM'): the " "go/no-go checklist (data complete / GL synced / NetSuite period " "closed / reconciliation clean / no open restatements), the period " "close state, plus the underlying completeness + GL reconciliation. " "Use to answer 'can we close this month yet'. Read-only; finance-view." ), ) async def finance_close_cockpit(period: str) -> dict: async with tool_runtime("finance_close_cockpit", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize( await read_service.close_cockpit(rt.db, fsvc.validate_period(period)) ) @mcp_server.tool( name="finance_close_status", description=( "Month-end book-close progress for a period ('YYYY-MM'): the close " "run (book-close target date, lifecycle status, anchors) plus the " "task rollup — overall + per-owner + per-process completion counts " "and %. Use to answer 'how far along is the June close / who's " "behind'. Read-only; finance-view." ), ) async def finance_close_status(period: str) -> dict: async with tool_runtime("finance_close_status", {"period": period}) as rt: from app.domains.finance import close_serializers as cser from app.domains.finance import close_service, close_task_service _require_finance_view(rt) close_service.validate_period(period) run = await close_service.get_run(rt.db, period) agg = await close_task_service.aggregate(rt.db, period) run_dict = None if run is not None: window = await close_service.compute_window(rt.db, run) run_dict = cser.run_to_dict(run, window) return serialize({"run": run_dict, "aggregate": agg}) @mcp_server.tool( name="finance_close_ai_assist", description=( "Real AI assistance for one month-end close task whose ai_work is " "'assist' or 'perform' (via 百炼, deterministic fallback). assist → " "actionable how-to + risks; perform → a DRAFT work product for human " "review. Advisory only — never posts entries or completes the task. " "Optional mode override ('assist'/'perform'). Finance-operations role." ), ) async def finance_close_ai_assist(task_id: int, mode: str | None = None) -> dict: async with tool_runtime( "finance_close_ai_assist", {"task_id": task_id, "mode": mode}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_operate_finance from app.domains.finance import close_ai_service if not can_operate_finance(list(rt.user.role_names or [])): raise ForbiddenError("close AI assist requires the finance / global-admin role") return serialize(await close_ai_service.generate_task_assist(rt.db, task_id, mode=mode)) @mcp_server.tool( name="finance_close_signals", description=( "Helix-native close signals for a period ('YYYY-MM'): live " "green/amber/red decision support derived from platform data — GL " "synced, reconciliation clean, NetSuite period closed, no open " "restatements, FX frozen, cloud cost materialised, 汇联易 synced — " "each with a detail line + evidence deep-link. These inform but never " "auto-complete close tasks. Read-only; finance-view." ), ) async def finance_close_signals(period: str) -> dict: async with tool_runtime("finance_close_signals", {"period": period}) as rt: from app.domains.finance import close_service, close_signals _require_finance_view(rt) close_service.validate_period(period) return serialize(await close_signals.evaluate_signals(rt.db, period)) @mcp_server.tool( name="finance_my_close_tasks", description=( "The caller's own month-end close tasks for a period (default: their " "latest period). Returns the period label + book-close date, each " "task (process, description, day anchor, deadline, status), and a " "status rollup. Self-scoped — only your own tasks. Read-only." ), ) async def finance_my_close_tasks(period: str | None = None) -> dict: async with tool_runtime("finance_my_close_tasks", {"period": period}) as rt: from app.domains.finance import close_self_service from app.domains.finance import close_serializers as cser payload = await close_self_service.my_close_tasks( rt.db, person_id=str(rt.user.id), period=period, ) payload = {**payload, "tasks": [cser.task_to_dict(t) for t in payload["tasks"]]} return serialize(payload) @mcp_server.tool( name="finance_close_set_task_status", description=( "Set the status of one month-end close task " "(not_started / in_progress / completed / na). Allowed for the task's " "owner (self-service) or a finance operator. Mutating." ), ) async def finance_close_set_task_status(task_id: int, status: str) -> dict: async with tool_runtime( "finance_close_set_task_status", {"task_id": task_id, "status": status}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_operate_finance from app.domains.finance import close_serializers as cser from app.domains.finance import close_task_service task = await close_task_service.get_task(rt.db, task_id) is_owner = str(task.owner_person_id) == str(rt.user.id) if not (is_owner or can_operate_finance(list(rt.user.role_names or []))): raise ForbiddenError( "only the task owner or a finance operator can update this task" ) updated = await close_task_service.set_status( rt.db, task_id, status=status, actor_id=str(rt.user.id), ) return serialize(cser.task_to_dict(updated)) @mcp_server.tool( name="finance_close_set_task_comment", description=( "Set / clear the comment on one month-end close task. Allowed for the " "task's owner (self-service) or a finance operator. Pass an empty " "comment to clear. Mutating." ), ) async def finance_close_set_task_comment(task_id: int, comment: str | None = None) -> dict: async with tool_runtime( "finance_close_set_task_comment", {"task_id": task_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_operate_finance from app.domains.finance import close_serializers as cser from app.domains.finance import close_task_service task = await close_task_service.get_task(rt.db, task_id) is_owner = str(task.owner_person_id) == str(rt.user.id) if not (is_owner or can_operate_finance(list(rt.user.role_names or []))): raise ForbiddenError( "only the task owner or a finance operator can annotate this task" ) updated = await close_task_service.set_comment( rt.db, task_id, comment=(comment or None), commenter_person_id=str(rt.user.id), commenter_label=getattr(rt.user, "display_name", None), ) return serialize(cser.task_to_dict(updated)) @mcp_server.tool( name="finance_flux_gl_tieout", description=( "Tie-out test: compute consolidated BS/PL statement lines for a " "period ('YYYY-MM') directly from NetSuite GL (via the consolidated " "exchange-rate table) and diff them against the manually-imported " ".xls figures in finance-flux. Returns per-line reported vs computed " "vs diff + a tie-rate. Evidence for whether the manual upload can be " "retired. statement = 'BS' or 'PL'. Finance-operations role." ), ) async def finance_flux_gl_tieout(period: str, statement: str = "BS") -> dict: async with tool_runtime( "finance_flux_gl_tieout", {"period": period, "statement": statement}, ) as rt: from app.core.coordinators import finance_flux_gl_coordinator as glc from app.core.exceptions import ForbiddenError from app.core.rbac import can_operate_finance from app.domains.finance import service as fsvc if not can_operate_finance(rt.user.role_names): raise ForbiddenError( "GL tie-out requires the finance-operations role." ) st = statement if statement in ("BS", "PL") else "BS" return serialize(await glc.tie_out(rt.db, fsvc.validate_period(period), st)) @mcp_server.tool( name="finance_my_expenses", description=( "The caller's OWN reimbursement / expense documents (from 汇联易) + a " "status summary (total USD, by payment status, by category). " "Self-service — scoped to the authenticated employee; needs no role. " "Empty until 汇联易 expense actuals are flowing. Read-only." ), ) async def finance_my_expenses() -> dict: async with tool_runtime("finance_my_expenses", {}) as rt: from app.domains.finance import read_service return serialize(await read_service.my_expenses(rt.db, str(rt.user.id))) @mcp_server.tool( name="finance_expense_analytics", description=( "T&E / expense intelligence for a month ('YYYY-MM'): spend by " "category, by department, by payment status, and top spenders " "(over 汇联易 external statements). Read-only; finance-view role." ), ) async def finance_expense_analytics(period: str) -> dict: async with tool_runtime("finance_expense_analytics", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize( await read_service.expense_analytics(rt.db, fsvc.validate_period(period)) ) @mcp_server.tool( name="finance_open_commitments", description=( "Open purchase-order commitments — approved/received POs not yet " "fully paid — as forward spend exposure per department/cost-centre, " "in USD. The 'committed but not yet spent' leg of budget-vs-actual " "(it's what finance_budget_status now subtracts as remaining budget). " "Read-only; finance-view role." ), ) async def finance_open_commitments() -> dict: async with tool_runtime("finance_open_commitments", {}) as rt: from app.core.coordinators import finance_governance_coordinator as gov _require_finance_view(rt) return serialize(await gov.open_commitments(rt.db)) @mcp_server.tool( name="finance_my_attributed_cost", description=( "The caller's OWN cost to the company for a month ('YYYY-MM'): the " "unified spend ledger sliced to owner=me (cloud/software/ai/hardware/" "procurement) + their 汇联易 expenses, with a 12-month total trend. " "Self-service — scoped to the authenticated employee, no role needed. " "Read-only." ), ) async def finance_my_attributed_cost(period: str) -> dict: async with tool_runtime("finance_my_attributed_cost", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc return serialize( await read_service.my_attributed_cost( rt.db, str(rt.user.id), fsvc.validate_period(period), withhold_cloud=_mcp_withhold_cloud(rt)) ) @mcp_server.tool( name="finance_my_bu_costs", description=( "The BU the caller leads/belongs to, scoped to just that BU: per-axis " "(cloud/software/ai/hardware) YTD actual vs budget vs year-end " "forecast for the fiscal year. Self-service for a BU owner — empty if " "the caller maps to no BU. Read-only." ), ) async def finance_my_bu_costs(fiscal_year: int) -> dict: async with tool_runtime("finance_my_bu_costs", {"fiscal_year": fiscal_year}) as rt: from app.domains.finance import read_service return serialize( await read_service.my_bu_costs( rt.db, str(rt.user.id), int(fiscal_year), withhold_cloud=_mcp_withhold_cloud(rt)) ) @mcp_server.tool( name="finance_leadership_dashboard", description=( "Company-wide leadership snapshot for a month ('YYYY-MM'): headline " "consumption/procurement totals + cost-type mix + MoM, top department " "movers, top vendors, per-BU cost vs budget, budget health " "(over/warning counts), unallocated %, and cost-per-headcount. The " "CFO 大屏 in one call. Read-only; finance-view role (incl. management)." ), ) async def finance_leadership_dashboard(period: str) -> dict: async with tool_runtime("finance_leadership_dashboard", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize( await read_service.leadership_dashboard( rt.db, fsvc.validate_period(period), withhold_cloud=_mcp_withhold_cloud(rt)) ) @mcp_server.tool( name="finance_vendor_360", description=( "One vendor's spend UNIFIED across channels for a month: by cost_type " "(cloud/software/ai/hardware/procurement/expense — the split a plain " "vendor total hides), by department, a 12-month trend, and the " "vendor's concentration as a share of company spend. Answers 'how much " "do we actually pay across everything'. Read-only; finance-view." ), ) async def finance_vendor_360(period: str, vendor: str) -> dict: async with tool_runtime( "finance_vendor_360", {"period": period, "vendor": vendor}, ) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize( await read_service.vendor_360( rt.db, fsvc.validate_period(period), vendor, withhold_cloud=_mcp_withhold_cloud(rt)) ) @mcp_server.tool( name="get_finance_flux_summary", description=( "Month-end flux (variance) executive summary for a period ('YYYY-MM'): " "KPI cards (total assets/revenue/gross profit/operating + net income) " "with current vs prior value, Δ, Δ%, good/bad tone, plus the " "variance-flag banner counts (amount-threshold / pct-threshold / both). " "Figures are NetSuite's own consolidated USD report numbers. " "``mode`` = 'mom' (vs prior month) or 'yoy' (vs same month last year). " "Read-only; finance-view role." ), ) async def get_finance_flux_summary(period: str, mode: str = "mom") -> dict: async with tool_runtime( "get_finance_flux_summary", {"period": period, "mode": mode} ) as rt: from app.domains.finance_flux import flux_service _require_finance_view(rt) return serialize(await flux_service.compute_summary( rt.db, period_key=period, mode=mode)) @mcp_server.tool( name="get_finance_flux_statement", description=( "Per-line month-end flux for one statement: ``statement`` = 'BS' " "(balance sheet) or 'PL' (income statement), ``period`` 'YYYY-MM', " "``mode`` 'mom'|'yoy'. Each line returns label, section, current/prior " "value, Δ, Δ%, flag (none/amount/pct/both), and any analyst commentary " "/ recon link / AI narrative. Values are NetSuite's consolidated USD " "report figures. Read-only; finance-view role." ), ) async def get_finance_flux_statement( period: str, statement: str, mode: str = "mom", ) -> dict: async with tool_runtime( "get_finance_flux_statement", {"period": period, "statement": statement, "mode": mode}, ) as rt: from app.domains.finance_flux import flux_service _require_finance_view(rt) return serialize(await flux_service.compute_statement( rt.db, period_key=period, statement=statement, mode=mode)) @mcp_server.tool( name="get_finance_flux_ai_summary", description=( "百炼 AI executive summary of a whole statement's month-end flux: the " "biggest movers + headline totals are fed to the model, which returns a " "4-6 sentence narrative of the drivers and what needs follow-up. " "``statement`` = 'BS'|'PL', ``period`` 'YYYY-MM', ``mode`` 'mom'|'yoy'. " "Falls back to a deterministic numbers-grounded summary when no LLM key " "is configured. Read-only (nothing persisted); finance-view role." ), ) async def get_finance_flux_ai_summary( period: str, statement: str, mode: str = "mom", ) -> dict: async with tool_runtime( "get_finance_flux_ai_summary", {"period": period, "statement": statement, "mode": mode}, ) as rt: from app.domains.finance_flux import ai_service _require_finance_view(rt) return serialize(await ai_service.generate_statement_summary( rt.db, period_key=period, statement=statement, mode=mode)) @mcp_server.tool( name="list_finance_flux_periods", description=( "List the accounting periods whose NetSuite consolidated reports have " "been imported into the flux module, newest first, with sync status. " "Use this to discover which periods get_finance_flux_summary / " "get_finance_flux_statement can be called for. Read-only; finance-view role." ), ) async def list_finance_flux_periods() -> dict: async with tool_runtime("list_finance_flux_periods", {}) as rt: from sqlalchemy import select as _select from app.domains.finance_flux.models import FinanceFluxPeriod _require_finance_view(rt) rows = (await rt.db.execute( _select(FinanceFluxPeriod).order_by(FinanceFluxPeriod.period_key.desc()) )).scalars().all() return serialize({"periods": [{ "period_key": p.period_key, "label": p.label, "is_closed": p.is_closed, "sync_status": p.sync_status, } for p in rows]}) @mcp_server.tool( name="finance_budget_status", description=( "Budget-vs-actual for a fiscal year as of a month: every budget " "(scoped by cost_type/department/cost_center) with its annual " "ceiling, YTD actual + YTD-prorated budget, variance, burn %, " "run-rate year-end forecast, projected over/under, and status " "(ok/warning/over), plus portfolio totals and over/warning counts. " "``fiscal_year`` int (maps to the org's fiscal window, default Apr→Mar); " "``as_of_period`` 'YYYY-MM' (default = current month clamped to the " "fiscal year's last month). The plan side of the spend ledger. " "Read-only; finance-view role." ), ) async def finance_budget_status(fiscal_year: int, as_of_period: str | None = None) -> dict: async with tool_runtime( "finance_budget_status", {"fiscal_year": fiscal_year, "as_of_period": as_of_period}, ) as rt: from app.domains.finance import budget_service from app.domains.finance import service as fsvc _require_finance_view(rt) as_of = fsvc.validate_period(as_of_period) if as_of_period else None return serialize(await budget_service.budget_vs_actual( rt.db, fiscal_year, as_of, withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_acknowledge_finding", description=( "Triage a cost-governance finding so it stops re-nagging: set " "``status`` = acknowledged / snoozed / resolved for one finding " "identified by ``kind`` (e.g. 'savings_seat') + ``ref`` (the " "finding's stable key, taken from the finding's ``ref`` field). " "``snooze_until`` (ISO date) is required when status=snoozed. " "Idempotent upsert. Finance-controller / global-admin only." ), ) async def finance_acknowledge_finding( kind: str, ref: str, status: str, snooze_until: str | None = None, note: str | None = None, ) -> dict: async with tool_runtime( "finance_acknowledge_finding", {"kind": kind, "ref": ref, "status": status}, mutates=True, ) as rt: from datetime import datetime from app.core.exceptions import ForbiddenError from app.core.rbac import can_operate_finance from app.domains.finance import finding_service if not can_operate_finance(list(rt.user.role_names or [])): raise ForbiddenError("Finding triage requires finance / global_admin role.") su = datetime.fromisoformat(snooze_until) if snooze_until else None row = await finding_service.set_ack( rt.db, kind=kind, ref=ref, status=status, snooze_until=su, note=note, actor_id=str(rt.user.id), ) return {"id": row.id, "status": row.status} @mcp_server.tool( name="finance_my_budgets", description=( "Budget-vs-actual for budgets the CURRENT user owns (the " "accountability view for a cost-centre / department owner): each " "owned budget's annual ceiling, YTD actual, burn %, year-end " "forecast and status. Self-scoped — any user, returns only their " "own budgets (no finance role required). ``fiscal_year`` int (maps to " "the org's fiscal window, default Apr→Mar); ``as_of_period`` 'YYYY-MM' " "(default = current month clamped to the fiscal year's last month). " "Read-only." ), ) async def finance_my_budgets(fiscal_year: int, as_of_period: str | None = None) -> dict: async with tool_runtime( "finance_my_budgets", {"fiscal_year": fiscal_year, "as_of_period": as_of_period}, ) as rt: from app.domains.finance import budget_service from app.domains.finance import service as fsvc as_of = fsvc.validate_period(as_of_period) if as_of_period else None return serialize(await budget_service.budget_vs_actual( rt.db, fiscal_year, as_of, owner_person_id=str(rt.user.id), withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_bu_costs", description=( "Per-BU (一级部门) fiscal-year cost across the four consumption axes " "(cloud / software / ai / hardware) measured against budget: each " "BU's YTD actual + per-axis breakdown, annual + YTD-prorated budget, " "run-rate year-end forecast, projected variance, pace %, and status. " "``fiscal_year`` int (maps to the org's fiscal window, default " "Apr→Mar); ``as_of_period`` 'YYYY-MM' (default = current month clamped " "to FY end); ``hardware_basis`` 'depreciation' (default, accrual) | " "'cash' (purchase outflow). The finance / leadership cost lens. " "Read-only; finance-view role." ), ) async def finance_bu_costs( fiscal_year: int, as_of_period: str | None = None, hardware_basis: str = "depreciation", ) -> dict: async with tool_runtime( "finance_bu_costs", {"fiscal_year": fiscal_year, "as_of_period": as_of_period, "hardware_basis": hardware_basis}, ) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) as_of = fsvc.validate_period(as_of_period) if as_of_period else None return serialize(await read_service.bu_fiscal_year_costs( rt.db, fiscal_year, as_of_period=as_of, hardware_basis=hardware_basis, withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_spend_pivot", description=( "Pivot the unified spend ledger by 1-3 dimensions over a single " "month (``period``) or a month RANGE (``start`` + ``end``, " "YYYY-MM, inclusive, <=36 months) — use the range form for " "'这些供应商/软件这段时间分别一共花了多少' instead of calling this " "once per month and adding the numbers yourself. ``group_by`` is " "comma-separated from {cost_type, basis, department, cost_center, " "vendor, sub_category, owner, allocation_source}. Optional " "filters: ``cost_type`` (csv), ``basis``, ``department`` (rolls up " "subtree), ``cost_center``, ``vendor`` (single name or a LIST of " "names — exact match; get real values from " "finance_spend_vendors). NULL department/cost_center fold into the " "explicit '__unallocated__' bucket. Amounts are USD, normalised at " "each month's frozen FX rate, so summing months is sound; " "``provisional_periods`` in the response names any month in the " "range that is not closed yet (its numbers can still be " "restated). NOTE this ledger counts AI-category software under " "``cost_type='ai'``, not ``software`` — for the native-currency, " "per-software-catalog view use software_cost_range_summary " "instead, and do not cross-check the two totals. Read-only; " "finance-view role." ), ) async def finance_spend_pivot( period: str | None = None, start: str | None = None, end: str | None = None, group_by: str = "cost_type", cost_type: str | None = None, basis: str | None = None, department: str | None = None, cost_center: str | None = None, vendor: str | list[str] | None = None, ) -> dict: async with tool_runtime( "finance_spend_pivot", {"period": period, "start": start, "end": end, "group_by": group_by, "cost_type": cost_type, "basis": basis, "department": department, "cost_center": cost_center, "vendor": vendor}, ) as rt: from app.domains.finance import read_service _require_finance_view(rt) dims = [d.strip() for d in (group_by or "cost_type").split(",") if d.strip()] cts = [c.strip() for c in cost_type.split(",")] if cost_type else None filters = {"cost_type": cts, "basis": basis, "department": department, "cost_center": cost_center, "vendor": _finance_vendor_filter(vendor), "withhold_cloud": _mcp_withhold_cloud(rt)} return serialize(await read_service.pivot( rt.db, _finance_periods(period, start, end), dims, filters, )) @mcp_server.tool( name="finance_spend_vendors", description=( "List the vendors that actually spent in a month (``period``) or a " "month range (``start`` + ``end``), largest USD first — the " "resolver for finance_spend_pivot's ``vendor`` filter, which is " "EXACT match (a half-remembered name silently matches nothing). " "Honours the same ``cost_type`` / ``basis`` / ``department`` / " "``cost_center`` filters as the pivot, so ``cost_type='software'`` " "lists exactly the software vendors that billed. Capped at 500 " "options with an explicit ``truncated`` flag. 1:1 with GET " "/finance/spend/vendors. Read-only; finance-view role." ), ) async def finance_spend_vendors( period: str | None = None, start: str | None = None, end: str | None = None, cost_type: str | None = None, basis: str | None = None, department: str | None = None, cost_center: str | None = None, owner: str | None = None, ) -> dict: async with tool_runtime( "finance_spend_vendors", {"period": period, "start": start, "end": end, "cost_type": cost_type, "basis": basis, "department": department, "cost_center": cost_center, "owner": owner}, ) as rt: from app.domains.finance import read_service _require_finance_view(rt) cts = [c.strip() for c in cost_type.split(",")] if cost_type else None # ``owner`` is part of "the same filters as the pivot" the # resolver's docstring promises (#2190) — without it the option # list is wider than the result set. filters = {"cost_type": cts, "basis": basis, "department": department, "cost_center": cost_center, "owner": owner, "withhold_cloud": _mcp_withhold_cloud(rt)} return serialize(await read_service.vendors_in_range( rt.db, _finance_periods(period, start, end), filters, )) @mcp_server.tool( name="finance_spend_movers", description=( "Top month-over-month spend changes (increases & decreases) for " "a month by dimension (default 'department'). Answers 'what " "changed / is spend healthy'. ``dim`` ∈ {cost_type, department, " "cost_center, vendor, sub_category, owner}. Read-only; " "finance-view role." ), ) async def finance_spend_movers(period: str, dim: str = "department", top: int = 10) -> dict: async with tool_runtime( "finance_spend_movers", {"period": period, "dim": dim, "top": top}, ) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) t = max(1, min(int(top), 50)) return serialize(await read_service.movers( rt.db, fsvc.validate_period(period), dim, top=t, withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_reconcile_internal", description=( "Verify the unified finance ledger agrees with every source " "domain for a month (cloud vs cost_facts, ai vs AI dashboard, " "software/procurement native sums). Returns per-cost-type match " "booleans + an ``all_match`` flag — the trust gate before relying " "on the numbers. Read-only; finance / global-admin role." ), ) async def finance_reconcile_internal(period: str) -> dict: async with tool_runtime("finance_reconcile_internal", {"period": period}) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_operate_finance from app.domains.finance import service as fsvc # NOT ``_require_finance_view`` (2026-07-29) — that mirrors # ``can_view_finance``, which admits auditor / management, and the # payload carries 实付-basis cloud spend at (company × month) grain. # Must stay in lockstep with the HTTP twin's gate in # ``finance/router.py::reconcile_internal``; the reasoning is written # out there. if not can_operate_finance(list(rt.user.role_names or [])): raise ForbiddenError( "internal reconciliation requires the finance / global-admin role", ) return serialize(await fsvc.reconcile_internal(rt.db, period)) @mcp_server.tool( name="finance_spend_completeness", description=( "Trust panel for a month: per-source row counts + freshness " "(last computed_at), provisional/closed state, and any logged " "restatements (closed-period total movements). Tells finance " "whether the month's number can be relied on. Read-only; " "finance-view role." ), ) async def finance_spend_completeness(period: str) -> dict: async with tool_runtime("finance_spend_completeness", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize(await read_service.completeness( rt.db, fsvc.validate_period(period), withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_spend_digest", description=( "A deterministic monthly finance narrative for a month: total + " "MoM, cost-type mix, top departments, biggest mover, unallocated " "warning, provisional/closed note. Composed from the same " "aggregates the dashboard shows (no LLM drift). Read-only; " "finance-view role." ), ) async def finance_spend_digest(period: str) -> dict: async with tool_runtime("finance_spend_digest", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize(await read_service.digest( rt.db, fsvc.validate_period(period), withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_savings_opportunities", description=( "Wasted + idle paid software seats finance can stop paying for: " "seats held by leavers / off-paid-site / inactive-vendor accounts, " "plus idle seats in the reclaim grace window — each estimated to " "monthly USD at the current FX rate. The 'money you could save' " "list. Read-only; finance-view role." ), ) async def finance_savings_opportunities() -> dict: async with tool_runtime("finance_savings_opportunities", {}) as rt: from app.core.coordinators import finance_governance_coordinator as gov _require_finance_view(rt) return serialize(await gov.savings_opportunities(rt.db)) @mcp_server.tool( name="finance_contract_renewals", description=( "Active procurement contracts approaching renewal within " "``within_days`` (default 90), with contracted value in USD and " "days-left — the forward-commitment calendar. Read-only; " "finance-view role." ), ) async def finance_contract_renewals(within_days: int = 90) -> dict: async with tool_runtime("finance_contract_renewals", {"within_days": within_days}) as rt: from app.core.coordinators import finance_governance_coordinator as gov _require_finance_view(rt) d = max(1, min(int(within_days), 730)) return serialize(await gov.contract_renewals(rt.db, within_days=d)) @mcp_server.tool( name="finance_contract_vs_actual", description=( "Per active contract: contracted amount vs actual received/" "completed PO spend to date (USD) + utilisation %. Reconciles " "'did we use what we signed for'. Read-only; finance-view role." ), ) async def finance_contract_vs_actual() -> dict: async with tool_runtime("finance_contract_vs_actual", {}) as rt: from app.core.coordinators import finance_governance_coordinator as gov _require_finance_view(rt) return serialize(await gov.contract_vs_actual(rt.db)) @mcp_server.tool( name="finance_external_reconcile", description=( "Reconcile internal computed spend vs external actual spend " "(from 汇联易 费控/报销) for a month, by dimension " "(cost_center / department / vendor). Returns per-key internal vs " "external USD + diff + the external chain rollup (docs / invoices " "verified / paid). Large diffs flag where the two systems " "disagree. Read-only; finance-view role." ), ) async def finance_external_reconcile(period: str, dim: str = "cost_center") -> dict: async with tool_runtime("finance_external_reconcile", {"period": period, "dim": dim}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize(await read_service.external_reconcile(rt.db, fsvc.validate_period(period), dim)) @mcp_server.tool( name="finance_external_statements", description=( "Browse raw external actual-spend lines (汇联易 expense / " "reimbursement / invoice) for a month — filter by cost_center / " "vendor / promoted (whether the line is promoted into the unified " "ledger as 'expense' vs reconciliation-only). Read-only; " "finance-view role." ), ) async def finance_external_statements( period: str, cost_center: str | None = None, vendor: str | None = None, promoted: bool | None = None, limit: int = 100, ) -> dict: async with tool_runtime( "finance_external_statements", {"period": period, "cost_center": cost_center, "vendor": vendor, "promoted": promoted, "limit": limit}, ) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) lim = max(1, min(int(limit), 1000)) return serialize(await read_service.external_statements( rt.db, fsvc.validate_period(period), cost_center=cost_center, vendor=vendor, promoted=promoted, limit=lim, )) @mcp_server.tool( name="finance_cost_center_coverage", description=( "Authoritative cost-centre coverage for a month: spend booked to " "cost centres not in the 汇联易 master (orphans / attribution " "gaps), unused master centres, and the % of spend mapping to a " "known code. Read-only; finance-view role." ), ) async def finance_cost_center_coverage(period: str) -> dict: async with tool_runtime("finance_cost_center_coverage", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize(await read_service.cost_center_coverage( rt.db, fsvc.validate_period(period), withhold_cloud=_mcp_withhold_cloud(rt), )) @mcp_server.tool( name="finance_expense_hygiene", description=( "Reimbursement-hygiene findings for a month from the external " "(汇联易) statements: duplicate invoice numbers across documents " "(possible double submission) and lines marked PAID without a " "verified invoice. Read-only; finance-view role." ), ) async def finance_expense_hygiene(period: str) -> dict: async with tool_runtime("finance_expense_hygiene", {"period": period}) as rt: from app.domains.finance import read_service from app.domains.finance import service as fsvc _require_finance_view(rt) return serialize(await read_service.expense_governance(rt.db, fsvc.validate_period(period))) @mcp_server.tool( name="cost_resolve_resource_creator", description=( "Resolve 'who created this cloud resource' via the provider's " "audit log (CloudTrail / Cloud Audit Logs / ActionTrail / " "Activity Log). Returns cached attribution row when available " "(no API call); fetches + persists on cache miss. " "``refresh=true`` forces a fresh audit-API call regardless of " "cache — use sparingly (rate-limited at the provider). " "Audit-window caveats: AWS / Aliyun / Azure 90 days (older " "resources return ``status='not_found'`` or " "``status='window_exceeded'``); GCP 400 days. Returns ``status`` " "+ ``source`` + nested ``creator`` block (principal_id / email " "/ display_name / person_id when matched in Helix). RBAC: " "``can_view_finance`` OR ``can_read_cloud`` — same gate as the " "HTTP twin ``GET /cmdb/cost/resources/{integration_id}/" "{resource_id:path}/creator``." ), ) async def cost_resolve_resource_creator( integration_id: str, resource_id: str, refresh: bool = False, ) -> dict: # ``mutates=True``: the resolve call writes a row to # ``cmdb_resource_creators`` (the durable attribution cache that # both the cron sweep and the HTTP twin read). Without commit the # row rolls back on context exit, every call hits the audit API, # cron re-queues the same resource forever, and the per-provider # rate budget burns. The HTTP twin commits via ``get_db``'s # auto-commit; this MCP twin needs the explicit ``mutates`` flag # to follow the same path through ``tool_runtime``. The leaver- # gate consequence (offboarding users get a 403) is acceptable — # the RBAC already requires ``can_view_finance`` / ``can_read_ # cloud`` which leaver_pending users typically don't carry. async with tool_runtime( "cost_resolve_resource_creator", { "integration_id": integration_id, "resource_id": resource_id, "refresh": refresh, }, mutates=True, ) as rt: from app.core.exceptions import ( ForbiddenError as _Forbidden, ) from app.core.exceptions import ( NotFoundError as _NotFound, ) from app.core.exceptions import ( ValidationError as _Validation, ) from app.core.rbac import can_read_cloud, can_view_finance from app.domains.cmdb.resource_creator_service import ( resolve_resource_creator, ) if not ( can_view_finance(rt.user.role_names) or can_read_cloud(rt.user.role_names) ): raise _Forbidden() if not integration_id or not resource_id: raise _Validation( "integration_id and resource_id must be non-empty", ) _validate_uuid(integration_id, label="integration_id") if len(resource_id) > 512: raise _Validation("resource_id exceeds 512 chars") # Audit-API rate-limit (mirrors HTTP twin's GET /creator # cache-miss + refresh=true gating). Cache HIT is a pure # SELECT — no provider quota burn — and stays unthrottled. # Both (a) refresh=true and (b) cache-miss with refresh= # false trigger a driver.get_resource_creator call → 10/h/ # user cap via shared Redis key. PAT holders can't bypass # by switching transports because the limiter is the same # one HTTP uses. from app.core.dependencies import apply_sensitive_rate_limit from app.domains.cmdb.resource_creator_service import ( get_cached_creator, ) if refresh: await apply_sensitive_rate_limit(rt.user.id) else: cached_peek = await get_cached_creator( rt.db, integration_id, resource_id, ) if cached_peek is None: await apply_sensitive_rate_limit(rt.user.id) try: row = await resolve_resource_creator( rt.db, integration_id=integration_id, resource_id=resource_id, force=bool(refresh), ) except LookupError as exc: raise _NotFound("Integration", integration_id) from exc # Mirror the HTTP twin's audit-row write so security # auditors filtering on ``cmdb.resource_creator.lookup`` # see MCP-initiated reveals too. PII-clean (no # ``creator_email`` in details — same posture as HTTP). from app.audit.service import AuditService await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="cmdb.resource_creator.lookup", resource_type="cmdb.resource_creator", resource_id=f"{integration_id}:{resource_id}"[:128], status="success", detail={ "via": "mcp", "refresh": bool(refresh), "result_status": row.status, "result_resolved": row.status == "resolved", }, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) from app.domains.cmdb.resource_creator_service import ( serialize_resource_creator_row, ) return serialize(serialize_resource_creator_row(row)) @mcp_server.tool( name="cost_retry_resource_creator", description=( "Force-retry a stuck resource-creator attribution row: zero " "``attempt_count``, clear the force-refresh cooldown, and " "fire a fresh audit-API resolve. Use when ``status='error'`` " "AND ``attempt_count`` has hit the per-row cap (cron sweep " "excludes such rows from its candidate pool — without this " "reset they stay stuck even after the underlying cloud-side " "issue is fixed). Returns the same shape as " "``cost_resolve_resource_creator``. Idempotent. RBAC: same " "``can_view_finance`` OR ``can_read_cloud`` as the read " "path; HTTP twin is ``POST /cmdb/cost/resources/{integration_" "id}/{resource_id:path}/creator/retry``." ), ) async def cost_retry_resource_creator( integration_id: str, resource_id: str, ) -> dict: # ``mutates=True``: zeros attempt_count + last_attempt_at + # last_error then runs ``resolve_resource_creator(force=True)`` # which writes a fresh attribution row. Same rollback risk as # ``cost_resolve_resource_creator`` if mutates is forgotten — # the reset SQL would silently roll back on context exit. async with tool_runtime( "cost_retry_resource_creator", { "integration_id": integration_id, "resource_id": resource_id, }, mutates=True, ) as rt: from app.core.dependencies import apply_sensitive_rate_limit from app.core.exceptions import ( ForbiddenError as _Forbidden, ) from app.core.exceptions import ( NotFoundError as _NotFound, ) from app.core.exceptions import ( ValidationError as _Validation, ) from app.core.rbac import can_read_cloud, can_view_finance from app.domains.cmdb.resource_creator_service import ( reset_and_resolve_resource_creator, serialize_resource_creator_row, ) if not ( can_view_finance(rt.user.role_names) or can_read_cloud(rt.user.role_names) ): raise _Forbidden() if not integration_id or not resource_id: raise _Validation( "integration_id and resource_id must be non-empty", ) _validate_uuid(integration_id, label="integration_id") if len(resource_id) > 512: raise _Validation("resource_id exceeds 512 chars") # Audit-API burner — every call fires a fresh CloudTrail / # Cloud Logging / ActionTrail / Activity Log query against # the live provider. Reset clears the in-row cooldown by # design, so this is the ONLY MCP-side protection against # a compromised PAT looping the tool to drain provider # rate budget. Mirrors HTTP twin's `Depends(check_sensitive_ # endpoint_rate_limit)` — same 10/h/user cap, raised as # AppException(429) which the MCP transport surfaces to # the caller as a rate-limit error. await apply_sensitive_rate_limit(rt.user.id) try: row = await reset_and_resolve_resource_creator( rt.db, integration_id=integration_id, resource_id=resource_id, ) except LookupError as exc: raise _NotFound("Integration", integration_id) from exc from app.audit.service import AuditService await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="cmdb.resource_creator.retry", resource_type="cmdb.resource_creator", resource_id=f"{integration_id}:{resource_id}"[:128], status="success", detail={ "via": "mcp", "result_status": row.status, "result_resolved": row.status == "resolved", }, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(serialize_resource_creator_row(row)) @mcp_server.tool( name="cost_forecast", description=( "Month-end cost forecast: linear-extrapolation, trailing-14d, " "and the blend of the two. ``integration_id`` narrows to one " "tenant. Returns MTD spend + days remaining + projections. " "Read-only; requires finance-view role." ), ) async def cost_forecast( integration_id: str | None = None, ) -> dict: async with tool_runtime( "cost_forecast", {"integration_id": integration_id}, ) as rt: from app.domains.cmdb.cost_service import forecast_month_end _require_finance_view(rt) _validate_uuid(integration_id, label="integration_id") data = await forecast_month_end( rt.db, integration_id=integration_id, ) return serialize(data) @mcp_server.tool( name="cost_anomalies", description=( "Per-resource cost spikes vs the resource's own ``days``-day " "median + IQR (default 30 days, threshold 2.5×IQR). Sorted by " "absolute USD impact descending. ``integration_id`` narrows. " "``limit`` caps the response at the top-N most-impactful " "spikes (default 200, max 500) so an MCP client doesn't pull " "an unbounded result set into the LLM context. Read-only; " "requires finance-view role." ), ) async def cost_anomalies( days: int = 30, integration_id: str | None = None, min_impact_usd: float = 50.0, z_threshold: float = 2.5, limit: int = 200, ) -> dict: async with tool_runtime( "cost_anomalies", { "days": days, "integration_id": integration_id, "min_impact_usd": min_impact_usd, "z_threshold": z_threshold, "limit": limit, }, ) as rt: from app.core.exceptions import ValidationError from app.domains.cmdb.cost_service import detect_anomalies _require_finance_view(rt) if days < 7 or days > 120: raise ValidationError("days must be between 7 and 120") if z_threshold < 0.5 or z_threshold > 10.0: raise ValidationError("z_threshold must be between 0.5 and 10.0") if min_impact_usd < 0 or min_impact_usd > 1_000_000: raise ValidationError( "min_impact_usd must be between 0 and 1,000,000", ) # Round-49: matches the HTTP endpoint's ``limit`` bounds (1–500). if limit < 1 or limit > 500: raise ValidationError("limit must be between 1 and 500") _validate_uuid(integration_id, label="integration_id") data = await detect_anomalies( rt.db, days=days, integration_id=integration_id, min_impact_usd=min_impact_usd, z_threshold=z_threshold, limit=limit, ) return serialize({ "days": days, "z_threshold": z_threshold, "min_impact_usd": min_impact_usd, "limit": limit, "anomalies": data, }) @mcp_server.tool( name="cost_subordinates_breakdown", description=( "Manager-scoped cost breakdown for the caller PLUS every " "direct/transitive report (BFS over identity_persons." "manager_person_id, skipping employment_status='left'). " "Mirrors the GET /api/v1/cmdb/cost/subordinates HTTP endpoint " "and powers the /my/cloud/department-cost page. Returns " "``total_usd`` + per-person / per-service / per-department " "breakdowns over the date window (default last 30 days). " "``integration_id`` narrows to one cloud tenant. No finance-" "view role required — the response is by construction scoped " "to the caller's own management tree, so a non-manager simply " "sees their own allocated facts. Read-only." ), ) async def cost_subordinates_breakdown( start: str | None = None, end: str | None = None, integration_id: str | None = None, ) -> dict: async with tool_runtime( "cost_subordinates_breakdown", { "start": start, "end": end, "integration_id": integration_id, }, ) as rt: from datetime import UTC from datetime import datetime as _dt from datetime import timedelta as _td from app.core.exceptions import ForbiddenError from app.domains.asset.router import _resolve_subordinate_ids from app.domains.cmdb.cost_service import ( subordinates_cost_breakdown, ) from app.identity.person_models import IdentityPerson # Mirror the HTTP route's leaver guard — a still-active PAT # belonging to a left employee must not be able to pull # historical cost facts via the MCP surface. Matches the # ``is_active_employee`` convention (cost_router.py:678): BOTH # ``left`` and ``leaver_pending`` are refused — the MCP version # used to miss ``leaver_pending`` and let a leaver-pending # employee with a still-valid PAT pull their reporting tree's # cost data after their notice was filed but before their last # working day. (R1 logic finding 2026-05-28.) caller_person = await rt.db.get(IdentityPerson, rt.user.id) if ( caller_person is None or caller_person.employment_status in ("left", "leaver_pending") ): raise ForbiddenError() _validate_uuid(integration_id, label="integration_id") end_dt = ( _parse_iso_date(end, field="end") if end else (_dt.now(UTC).date() - _td(days=1)) ) start_dt = ( _parse_iso_date(start, field="start") if start else (end_dt - _td(days=29)) ) _validate_cost_window(start_dt, end_dt) person_ids = await _resolve_subordinate_ids(rt.db, rt.user.id) data = await subordinates_cost_breakdown( rt.db, person_ids=person_ids, start=start_dt, end=end_dt, integration_id=integration_id, ) return serialize({ "start": start_dt.isoformat(), "end": end_dt.isoformat(), "scope_person_count": len(person_ids), **data, }) @mcp_server.tool( name="cost_timeseries", description=( "Daily cost series broken out by one dimension. ``group_by`` ∈ " "{service, resource_group, resource_id, region, resource_type, " "cost_center, department, owner, integration}. Returns top-N " "series + 'other' bucket + daily totals, zero-filled so plotting " "is a direct map. Default window = 30 days. ``integration_id`` " "narrows. Read-only; finance-view role required." ), ) async def cost_timeseries( group_by: str, start: str | None = None, end: str | None = None, integration_id: str | None = None, top: int = 10, ) -> dict: async with tool_runtime( "cost_timeseries", { "group_by": group_by, "start": start, "end": end, "integration_id": integration_id, "top": top, }, ) as rt: from datetime import UTC from datetime import datetime as _dt from datetime import timedelta as _td from app.core.exceptions import ValidationError from app.domains.cmdb.cost_service import ( GROUP_BY_COLUMNS, timeseries, ) _require_finance_view(rt) if group_by not in GROUP_BY_COLUMNS: raise ValidationError( f"unknown group_by={group_by!r}; " f"supported: {sorted(GROUP_BY_COLUMNS)}", ) if top < 1 or top > 50: raise ValidationError("top must be between 1 and 50") _validate_uuid(integration_id, label="integration_id") end_dt = ( _parse_iso_date(end, field="end") if end else (_dt.now(UTC).date() - _td(days=1)) ) start_dt = ( _parse_iso_date(start, field="start") if start else (end_dt - _td(days=29)) ) _validate_cost_window(start_dt, end_dt) result = await timeseries( rt.db, start=start_dt, end=end_dt, group_by=group_by, integration_id=integration_id, top=top, ) return serialize({ "start": start_dt.isoformat(), "end": end_dt.isoformat(), "group_by": group_by, **result, }) @mcp_server.tool( name="cost_allocation_rules_list", description=( "List the chargeback allocation-rule ladder used to stamp " "owner_person_id / owner_department / cost_center on every " "ingested cost_fact. Rules are ordered by priority ascending " "(lowest priority number wins on a tie at ingest time). " "Read-only; finance-view role required." ), ) async def cost_allocation_rules_list() -> dict: async with tool_runtime("cost_allocation_rules_list", {}) as rt: from app.domains.cmdb.cost_service import list_allocation_rules _require_finance_view(rt) rules = await list_allocation_rules(rt.db) return serialize({"rules": rules}) @mcp_server.tool( name="cost_recompute_allocation", description=( "Re-evaluate the allocation-rule ladder against the last " "``days`` of facts (default 90, max 365). Used after editing a " "rule — without this, only future ingests pick up the change. " "Returns ``{updated, matched}``. Mutating action — leaver-" "pending users are blocked. Manage-cloud role required." ), ) async def cost_recompute_allocation(days: int = 90) -> dict: async with tool_runtime( "cost_recompute_allocation", {"days": days}, mutates=True, ) as rt: from app.core.distributed_lock import LockBusy, distributed_lock from app.core.exceptions import ( AppException, ForbiddenError, ValidationError, ) from app.core.rbac import can_manage_cloud from app.domains.cmdb.cost_service import recompute_allocation if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "cost_recompute_allocation requires manage_cloud role", ) if days < 1 or days > 365: raise ValidationError("days must be between 1 and 365") # Single-flight: two concurrent recompute calls would both walk # the rule ladder and last-writer-wins on each fact's # ``allocation_rule_id`` — non-deterministic on equal-priority # rules and burns DB cycles re-stamping the same rows. AI # agents are particularly likely to retry idempotent-looking # operations on timeout. ttl matches the cron handler + HTTP # endpoint (1800s = the scheduled-job hard-timeout ceiling) so # the lock outlives the longest possible recompute and a TTL # shorter than the actual work duration can never let a # "concurrent" caller bypass the lock exactly when correctness # matters most. The old 600s value was sized against a stale # "5-10 min" estimate and could expire mid-run on a large # re-stamp. try: async with distributed_lock( "cost_recompute_allocation", ttl=1800, ): result = await recompute_allocation(rt.db, days=days) except LockBusy as exc: raise AppException( code="COST_RECOMPUTE_BUSY", message=( "Another recompute is in progress. Retry once it " "finishes (typical run: <1 minute)." ), status_code=409, ) from exc return serialize(result) @mcp_server.tool( name="cost_backfill", description=( "Trigger an out-of-band cost-facts ingestion for the given " "integration over ``[start, end]`` (≤ 365 days). Same single-" "flight lock as the cron — returns 409 (COST_FACTS_SYNC_BUSY) " "if the 04:xx tick or another backfill is in progress. " "Mutating action. Manage-cloud role required." ), ) async def cost_backfill( integration_id: str, start: str, end: str, ) -> dict: async with tool_runtime( "cost_backfill", {"integration_id": integration_id, "start": start, "end": end}, mutates=True, ) as rt: from app.core.distributed_lock import LockBusy, distributed_lock from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_cloud from app.domains.cmdb.cost_service import ( sync_cost_facts_for_integration, ) if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "cost_backfill requires manage_cloud role", ) _validate_uuid(integration_id, label="integration_id") # ``_parse_iso_date`` raises a typed VALIDATION_ERROR with a # bounded ``_short_repr`` of the offending value — bare # ``date.fromisoformat`` echoes the raw input verbatim into # the LLM context and the audit row. start_dt = _parse_iso_date(start, field="start") end_dt = _parse_iso_date(end, field="end") _validate_cost_window(start_dt, end_dt) _validate_backfill_floor(start_dt) try: # Per-integration lock key — same as the HTTP backfill # endpoint and the cron handler. Pre-fix the MCP tool used # ``sched_job:cmdb.cost_facts_sync`` which was the OLD # handler-wide lock; while harmless on its own, it would # collide with the new per-integration lock above and # let two MCP tool invocations on the SAME integration # bypass the integration's serialization. Aligns the three # call paths (HTTP / MCP / cron) on one key. async with distributed_lock( f"cloud_sync:cost_facts:{integration_id}", ttl=900, ): result = await sync_cost_facts_for_integration( integration_id, start_date=start_dt, end_date=end_dt, ) except LockBusy as exc: raise AppException( code="COST_FACTS_SYNC_BUSY", message=( "Another cost_facts run is in progress. Retry once " "the cron tick or running backfill finishes." ), status_code=409, ) from exc return serialize(result) # ════════════════════════════════════════════════════════════════════ # Software seat self-cancel (next-month boundary, migration 096) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="cancel_my_seat_next_month", description=( "Schedule one of YOUR software seats for cancellation at the " "end of the current billing period (start of next month UTC). " "Idempotent — re-submitting updates the reason. The seat keeps " "working until the boundary, then a cron flips it to revoked. " "You can only cancel seats whose user_id matches you; admins " "can cancel anyone's. Use undo_my_seat_cancellation to back out." ), ) async def cancel_my_seat_next_month( allocation_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "cancel_my_seat_next_month", {"allocation_id": allocation_id, "reason": reason}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — written verbatim into # ``alloc.cancel_reason`` (TEXT). (R1 sweep: Class C cap.) reason = _bound_free_text(reason, field="reason", max_len=2000) from datetime import UTC from datetime import datetime as _dt from app.audit.service import AuditService from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_assets from app.domains.asset.models import SoftwareSeatAllocation from app.domains.asset.router import _start_of_next_month_utc # SELECT ... FOR UPDATE: two MCP calls (or MCP + UI) racing on # the same allocation must NOT both pass the status check and # both write — that produces duplicate audit rows and last- # writer-wins on cancel_requested_at / cancel_reason. Found in # the launch-readiness audit 2026-05-05. alloc = await rt.db.get( SoftwareSeatAllocation, _u(allocation_id), with_for_update=True, ) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) # Order: ownership BEFORE state. Reversing these would let a # caller probe whether a stranger's allocation is `active` # (400 SEAT_NOT_ACTIVE) vs anything else (403) — a status # oracle. Tighter than the HTTP route, but the HTTP route is # only hit by the React UI which already loads the row before # offering the cancel button, whereas an MCP/LLM client could # iterate raw IDs. is_admin = can_manage_assets(rt.user.role_names) is_self = alloc.user_id == rt.user.id if not (is_admin or is_self): raise ForbiddenError( "You can only cancel your own seat allocation.", ) # Self-cancel policy gate — parity with the HTTP route. IT-managed # identity software (Google / Feishu) is marked # ``allow_self_cancel=False`` on its catalog row; the holder can't # drop their own seat, admins can on their behalf. if is_self and not is_admin: from app.domains.asset.models import ( AssetSoftwareCatalog, SoftwareLicensePlan, ) plan_for_policy = await rt.db.get( SoftwareLicensePlan, alloc.plan_id, ) catalog_for_policy = ( await rt.db.get( AssetSoftwareCatalog, plan_for_policy.software_id, ) if plan_for_policy is not None else None ) if ( catalog_for_policy is not None and catalog_for_policy.allow_self_cancel is False ): raise ForbiddenError( "This software is managed by IT and cannot be " "cancelled by you. Contact IT if you no longer need " "this access.", ) if alloc.status != "active": # Don't echo the status verbatim — even with ownership # ordered first, the field may carry signal (active vs # pending_cancellation vs revoked) that an admin caller # has rights to see but a self caller may not need. raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is not active; only active seats can be " "scheduled for cancellation." ), status_code=400, ) now = _dt.now(UTC) boundary = _start_of_next_month_utc(now) alloc.cancel_at_period_end = boundary alloc.cancel_requested_at = now alloc.cancel_requested_by = rt.user.id alloc.cancel_reason = reason or alloc.cancel_reason await rt.db.flush() await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action=( "software_seat.cancel_next_month_self" if is_self else "software_seat.cancel_next_month_admin" ), resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), "cancel_at_period_end": boundary.isoformat(), "reason": alloc.cancel_reason, }, ) return { "id": str(alloc.id), "status": alloc.status, "cancel_at_period_end": boundary.isoformat(), "cancel_requested_at": now.isoformat(), "cancel_requested_by": str(rt.user.id), "cancel_reason": alloc.cancel_reason, } @mcp_server.tool( name="undo_my_seat_cancellation", description=( "Undo a previously scheduled 'cancel next month' on a seat you " "hold. No-op (200) if there's nothing pending — safe to retry." ), ) async def undo_my_seat_cancellation(allocation_id: str) -> dict: async with tool_runtime( "undo_my_seat_cancellation", {"allocation_id": allocation_id}, mutates=True, ) as rt: from app.audit.service import AuditService from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_assets from app.domains.asset.models import SoftwareSeatAllocation # SELECT ... FOR UPDATE: see cancel_my_seat_next_month for the # rationale. Two undos racing on the same allocation must # serialize so audit rows stay coherent. alloc = await rt.db.get( SoftwareSeatAllocation, _u(allocation_id), with_for_update=True, ) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) is_admin = can_manage_assets(rt.user.role_names) is_self = alloc.user_id == rt.user.id if not (is_admin or is_self): raise ForbiddenError( "You can only undo your own pending seat cancellation.", ) # Race guard mirrors the HTTP route: the auto-revoke cron at # 00:05 UTC on the 1st flips status='active' → 'revoked' but # leaves cancel_* fields populated. Without this check, a user # whose seat the cron just revoked would get a silent 200 here # while their access stays gone — surface the truth instead. if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is no longer active — the scheduled cancellation " "has already been applied. Submit a new seat request " "if you still need access." ), status_code=409, ) had_pending = alloc.cancel_at_period_end is not None alloc.cancel_at_period_end = None alloc.cancel_requested_at = None alloc.cancel_requested_by = None alloc.cancel_reason = None await rt.db.flush() if had_pending: await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action=( "software_seat.cancel_undo_self" if is_self else "software_seat.cancel_undo_admin" ), resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), }, ) return {"id": str(alloc.id), "status": alloc.status} @mcp_server.tool( name="keep_my_idle_seat", description=( "Keep an idle-warned software seat so the idle-reclaim job won't " "revoke it. When the software_seat.idle_reclaim job warns that one " "of YOUR Atlassian (Jira/JSM/Confluence) seats has gone unused, " "call this to extend it for the configured keep window (default " "180 days). Clears the warning so a future idle stretch gets a " "fresh warning + full grace buffer. You can only keep your own " "seat; admins can keep anyone's. Idempotent — re-calling just " "extends the window. Only active seats can be kept." ), ) async def keep_my_idle_seat( allocation_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "keep_my_idle_seat", {"allocation_id": allocation_id, "reason": reason}, mutates=True, ) as rt: # Cap free-text at the MCP boundary — flows into audit detail # JSON without an upstream Pydantic length validator. (R1 # sweep: Class C cap.) reason = _bound_free_text(reason, field="reason", max_len=2000) from datetime import UTC from datetime import datetime as _dt from datetime import timedelta as _td from app.audit.service import AuditService from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_assets from app.domains.asset.models import SoftwareSeatAllocation from app.domains.asset.router import _resolve_idle_keep_days # SELECT ... FOR UPDATE so a UI click + MCP call racing on the # same allocation serialize (coherent audit rows, deterministic # kept_until). Mirrors cancel_my_seat_next_month. alloc = await rt.db.get( SoftwareSeatAllocation, _u(allocation_id), with_for_update=True, ) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) # Ownership BEFORE state (status oracle hardening — same as the # cancel tool). is_admin = can_manage_assets(rt.user.role_names) is_self = alloc.user_id == rt.user.id if not (is_admin or is_self): raise ForbiddenError( "You can only keep your own seat allocation.", ) if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is not active; only an active seat can be kept. " "If it was already reclaimed, submit a new seat request." ), status_code=400, ) keep_days = await _resolve_idle_keep_days(rt.db) now = _dt.now(UTC) kept_until = now + _td(days=keep_days) alloc.idle_kept_until = kept_until alloc.idle_warned_at = None alloc.idle_manual_review_at = None await rt.db.flush() await AuditService(rt.db).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action=( "software_seat.idle_keep_self" if is_self else "software_seat.idle_keep_admin" ), resource_type="software_seat_allocation", resource_id=str(alloc.id), status="success", detail={ "plan_id": str(alloc.plan_id), "target_user_id": str(alloc.user_id), "idle_kept_until": kept_until.isoformat(), "keep_days": keep_days, "reason": reason, }, ) return { "id": str(alloc.id), "status": alloc.status, "idle_kept_until": kept_until.isoformat(), "keep_days": keep_days, } @mcp_server.tool( name="admin_change_seat_plan", description=( "Admin tier-change for an existing seat allocation. Atomically " "revokes the current seat and creates a new active seat on the " "target plan, under one trace_id. Constraints: the new plan must " "belong to the SAME software (no Zoom→Slack swaps), must use the " "same currency, and the user must not already hold an active " "seat on the new plan. REFUSED for software whose offboarding " "policy is ``must_transfer`` (Google Workspace user_suspend, " "Zoom delete with hosted recordings) — those would orphan " "vendor-side data without a successor; use revoke + grant with " "an explicit successor instead. Atomicity: a vendor-grant " "enqueue failure rolls everything back so the user retains " "their original seat (``CHANGE_PLAN_GRANT_ENQUEUE_FAILED`` " "503). Vendor-side change is queued; the Zoom driver receives " "``allow_demote=true`` so a Licensed→Basic PATCH is permitted " "(the regular grant path refuses it). Pairs with a " "``software_seat.plan_changed`` audit row capturing from/to " "plan ids + reason. Asset/global admin only — matches HTTP " "POST /api/v1/assets/seat-allocations/{id}/change-plan." ), ) async def admin_change_seat_plan( allocation_id: str, new_plan_id: str, reason: str, ) -> dict: async with tool_runtime( "admin_change_seat_plan", { "allocation_id": allocation_id, "new_plan_id": new_plan_id, "reason": reason, }, mutates=True, ) as rt: from datetime import UTC from datetime import datetime as _dt from app.audit.service import AuditService from app.core.coordinators.provisioning_coordinator import ( check_plan_quota, enqueue_grant_for_seat, enqueue_revoke_for_seat, ) from app.core.exceptions import AppException from app.domains.asset.models import ( SoftwareLicensePlan, SoftwareSeatAllocation, ) from app.domains.provisioning.models import ( SOURCE_MANUAL, ProvisioningTask, ) from app.identity.person_models import IdentityPerson _require_asset_admin(rt) if not reason or not reason.strip(): raise AppException( code="VALIDATION_ERROR", message="`reason` is required for an audit-traceable tier change.", status_code=400, ) reason = reason.strip()[:2000] alloc = await rt.db.get(SoftwareSeatAllocation, _u(allocation_id)) if alloc is None: raise AppException( code="NOT_FOUND", message="Allocation not found", status_code=404, ) if alloc.status != "active": raise AppException( code="SEAT_NOT_ACTIVE", message=( "Seat is not active; only active seats can have " "their plan changed." ), status_code=400, ) old_plan = await rt.db.get(SoftwareLicensePlan, alloc.plan_id) new_plan = await rt.db.get(SoftwareLicensePlan, _u(new_plan_id)) if old_plan is None or new_plan is None: raise AppException( code="NOT_FOUND", message="License plan not found", status_code=404, ) if old_plan.id == new_plan.id: raise AppException( code="SAME_PLAN", message="new_plan_id is identical to the current plan.", status_code=400, ) if new_plan.status != "active": raise AppException( code="PLAN_INACTIVE", message="Target license plan is not active.", status_code=400, ) if new_plan.software_id != old_plan.software_id: raise AppException( code="PLAN_DIFFERENT_SOFTWARE", message=( "Tier change must stay within the same software." ), status_code=400, ) # Case-insensitive — see HTTP route for rationale. # (Round 4 logic-scan P1.) old_currency = (old_plan.currency or "CNY").upper() new_currency = (new_plan.currency or "CNY").upper() if old_currency != new_currency: raise AppException( code="PLAN_DIFFERENT_CURRENCY", message=( f"Currency mismatch: current plan is " f"{old_currency}, target is {new_currency}." ), status_code=400, ) target = await rt.db.get(IdentityPerson, alloc.user_id) if target is None or target.deleted_at is not None: raise AppException( code="NOT_FOUND", message="Seat holder not found", status_code=404, ) if target.employment_status in ("left", "leaver_pending"): raise AppException( code="TARGET_NOT_ACTIVE", message=( f"Cannot change seat for a user whose employment_status " f"is {target.employment_status!r}." ), status_code=400, ) # Refuse duplicate active seat on the target plan. from sqlalchemy import select duplicate = (await rt.db.execute( select(SoftwareSeatAllocation).where( SoftwareSeatAllocation.plan_id == new_plan.id, SoftwareSeatAllocation.user_id == alloc.user_id, SoftwareSeatAllocation.status == "active", ) )).scalars().first() if duplicate is not None: raise AppException( code="DUPLICATE_SEAT", message=( "User already holds an active seat on the target plan." ), status_code=409, ) # Refuse change-plan for must_transfer software — see HTTP # endpoint for full rationale. (Round 2 security P1.) from app.domains.asset.models import AssetSoftwareCatalog software_row = await rt.db.get( AssetSoftwareCatalog, new_plan.software_id, ) if software_row is not None and software_row.offboarding_policy == "must_transfer": raise AppException( code="CHANGE_PLAN_REQUIRES_TRANSFER", message=( "This software's offboarding policy is " "'must_transfer' — vendor-side data would be " "orphaned by a tier change. Use the revoke flow " "with a successor, then grant the new tier." ), status_code=400, ) # Refuse change-plan when the vendor uses product-access # semantics (Atlassian / Jira today) — parity with the HTTP # twin (asset/router.py change-plan endpoint, hardened # 2026-05-28 after the shengbo.ma / sunxiaoguang revoke # loop). The revoke step strips the user from EVERY # conferring group; the paired grant only re-adds the single # bound group — a "tier change" would silently destroy the # user's team / role group memberships. The MCP tool predates # that fix and was missed; closed in the 2026-06-10 launch # audit. from app.core.coordinators.provisioning_coordinator import ( _driver_supports_product_access_revoke, ) from app.domains.provisioning.models import ( SoftwareConnector as _SCRow, ) cp_connector = await rt.db.scalar( select(_SCRow).where( _SCRow.software_id == new_plan.software_id, _SCRow.is_active.is_(True), ), ) if ( cp_connector is not None and await _driver_supports_product_access_revoke( rt.db, cp_connector, ) ): raise AppException( code="CHANGE_PLAN_NOT_SUPPORTED_FOR_PRODUCT_ACCESS_VENDOR", message=( "This software's provider revokes by stripping " "every group that confers the product (Atlassian " "/ Jira). A tier change would remove the user " "from team / role groups in the revoke step and " "only re-add them to the single bound group in " "the grant step, shrinking their actual access. " "There is no per-user 'tier' for this vendor — " "explicit revoke + new grant via the dedicated " "tools if the binding really needs to change." ), status_code=400, ) await check_plan_quota(rt.db, new_plan.id) # Unique trace_id PER CALL. Two admin tier changes on the same # allocation (e.g. revoke → re-grant → re-tier) must NOT share # one trace — that conflates separate audit chains under one # ID. ``_trace_str()`` returns the per-MCP-request UUID; that # gives one chain per LLM invocation. Found in the # launch-readiness audit 2026-05-05. trace_id = _trace_str() now = _dt.now(UTC) try: revoke_task_id = await enqueue_revoke_for_seat( rt.db, seat=alloc, trace_id=trace_id, actor_user_id=rt.user.id, source=SOURCE_MANUAL, enforce_offboarding_policy=False, ) except Exception as exc: # noqa: BLE001 raise AppException( code="CHANGE_PLAN_REVOKE_ENQUEUE_FAILED", message=( "Could not enqueue the vendor-side revoke for the old " "tier. Tier change aborted; original seat unchanged." ), status_code=503, ) from exc alloc.status = "revoked" alloc.revoked_at = now alloc.reason = ( (alloc.reason or "") + f"\n[admin tier change → {new_plan.name}: {reason}]" ).strip() new_alloc = SoftwareSeatAllocation( plan_id=new_plan.id, user_id=alloc.user_id, status="active", allocated_at=now, reason=( f"admin tier change from {old_plan.name} → {new_plan.name} " f"by {rt.user.email}: {reason}" ), ) rt.db.add(new_alloc) await rt.db.flush() grant_task_id = None try: grant_task_id = await enqueue_grant_for_seat( rt.db, seat=new_alloc, trace_id=trace_id, actor_user_id=rt.user.id, ) if grant_task_id is not None: grant_task = await rt.db.get(ProvisioningTask, grant_task_id) if grant_task is not None: ctx = dict(grant_task.context_data or {}) ctx["allow_demote"] = True ctx["tier_change_from_plan_id"] = str(old_plan.id) ctx["tier_change_from_seat_id"] = str(alloc.id) ctx["tier_change_reason"] = reason[:500] grant_task.context_data = ctx except Exception as exc: # noqa: BLE001 # Same atomicity contract as the HTTP endpoint — if grant # can't be queued, do NOT leave the new seat row active + # the old seat revoked + revoke task queued. Roll back so # the user retains their original seat. (Round 2 audit P0.) await _safe_rollback(rt.db) raise AppException( code="CHANGE_PLAN_GRANT_ENQUEUE_FAILED", message=( "Could not enqueue the vendor-side grant for the " "new tier; the tier change was rolled back so the " "user still has their original seat." ), status_code=503, ) from exc await AuditService(rt.db).log( # Use the per-transition trace_id so the audit row + both # provisioning tasks correlate under one key. # (Round 2 audit P1.) trace_id=trace_id, actor_user_id=rt.user.id, actor_type="user", action="software_seat.plan_changed", resource_type="software_seat_allocation", resource_id=str(new_alloc.id), status="success", detail={ "target_user_id": str(target.id), "from_plan_id": str(old_plan.id), "from_plan_name": old_plan.name, "to_plan_id": str(new_plan.id), "to_plan_name": new_plan.name, "currency": old_currency, "from_seat_id": str(alloc.id), "to_seat_id": str(new_alloc.id), "revoke_task_id": str(revoke_task_id) if revoke_task_id else None, "grant_task_id": str(grant_task_id) if grant_task_id else None, "reason": reason[:500], }, ) return { "from_seat_id": str(alloc.id), "to_seat_id": str(new_alloc.id), "from_plan": { "id": str(old_plan.id), "name": old_plan.name, }, "to_plan": { "id": str(new_plan.id), "name": new_plan.name, }, "revoke_task_id": str(revoke_task_id) if revoke_task_id else None, "grant_task_id": str(grant_task_id) if grant_task_id else None, } # ════════════════════════════════════════════════════════════════════ # Software catalog (owner self-edit) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="update_my_software_catalog_entry", description=( "Update a software catalog row you own (you are listed as " "owner_person_id OR it_contact_person_id, OR you are an asset/" "global admin). Non-admins cannot change name, status, " "owner_person_id, or it_contact_person_id — ownership and " "identity fields are admin-only (ask an asset_admin / " "global_admin to transfer ownership). ``department`` is " "auto-derived from the owner and cannot be set by clients. " "Pass only the fields you want to change; everything else " "stays untouched. Returns the row after update.\n\n" "Set ``allow_self_cancel=false`` to mark IT-managed identity " "software (Google Workspace, Feishu, …) — employees then can't " "drop their own seat from self-service (admins still can on " "their behalf). Default true.\n\n" "**Self-lockout warning (admins)**: if you set " "`owner_person_id` to someone other than yourself AND you are " "not also `it_contact_person_id`, the previous owner loses " "edit rights on this row. Confirm the ownership change " "explicitly before transferring." ), ) async def update_my_software_catalog_entry( catalog_id: str, asset_type: str | None = None, owner_person_id: str | None = None, it_contact_person_id: str | None = None, remark: str | None = None, login_url: str | None = None, expiry_date: str | None = None, # ISO YYYY-MM-DD license_info: str | None = None, offboarding_policy: str | None = None, # 'sso_cascade'|'auto_revoke' allow_self_cancel: bool | None = None, category: str | None = None, ) -> dict: async with tool_runtime( "update_my_software_catalog_entry", { "catalog_id": catalog_id, "fields": { k: v for k, v in { "asset_type": asset_type, "owner_person_id": owner_person_id, "it_contact_person_id": it_contact_person_id, "remark": remark, "login_url": login_url, "expiry_date": expiry_date, "license_info": license_info, "offboarding_policy": offboarding_policy, "allow_self_cancel": allow_self_cancel, "category": category, }.items() if v is not None }, }, mutates=True, ) as rt: from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_assets from app.domains.asset.models import AssetSoftwareCatalog from app.domains.asset.router import ( _enrich_software_catalog, _resolve_software_department, ) from app.domains.asset.schemas import SoftwareCatalogUpdateRequest item = await rt.db.get(AssetSoftwareCatalog, _u(catalog_id)) if item is None: raise AppException( code="NOT_FOUND", message="Software catalog item not found", status_code=404, ) is_admin = can_manage_assets(rt.user.role_names) is_owner = ( item.owner_person_id == rt.user.id or item.it_contact_person_id == rt.user.id ) if not (is_admin or is_owner): raise ForbiddenError( "Only asset admins or the software's listed owner / " "IT contact can edit this row.", ) # Ownership / edit-rights fields are ADMIN-ONLY — parity with # the HTTP twin hardened in PR #527 (asset/router.py strips # ``owner_person_id`` / ``it_contact_person_id`` from non-admin # patches): a non-admin owner must not be able to transfer # ownership or delegate edit rights (``it_contact_person_id`` # is an edit bypass) without admin involvement. The MCP tool # predates #527 and still exposed the transfer — closed in the # 2026-06-10 launch audit. Unlike HTTP (silent strip), we # raise an explicit 403 so an LLM client doesn't report a # transfer as successful when nothing changed; echoing the # CURRENT value back is tolerated as a no-op so clients that # round-trip the full row don't break. if not is_admin: if ( owner_person_id is not None and _u(owner_person_id) != item.owner_person_id ): raise ForbiddenError( "Changing owner_person_id is admin-only. Ask an " "asset_admin / global_admin to transfer ownership.", ) if ( it_contact_person_id is not None and _u(it_contact_person_id) != item.it_contact_person_id ): raise ForbiddenError( "Changing it_contact_person_id is admin-only. Ask " "an asset_admin / global_admin to delegate edit " "rights.", ) # Self-lockout guard — with ownership changes now admin-only, # a non-admin's only surviving path here is a no-op echo, so # this can no longer trip in practice. Kept as defense-in- # depth should the admin-only gate above ever be relaxed. if not is_admin: new_owner_person_id = ( _u(owner_person_id) if owner_person_id is not None else item.owner_person_id ) new_it_contact_person_id = ( _u(it_contact_person_id) if it_contact_person_id is not None else item.it_contact_person_id ) still_has_edit_rights = ( new_owner_person_id == rt.user.id or new_it_contact_person_id == rt.user.id ) if not still_has_edit_rights: raise ForbiddenError( "This update would remove you as both owner and IT " "contact, locking you out of further edits. Ask an " "asset_admin / global_admin to perform the change " "instead.", ) # Funnel the args through the SAME Pydantic update model the # HTTP route uses. Pydantic coerces ISO date strings into a # ``date`` object itself, so passing the raw string surfaces a # clean 422 VALIDATION_ERROR on bad input rather than letting a # raw `_date.fromisoformat` ValueError fall through to a 500. # ``department`` and the admin-locked ``name`` / ``status`` are # not exposed in the MCP signature, so the model only sees # self-service fields. patch_body = SoftwareCatalogUpdateRequest( asset_type=asset_type, owner_person_id=(_u(owner_person_id) if owner_person_id else None), it_contact_person_id=( _u(it_contact_person_id) if it_contact_person_id else None ), remark=remark, login_url=login_url, expiry_date=(expiry_date or None), license_info=license_info, offboarding_policy=offboarding_policy, allow_self_cancel=allow_self_cancel, category=category, ) patch = patch_body.model_dump(exclude_none=True) # Defensive — strip the admin-locked fields even though we # didn't expose them in the signature, in case future schema # revisions add new fields the MCP tool didn't anticipate. for locked in ("name", "status", "department"): patch.pop(locked, None) for k, v in patch.items(): setattr(item, k, v) if "owner_person_id" in patch or item.department in (None, ""): item.department = await _resolve_software_department( rt.db, item.owner_person_id, ) await rt.db.flush() enriched = await _enrich_software_catalog(rt.db, [item]) return enriched[0] # ════════════════════════════════════════════════════════════════════ # Asset inventory (employee response, migration 088) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_my_inventory_items", description=( "List inventory cycle items routed to YOU. Without cycle_id, " "returns pending items across every active cycle. With a " "cycle_id, returns every item (responded or not) for that " "cycle so you can see what you've already submitted. " "Bounded pagination — max 200 rows per call. The caller-side " "(no-cycle_id) variant is in-memory paginated since the " "underlying service returns the full pending set per active " "cycle (typically <100 rows for a real user)." ), ) async def list_my_inventory_items( cycle_id: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_inventory_items", {"cycle_id": cycle_id, "page": page, "page_size": page_size}, ) as rt: from app.domains.asset.inventory_router import _hydrate_items from app.domains.asset.inventory_service import AssetInventoryService service = AssetInventoryService(rt.db) if cycle_id is not None: items, total = await service.list_items( _u(cycle_id), holder_person_id=rt.user.id, page=page, page_size=page_size, ) else: # ``list_pending_for_person`` doesn't accept pagination — # the per-user pending count is small in practice but we # still slice in-memory so an LLM client gets stable shape # + accurate total. Earlier revision hard-coded page_size=200 # via list_items's page param; for the non-cycle case that # silently dropped any 201st item. all_items = await service.list_pending_for_person(rt.user.id) total = len(all_items) offset = (page - 1) * page_size items = all_items[offset:offset + page_size] data = await _hydrate_items(rt.db, items) return { "items": serialize(data), "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="list_my_active_inventory_cycles", description=( "List currently-active inventory cycles in which you have at " "least one assigned item. Pair with list_my_inventory_items for " "drilldown. Empty list = no inventory work pending for you." ), ) async def list_my_active_inventory_cycles() -> dict: async with tool_runtime("list_my_active_inventory_cycles", {}) as rt: from app.domains.asset.inventory_router import _hydrate_cycle from app.domains.asset.models import ( AssetInventoryCycle, AssetInventoryItem, ) rows = ( await rt.db.execute( select(AssetInventoryCycle).where( AssetInventoryCycle.status == "active", AssetInventoryCycle.id.in_( select(AssetInventoryItem.cycle_id).where( AssetInventoryItem.holder_person_id == rt.user.id, ) ), ) ) ).scalars().all() data = [await _hydrate_cycle(rt.db, c) for c in rows] return {"cycles": serialize(data)} @mcp_server.tool( name="respond_inventory_item", description=( "Record your response to an inventory item routed to you.\n\n" "Allowed `response_type` per holder_kind (server-enforced):\n" "- **assignee**: `in_use` (you have it & it's working — " "confirms the row), `damaged` (broken; admin investigates), " "`lost` (gone; admin investigates), `not_mine` (isn't yours / " "you no longer hold it; admin re-assigns), `other` (use " "`response_notes`).\n" "- **location_admin**: `in_storage` (sitting in office stock), " "`damaged`, `lost`, `taken_away` (someone removed it without " "intake — admin investigates), `moved_to` (relocated — must " "include `new_location`), `other`.\n\n" "Ownership transfer is NOT a valid inventory response — moving " "an asset from one person to another is an asset-admin-only " "action done via the asset transfer endpoints. If you no longer " "hold the asset, respond `not_mine` and an admin will re-assign " "it.\n\n" "`response_notes` is recommended for damaged/lost/taken_away/" "other so the admin doesn't have to chase context. `not_mine` " "flags the row for admin investigation but does NOT auto-" "reassign — IT decides who actually holds the asset.\n\n" "`evidence_photos` is REQUIRED for `not_mine` — pass a list of " "S3 object keys obtained from `/api/v1/uploads/presign-put` " "(purpose=`inventory_evidence`). The UI requires holders to " "photograph the actual hardware SN before submitting; an MCP " "caller MUST upload first then pass the resulting keys here, " "otherwise the response will be rejected.\n\n" "**Asset admins only** — you may also answer an item routed to " "someone else (\"IT 帮忙确认\", for a holder who replied over " "Feishu / email, or whose device you sighted yourself) as well " "as the unassigned `it_fallback` bucket. On the on-behalf path " "`response_notes` is REQUIRED (record how the asset was " "verified) and the asset's own office region is enforced; the " "row is stamped with YOUR person id and reads back as " "`responded_on_behalf: true`." ), ) async def respond_inventory_item( item_id: str, response_type: str, response_notes: str | None = None, new_location: str | None = None, evidence_photos: list[str] | None = None, ) -> dict: async with tool_runtime( "respond_inventory_item", { "item_id": item_id, "response_type": response_type, "response_notes": response_notes, "new_location": new_location, "evidence_photos_count": ( len(evidence_photos) if evidence_photos else 0 ), }, mutates=True, ) as rt: from app.audit.service import AuditService from app.core.rbac import can_manage_assets from app.domains.asset.inventory_router import _hydrate_items from app.domains.asset.inventory_service import AssetInventoryService # Cap free-text at the MCP boundary — service bypasses the HTTP # Pydantic body (InventoryItemRespondBody) so the # ``max_length=2000`` / ``max_length=256`` validators don't fire. # (R3 logic sweep.) response_notes = _bound_free_text( response_notes, field="response_notes", max_len=2000, ) new_location = _bound_free_text( new_location, field="new_location", max_len=256, ) is_admin = can_manage_assets(rt.user.role_names) service = AssetInventoryService(rt.db) if is_admin: # Mirror the HTTP route's #1778 gate: when an asset admin # answers someone else's item ("IT 帮忙确认") the asset's own # region decides authority, so a region-scoped admin can't # proxy-confirm an out-of-region row. A holder answering # their own item is authorised regardless of region — same # asymmetry as ``POST /items/{id}/respond``. from app.domains.asset.models import ( # noqa: PLC0415 AssetInventoryItem, ) inv_item = await rt.db.get(AssetInventoryItem, _u(item_id)) if ( inv_item is not None and inv_item.holder_person_id != rt.user.id ): await _mcp_enforce_asset_region(rt, inv_item.asset_id) item = await service.respond_to_item( _u(item_id), actor_person_id=rt.user.id, is_admin=is_admin, response_type=response_type, response_notes=response_notes, new_location=new_location, evidence_photos=evidence_photos, ) await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="asset_inventory.item.respond", resource_type="asset_inventory_item", resource_id=str(item.id), status="success", detail={ "response_type": response_type, "is_admin_override": ( is_admin and item.holder_person_id != rt.user.id ), # Mirrors the HTTP route: true only when the item WAS # routed to a person and someone else answered it # ("IT 帮忙确认"), unlike ``is_admin_override`` which # also covers the holder-less it_fallback bucket. "responded_on_behalf": ( item.holder_person_id is not None and item.holder_person_id != rt.user.id ), }, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) hydrated = await _hydrate_items(rt.db, [item]) return serialize(hydrated[0]) # ════════════════════════════════════════════════════════════════════ # DNS self-service (migration 098) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="submit_dns_request", description=( "Submit a DNS create / update / delete / enable / disable request " "via the Feishu approval flow. " "action in {create,update,delete,enable,disable}. record_type in " "{A,AAAA,CNAME,TXT,NS}. For update / delete / enable / disable, " "target_record_id MUST resolve to a record YOU currently own — " "the service rejects modifications to records owned by someone " "else. For enable/disable the value/ttl/name fields are still " "required by the schema but they're only used for display in the " "approval card; the executor only flips the cloud-side status " "flag and does NOT push the value. ttl seconds (60..86400, " "default 600) — but the DNS provider's own floor also applies and " "is enforced at submit time (Tencent DNSPod rejects anything " "below 600), so prefer 600 unless you know the tenant allows " "lower. Returns the created request row." ), ) async def submit_dns_request( action: str, integration_id: str, zone_name: str, name: str, record_type: str, value: str, purpose: str, ttl: int = 600, target_record_id: str | None = None, attachment_url: str | None = None, ) -> dict: async with tool_runtime( "submit_dns_request", { "action": action, "integration_id": integration_id, "zone_name": zone_name, "name": name, "record_type": record_type, "value": value, "ttl": ttl, "purpose": purpose, "target_record_id": target_record_id, "attachment_url": attachment_url, }, mutates=True, ) as rt: from app.domains.cmdb.dns_request_service import DnsRequestService from app.domains.cmdb.schemas import DnsRecordRequestCreate body = DnsRecordRequestCreate( action=action, # type: ignore[arg-type] integration_id=integration_id, zone_name=zone_name, name=name, record_type=record_type, # type: ignore[arg-type] value=value, ttl=ttl, target_record_id=target_record_id, purpose=purpose, attachment_url=attachment_url, ) svc = DnsRequestService(rt.db) response = await svc.submit( body=body, requester=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(response.model_dump()) @mcp_server.tool( name="list_my_dns_requests", description=( "List YOUR DNS requests (the service self-filters; admins also " "only see their own here). status filter optional." ), ) async def list_my_dns_requests( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_my_dns_requests", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.cmdb.dns_request_service import DnsRequestService svc = DnsRequestService(rt.db) items, total = await svc.list_for_user( requester=rt.user, page=page, page_size=page_size, status=status, ) return { "requests": [serialize(item.model_dump()) for item in items], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_dns_request", description=( "Cancel one of YOUR pending DNS requests. The underlying service " "rejects (403) attempts to cancel a request you didn't submit, " "and (409) attempts to cancel one that's no longer pending." ), ) async def cancel_my_dns_request(request_id: str) -> dict: async with tool_runtime( "cancel_my_dns_request", {"request_id": request_id}, mutates=True, ) as rt: from app.domains.cmdb.dns_request_service import DnsRequestService svc = DnsRequestService(rt.db) item = await svc.cancel( request_id=str(_u(request_id)), requester=rt.user, trace_id=_trace_str(), ) return serialize({ **item.model_dump(), "feishu_cancel_synced": svc.last_feishu_cancel_synced, }) @mcp_server.tool( name="bypass_4_eyes_dns_request", description=( "ADMIN-ONLY: explicitly bypass the 4-eyes guard on a DNS request " "that landed at status=failed with a self-approval block, then " "execute the cloud DNS change. Caller must have global_admin or " "cloud_admin. ``reason`` must be at least 20 chars — it is " "captured verbatim in the audit log AND fan-out as a Feishu + " "email notification to EVERY other admin. This is a high-" "visibility security-exception operation; prefer fixing the " "Feishu approval template and using ``retry_blocked_dns_request`` " "instead. Returns the row in its post-execute state (status " "= 'executed' on success, 'failed' with a new error_message " "if the cloud write itself blew up)." ), ) async def bypass_4_eyes_dns_request(request_id: str, reason: str) -> dict: async with tool_runtime( "bypass_4_eyes_dns_request", {"request_id": request_id, "reason_chars": len(reason or "")}, mutates=True, ) as rt: # Defense-in-depth tool-layer RBAC: the service layer enforces # ``can_manage_cloud`` internally, but every other cloud-admin # operation in this file (scan_cloud_orphan_assignments, # cleanup_cloud_orphan_assignments, transfer_cloud_scope_owner, # …) re-gates at the tool boundary so a future service-layer # weakening can't silently grant an MCP-level privilege # escalation. Bypass-4-eyes is one of the highest-blast tools # in the file (writes the cloud provider AND skips dual-control) # — mirror the pattern. (R2 security finding 2026-05-28.) from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "bypass_4_eyes_dns_request requires cloud_admin / " "global_admin.", ) # The HTTP twin (POST /dns-record-requests/{id}/bypass-execute) # deliberately requires an interactive browser session # (``require_browser_session``) precisely so a STOLEN personal # access token can't fire the 4-eyes bypass — the single # highest-blast DNS operation (writes the cloud provider AND skips # dual-control). MCP calls are always PAT-authenticated and carry no # browser session, so exposing the bypass here re-opens exactly the # stolen-PAT vector the HTTP gate closes. Refuse via MCP and direct # the admin to the web UI. (Security review 2026-07-22.) raise ForbiddenError( "The 4-eyes bypass cannot be executed via an API token / MCP — " "it requires an interactive browser session (anti stolen-PAT). " "Perform it in the Helix web console " "(DNS 申请 → 失败项 → 「4-eyes 例外执行」), or fix the Feishu " "approval template and retry the request normally.", ) @mcp_server.tool( name="retry_blocked_dns_request", description=( "ADMIN-ONLY: re-open a fresh Feishu approval instance for a " "DNS request that blocked on the server-side 4-eyes guard. Use " "AFTER the Feishu approval template has been fixed to route the " "requester away from approver nodes — the new instance goes " "through normal Feishu approval (no bypass) and the 4-eyes guard " "will fire again on the new callback if the template still " "routes back to the requester. Caller must have global_admin or " "cloud_admin. Row must be ``status=failed`` with a 4-eyes " "fragment in error_message; cloud-write failures are NOT " "retryable through this tool (they would risk double-firing the " "provider call)." ), ) async def retry_blocked_dns_request(request_id: str) -> dict: async with tool_runtime( "retry_blocked_dns_request", {"request_id": request_id}, mutates=True, ) as rt: # Defense-in-depth tool-layer RBAC, paired with # ``bypass_4_eyes_dns_request`` above. The service enforces # ``can_manage_cloud`` but we mirror the gate at the tool # boundary so a service-layer weakening doesn't silently # expand the MCP attack surface. (R2 security finding.) from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "retry_blocked_dns_request requires cloud_admin / " "global_admin.", ) from app.domains.cmdb.dns_request_service import DnsRequestService svc = DnsRequestService(rt.db) item = await svc.retry_after_template_fix( request_id=str(_u(request_id)), actor=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(item.model_dump()) @mcp_server.tool( name="resume_blocked_dns_request", description=( "ADMIN-ONLY: resume a DNS request that landed at status=failed " "ONLY because DNS writes were locked at execute time " "(``cmdb.dns_writes_enabled=false`` or the legacy prod-cloud " "freeze). The Feishu approval is still valid and no provider call " "was attempted, so this re-drives the cloud write on the SAME " "approval — no new approval instance, no bypass. Caller must have " "global_admin or cloud_admin. Refuses if DNS writes are still " "locked (re-enable them first) or if the failure was a genuine " "cloud-write error (those must be re-submitted as a fresh " "request, to avoid double-firing the provider). Returns the row " "in its post-execute state (status='executed' on success)." ), ) async def resume_blocked_dns_request(request_id: str) -> dict: async with tool_runtime( "resume_blocked_dns_request", {"request_id": request_id}, mutates=True, ) as rt: # Defense-in-depth tool-layer RBAC, mirroring # ``bypass_4_eyes_dns_request`` / ``retry_blocked_dns_request``. from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "resume_blocked_dns_request requires cloud_admin / " "global_admin.", ) from app.domains.cmdb.dns_request_service import DnsRequestService svc = DnsRequestService(rt.db) item = await svc.resume_after_write_lock_cleared( request_id=str(_u(request_id)), actor=rt.user, trace_id=_trace_str(), ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return serialize(item.model_dump()) @mcp_server.tool( name="recertify_my_dns_records", description=( "Mark one or more DNS records you own as 'still in use' (resets " "the recertify cadence). Pass record_ids to scope, or omit to " "recertify EVERY record you own. Records you don't own are " "silently skipped — they appear in the audit log." ), ) async def recertify_my_dns_records( record_ids: list[str] | None = None, ) -> dict: async with tool_runtime( "recertify_my_dns_records", {"record_ids": record_ids}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.cmdb.dns_request_service import DnsRequestService # Validate UUIDs at the boundary so a typo'd id surfaces # clearly. Cap the list at 500 — a 1M-element list would # parse 1M UUIDs and issue a `WHERE id IN (...)` of 1M # elements, OOMing the TiDB planner. validated_ids: list[str] | None = None if record_ids is not None: # `[]` looks innocent but the underlying service treats it # the same as None ("no filter") and would recertify EVERY # owned record — the opposite of what an LLM passing an # empty batch usually means. Reject explicitly so the LLM # can't accidentally trigger a global recertify by passing # an empty list. To recertify all, omit the arg entirely. if len(record_ids) == 0: raise AppException( code="VALIDATION_ERROR", message=( "record_ids must contain at least one id; " "omit the argument entirely to recertify every " "record you own." ), status_code=422, ) if len(record_ids) > 500: raise AppException( code="VALIDATION_ERROR", message=( "record_ids may not exceed 500 entries per call; " f"got {len(record_ids)}. Split into batches." ), status_code=422, ) validated_ids = [str(_u(r)) for r in record_ids] svc = DnsRequestService(rt.db) count = await svc.recertify_owned( owner=rt.user, record_ids=validated_ids, trace_id=_trace_str(), ) return {"recertified": count} @mcp_server.tool( name="transfer_my_dns_records", description=( "Hand off one or more DNS records you own to an active " "teammate (no approval). Recipient must be an active employee. " "You CANNOT transfer to yourself; you can only transfer rows " "you currently own (admins can transfer any row). New owner " "gets a Feishu + email notification. Up to 500 record_ids per " "call. Use ``list_dns_records(owner_person_id='me')`` to discover " "the record_ids you own, and search_directory_people to resolve " "the new owner's name to a person UUID." ), ) async def transfer_my_dns_records( record_ids: list[str], new_owner_person_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "transfer_my_dns_records", { "record_ids": record_ids, "new_owner_person_id": new_owner_person_id, "reason": reason, }, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.cmdb.router import _notify_owner_of_transfer from app.domains.cmdb.service import CmdbService if not record_ids or len(record_ids) == 0: raise AppException( code="VALIDATION_ERROR", message="record_ids must contain at least one id.", status_code=422, ) if len(record_ids) > 500: raise AppException( code="VALIDATION_ERROR", message=( f"record_ids capped at 500 per call; got " f"{len(record_ids)}. Split into batches." ), status_code=422, ) validated_ids = [str(_u(r)) for r in record_ids] validated_new_owner = str(_u(new_owner_person_id)) # Mirror the role gate used by the HTTP twin # (POST /cmdb/dns-records/batch-owner-transfer): # employee + global_admin + cloud_admin. Auditor / management / # pure read-only roles can't reach this surface. from app.core.rbac import ( CLOUD_ADMIN_ROLE, EMPLOYEE_ROLE, GLOBAL_ADMIN_ROLE, can_manage_cloud, has_role, ) if not has_role( rt.user.role_names, EMPLOYEE_ROLE, GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE, ): raise AppException( code="FORBIDDEN", message="Role not permitted to transfer DNS records.", status_code=403, ) is_admin = can_manage_cloud(rt.user.role_names) trace_id = _trace_str() summary = await CmdbService.transfer_owned_dns_records( rt.db, record_ids=validated_ids, new_owner_person_id=validated_new_owner, caller_person_id=str(rt.user.id), caller_is_admin=is_admin, reason=reason, trace_id=trace_id, ) if summary["not_owned"] and not is_admin: raise AppException( code="FORBIDDEN", message=( f"{summary['not_owned']} record(s) in the batch are " "not owned by you. Refresh the list and try again." ), status_code=403, ) transferred_ids = summary.pop("_transferred_ids", []) # Mirror the HTTP twin's explicit domain audit row # (cmdb/router.py:batch_transfer_dns_record_owner) so forensic # queries by ``action='cmdb.dns.self_service_transfer'`` find # MCP-initiated transfers too. tool_runtime only writes a # generic ``mcp.transfer_my_dns_records`` row. ``resource_id`` is # String(128); CSV-joining UUIDs truncates, so store the first id # as the row key and the full list in ``detail.transferred_ids``. if transferred_ids: from app.audit.service import AuditService first_id = transferred_ids[0] await AuditService(rt.db).log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action="cmdb.dns.self_service_transfer", status="success", resource_type="cmdb_dns_record", resource_id=first_id, detail={ "summary": summary, "new_owner_person_id": validated_new_owner, "record_ids_count": len(record_ids), "transferred_ids": transferred_ids, "reason": reason, }, ) try: await _notify_owner_of_transfer( rt.db, new_owner_person_id=validated_new_owner, actor=rt.user, resource_kind="dns", resource_count=len(transferred_ids), reason=reason, trace_id=trace_id, ) except Exception: # noqa: BLE001 # Notification failure never blocks the transfer commit. # The outbox row, if persisted, will retry. pass return summary @mcp_server.tool( name="transfer_my_servers", description=( "Hand off one or more cloud servers you own to an active " "teammate (no approval). Recipient must be an active employee. " "You CANNOT transfer to yourself; you can only transfer " "servers you currently own (admins can transfer any server). " "New owner gets a Feishu + email notification. Up to 500 " "server_ids per call. Use list_cloud_servers to discover " "server_ids, and search_directory_people to resolve the new " "owner's name to a person UUID." ), ) async def transfer_my_servers( server_ids: list[str], new_owner_person_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "transfer_my_servers", { "server_ids": server_ids, "new_owner_person_id": new_owner_person_id, "reason": reason, }, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.cmdb.router import _notify_owner_of_transfer from app.domains.cmdb.service import CmdbService if not server_ids or len(server_ids) == 0: raise AppException( code="VALIDATION_ERROR", message="server_ids must contain at least one id.", status_code=422, ) if len(server_ids) > 500: raise AppException( code="VALIDATION_ERROR", message=( f"server_ids capped at 500 per call; got " f"{len(server_ids)}. Split into batches." ), status_code=422, ) validated_ids = [str(_u(s)) for s in server_ids] validated_new_owner = str(_u(new_owner_person_id)) from app.core.rbac import ( CLOUD_ADMIN_ROLE, EMPLOYEE_ROLE, GLOBAL_ADMIN_ROLE, can_manage_cloud, has_role, ) if not has_role( rt.user.role_names, EMPLOYEE_ROLE, GLOBAL_ADMIN_ROLE, CLOUD_ADMIN_ROLE, ): raise AppException( code="FORBIDDEN", message="Role not permitted to transfer servers.", status_code=403, ) is_admin = can_manage_cloud(rt.user.role_names) trace_id = _trace_str() summary = await CmdbService.transfer_owned_servers( rt.db, server_ids=validated_ids, new_owner_person_id=validated_new_owner, caller_person_id=str(rt.user.id), caller_is_admin=is_admin, reason=reason, trace_id=trace_id, ) if summary["not_owned"] and not is_admin: raise AppException( code="FORBIDDEN", message=( f"{summary['not_owned']} server(s) in the batch are " "not owned by you. Refresh the list and try again." ), status_code=403, ) transferred_ids = summary.pop("_transferred_ids", []) # Mirror the HTTP twin's explicit domain audit row # (cmdb/router.py:batch_transfer_server_owner) so forensic # queries by ``action='cmdb.server.self_service_transfer'`` find # MCP-initiated transfers too. See the DNS twin above for the # resource_id truncation rationale. if transferred_ids: from app.audit.service import AuditService first_id = transferred_ids[0] await AuditService(rt.db).log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action="cmdb.server.self_service_transfer", status="success", resource_type="cmdb_server", resource_id=first_id, detail={ "summary": summary, "new_owner_person_id": validated_new_owner, "server_ids_count": len(server_ids), "transferred_ids": transferred_ids, "reason": reason, }, ) try: await _notify_owner_of_transfer( rt.db, new_owner_person_id=validated_new_owner, actor=rt.user, resource_kind="server", resource_count=len(transferred_ids), reason=reason, trace_id=trace_id, ) except Exception: # noqa: BLE001 pass return summary # ════════════════════════════════════════════════════════════════════ # Cloud scope + access self-service # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_cloud_scopes", description=( "List cloud scopes — AWS sub-account / Azure subscription / GCP " "project or billing account. 1:1 with GET /cmdb/scopes.\n\n" "Two modes:\n" "• default — the scope-level view: scopes the caller owns or " "already holds an active/pending grant on, each carrying " "active/pending member counts (distinct people) and grant counts " "(role assignments). This is the scope-level companion to " "``list_my_cloud_grants``, which is one row per role instead. " "Cloud / finance / audit readers see every scope, not just their " "own.\n" "• ``requestable=true`` — the access-request picker: every " "``active`` non-production scope that has an owner (plus the " "deliberately owner-less GCP billing accounts, whose approval " "authority is the cloud-admin team), regardless of the caller's " "current membership. **This is how you discover a ``scope_id`` to " "pass to ``submit_cloud_access_request``.** Production scopes are " "absent by design — prod cloud access is granted by the R&D " "security team outside Helix self-service, so requesting one is " "refused at the service boundary too.\n\n" "``search`` matches display name or the provider-side external " "id. Decommissioned (closed) scopes are hidden. Paginated " "1-based, page_size capped at 500 to mirror the HTTP cap the " "employee picker relies on. Call ``list_my_cloud_grants`` to see " "which of these the caller already has access to before " "requesting more." ), ) async def list_cloud_scopes( requestable: bool = False, search: str | None = None, page: int = 1, page_size: int = 200, ) -> dict: async with tool_runtime( "list_cloud_scopes", { "requestable": requestable, "search": search, "page": page, "page_size": page_size, }, ) as rt: from sqlalchemy import false as _false from sqlalchemy import func as _func from sqlalchemy import or_ as _or from sqlalchemy import select as _select from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_cloud, can_view_cloud_resources from app.domains.cmdb.lifecycle_router import _serialize_scopes from app.domains.cmdb.models import ( GRANT_STATUS_ACTIVE, GRANT_STATUS_PENDING, PROD_ENVIRONMENTS, SCOPE_STATUS_CLOSED, SCOPE_TYPE_GCP_BILLING_ACCOUNT, TENANT_LEVEL_SCOPE_TYPES, CmdbCloudAccessGrant, CmdbCloudScope, ) # Mirror the HTTP twin's gate: every authenticated employee # passes ``can_view_cloud_resources``; offboarded / suspended # users 403. Membership narrowing below is what keeps a plain # employee from enumerating the org in the default mode. if not can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("Cloud-resource access not granted.") search = _bound_free_text(search, field="search", max_len=200) # Cap matches the HTTP ``page_size`` cap (le=500) — the employee # "Request cloud access" picker fetches every requestable scope # in one shot and a 500-employee org routinely has 200+ scopes. page, page_size = _clamp_pagination(page, page_size, max_size=500) q = _select(CmdbCloudScope).where( CmdbCloudScope.deleted_at.is_(None), # Decommissioned scopes are irreversible dead rows; the HTTP # twin hides them from the default list too (its "已下线" tab # passes ``closed_only=true``, which has no MCP surface — # there is nothing an AI client can do with a closed scope). CmdbCloudScope.status != SCOPE_STATUS_CLOSED, ) if search: like = f"%{search}%" q = q.where(_or( CmdbCloudScope.display_name.ilike(like), CmdbCloudScope.external_id.ilike(like), )) if requestable: # Parity with ``lifecycle_router.list_scopes`` requestable # branch — active + tenant-level + has-owner (or an # owner-less GCP billing account, approved by the # cloud-admin team) + NOT prod. Filtering here rather than # only in the caller's head is what keeps the picker honest: # the service-layer guards (``_refuse_prod_self_service`` / # ``_refuse_ungrantable_scope_type``) are the second line of # defense, but an AI client that never sees an ungrantable # scope never wastes a request on one. # # ``TENANT_LEVEL_SCOPE_TYPES`` (added to the HTTP twin by # #2105 the same day this tool landed) excludes sub-tenant # rows — an ``azure_resource_group`` from the Azure OpenAI # auto-grant flow is active, owner=requester, env=dev, so it # passes every other predicate and shows up as a dead-end # picker entry whose role dropdown is empty. Keep this # predicate list in lockstep with the HTTP branch: the two # surfaces answering "what can I request?" differently is # exactly the drift this tool exists to end. q = q.where( CmdbCloudScope.status == "active", CmdbCloudScope.scope_type.in_(TENANT_LEVEL_SCOPE_TYPES), _or( CmdbCloudScope.owner_person_id.isnot(None), CmdbCloudScope.scope_type == SCOPE_TYPE_GCP_BILLING_ACCOUNT, ), CmdbCloudScope.environment.notin_(PROD_ENVIRONMENTS), ) elif not can_read_cloud(rt.user.role_names): # Employees see only scopes they own or are a member of. member_scope_ids = list((await rt.db.execute( _select(CmdbCloudAccessGrant.scope_id).where( CmdbCloudAccessGrant.person_id == str(rt.user.id), CmdbCloudAccessGrant.status.in_( (GRANT_STATUS_ACTIVE, GRANT_STATUS_PENDING), ), ) )).scalars().all()) q = q.where(_or( CmdbCloudScope.owner_person_id == str(rt.user.id), CmdbCloudScope.id.in_( [_u(s) for s in member_scope_ids], ) if member_scope_ids else _false(), )) total = (await rt.db.execute( _select(_func.count()).select_from(q.subquery()), )).scalar() or 0 rows = list((await rt.db.execute( q.order_by(CmdbCloudScope.display_name.asc()) .offset((page - 1) * page_size).limit(page_size) )).scalars().all()) # Reuse the HTTP serializer so owner labels / member counts / # prod-write-lock state can never drift between the two surfaces, # then project the picker-relevant subset. Billing fields # (budget, spend) are deliberately dropped — a scope picker has # no need for money, and the money surfaces carry their own # masking rules. Member counts match what the HTTP twin already # returns to the same audience. full = await _serialize_scopes(rt.db, rows, viewer=rt.user) return { "items": [ { "scope_id": s["id"], "display_name": s["display_name"], "external_id": s["external_id"], "provider": s["provider"], "integration_name": s["integration_name"], "scope_type": s["scope_type"], "environment": s["environment"], "status": s["status"], "owner_person_id": s["owner_person_id"], "owner_label": s["owner_label"], "default_grant_ttl_days": s["default_grant_ttl_days"], "active_member_count": s["active_member_count"], "pending_member_count": s["pending_member_count"], "active_grant_count": s["active_grant_count"], "pending_grant_count": s["pending_grant_count"], "write_locked": s["write_locked"], "console_url": s["console_url"], "last_synced_at": _iso_utc(s["last_synced_at"]), } for s in full ], "page": page, "page_size": page_size, "total": int(total), } @mcp_server.tool( name="submit_cloud_access_request", description=( "Request access (a role) on a cloud scope (sub-account / " "subscription / project). Routes to the scope owner for " "approval. ttl_days bounded 1..365 (default uses scope's " "default_grant_ttl_days). Use " "``list_cloud_scopes(requestable=True)`` to discover scope_ids " "you can request against." ), ) async def submit_cloud_access_request( scope_id: str, role_key: str, role_display_name: str | None = None, reason: str | None = None, ttl_days: int | None = None, extra_role_keys: list[str] | None = None, ) -> dict: async with tool_runtime( "submit_cloud_access_request", { "scope_id": scope_id, "role_key": role_key, "ttl_days": ttl_days, "extra_role_keys_count": len(extra_role_keys or []), }, mutates=True, ) as rt: # Cap free-text at the MCP boundary — service has no length cap # before the DB write. (R1 sweep: Class C cap.) reason = _bound_free_text(reason, field="reason", max_len=2000) from app.domains.cmdb.lifecycle_service import CloudLifecycleService if extra_role_keys and len(extra_role_keys) > 10: from app.core.exceptions import AppException raise AppException( code="VALIDATION_ERROR", message="extra_role_keys capped at 10 per submission", status_code=422, ) # Mirror the HTTP /grants/request guard — non-cloud-admin # callers cannot self-grant admin-named roles via MCP either. # Without this an LLM-driven flow with a regular-employee PAT # could elevate the user to "User Access Administrator" / # "*管理员" by passing the role_key directly. (HTTP refuses # the same in lifecycle_router.request_grant.) # # NOTE (R1 security/code-review finding 2026-05-28): this used # to call ``can_read_cloud`` which also admits AUDITOR / FINANCE # / MANAGEMENT — meaning a user holding only one of those roles # passed the ``not can_read_cloud(...)`` test as False and # skipped the admin-role refusal, letting them self-grant # ``User Access Administrator`` / ``*管理员`` via MCP. The HTTP # twin at ``lifecycle_router.request_grant`` correctly uses # ``can_manage_cloud`` (admits only global_admin / cloud_admin), # so MCP must too — anything narrower than ``can_manage_cloud`` # is a privilege-escalation primitive. # Role-governance gating (ungrantable refused / restricted = # cloud-admin only) is enforced centrally in # ``CloudLifecycleService.submit_access_request`` — the service is # the single chokepoint for both this MCP tool and the HTTP route, # so a role-key passed directly here is gated identically. See # app/integrations/cloud_role_governance.py. # Prod cloud-account write lock — mirror the HTTP guard so the # MCP surface can't bypass it. Role-independent; blocks everyone # while engaged. See app/domains/cmdb/prod_write_lock.py. from app.domains.cmdb.models import CmdbCloudScope as _PWLScope from app.domains.cmdb.prod_write_lock import ( ensure_prod_scope_writable as _ensure_prod_writable, ) await _ensure_prod_writable( rt.db, await rt.db.get(_PWLScope, _u(scope_id)), ) service = CloudLifecycleService(rt.db) grant = await service.submit_access_request( scope_id=str(_u(scope_id)), person_id=str(rt.user.id), role_key=role_key, role_display_name=role_display_name, reason=reason, ttl_days=ttl_days, requester_id=rt.user.id, trace_id=_trace_str(), extra_role_keys=list(extra_role_keys or []), actor_role_names=list(rt.user.role_names), ) return { "id": str(grant.id), "scope_id": str(grant.scope_id), "role_key": grant.role_key, "status": grant.status, "request_group_id": grant.request_group_id, } @mcp_server.tool( name="list_my_cloud_grants", description=( "List the caller's own cloud-scope grants — 我有哪些云权限/云账号 — " "every role on every scope where they're listed as a member, NAMED " "(`scope_display_name`, `scope_external_id` = the cloud account id, " "`scope_environment`, `provider`), with status " "(active/pending/revoked/expired/disabled) + days-until-expiry. " "Default returns ALL statuses including revoked / expired / " "disabled rows so an auditor can see history; pass " "``status='active'`` to scope to currently-effective access " "only (this is what most assistants want — a renew/revoke " "action on a revoked row will be refused at the service " "boundary). Paginated 1-based — defaults to page=1, " "page_size=200 (mirrors the HTTP /my-grants cap). For the " "scope-level view (with member counts) use ``list_cloud_scopes``." ), ) async def list_my_cloud_grants( status: str | None = None, page: int = 1, page_size: int = 200, ) -> dict: async with tool_runtime( "list_my_cloud_grants", {"status": status, "page": page, "page_size": page_size}, ) as rt: from datetime import UTC as _UTC from datetime import datetime as _dt from sqlalchemy import func as _func from sqlalchemy import select as _select from app.domains.cmdb.models import CmdbCloudAccessGrant # Bound the page/size — an unbounded list lets a long-tenured # employee with hundreds of historical grants pull a multi-MiB # JSON blob into the LLM context. Mirrors the HTTP /my-grants # cap (le=500) in lifecycle_router.list_my_grants; the helper # default (100) would silently truncate below the signature's # promised page_size=200. page, page_size = _clamp_pagination(page, page_size, max_size=500) base_q = _select(CmdbCloudAccessGrant).where( CmdbCloudAccessGrant.person_id == str(rt.user.id), ) if status: base_q = base_q.where(CmdbCloudAccessGrant.status == status) total = (await rt.db.execute( _select(_func.count()).select_from(base_q.subquery()), )).scalar() or 0 page_q = ( base_q.order_by(CmdbCloudAccessGrant.created_at.desc()) .offset((page - 1) * page_size).limit(page_size) ) rows = list((await rt.db.execute(page_q)).scalars().all()) now = _dt.now(_UTC).replace(tzinfo=None) # Name the scope. Without this the answer to 「我有哪些云权限」 was a list # of UUIDs: the HTTP twin has carried ``scope_display_name`` / # ``provider`` since it shipped, and a grant nobody can name is a grant # the employee cannot act on. One extra query for the page, two with # integrations — not per row. from app.domains.cmdb.models import CmdbCloudScope from app.integrations.models import Integration scope_ids = {g.scope_id for g in rows if g.scope_id} scopes: dict[str, Any] = {} integrations: dict[str, Any] = {} if scope_ids: scope_rows = list((await rt.db.execute( _select(CmdbCloudScope).where( CmdbCloudScope.id.in_([_u(str(s)) for s in scope_ids]), ) )).scalars().all()) scopes = {str(s.id): s for s in scope_rows} integ_ids = { str(s.integration_id) for s in scope_rows if s.integration_id } if integ_ids: integ_rows = list((await rt.db.execute( _select(Integration).where( Integration.id.in_([_u(i) for i in integ_ids]), ) )).scalars().all()) integrations = {str(i.id): i for i in integ_rows} def _scope_fields(grant) -> dict: scope = scopes.get(str(grant.scope_id)) if scope is None: return {} integ = integrations.get(str(scope.integration_id)) return { "scope_display_name": scope.display_name, "scope_external_id": scope.external_id, "scope_environment": scope.environment, "provider": integ.provider if integ else None, "integration_name": integ.name if integ else None, } return { "items": [ { "id": str(g.id), "scope_id": str(g.scope_id), **_scope_fields(g), "role_key": g.role_key, "role_display_name": g.role_display_name, "status": g.status, "expires_at": ( _iso_utc(g.expires_at) ), "days_until_expiry": ( (g.expires_at - now).days if g.expires_at else None ), } for g in rows ], "page": page, "page_size": page_size, "total": int(total), } @mcp_server.tool( name="list_my_pending_cloud_grant_approvals", description=( "Pending cloud-access grant requests the caller is responsible " "for approving. Plain scope owners see pending grants only on " "scopes they own (with self-requested rows filtered — four-eyes " "rule); global_admin sees every pending grant across the org. " "Mirrors the HTTP /cmdb/lifecycle/my-pending-grant-approvals " "route used by the MyTodosPage cloud-approvals inbox. Each row " "carries scope label, requester, role, requested TTL, and the " "originating reason text so an AI client can rank or filter " "before calling the approve / reject HTTP routes." ), ) async def list_my_pending_cloud_grant_approvals() -> dict: async with tool_runtime( "list_my_pending_cloud_grant_approvals", {}, ) as rt: from sqlalchemy import select as _select from app.core.rbac import can_manage_cloud from app.domains.cmdb.lifecycle_service import GRANT_STATUS_PENDING from app.domains.cmdb.models import ( CmdbCloudAccessGrant, CmdbCloudScope, ) q = ( _select(CmdbCloudAccessGrant) .where(CmdbCloudAccessGrant.status == GRANT_STATUS_PENDING) .order_by(CmdbCloudAccessGrant.created_at.desc()) ) if not can_manage_cloud(rt.user.role_names): q = q.join( CmdbCloudScope, CmdbCloudScope.id == CmdbCloudAccessGrant.scope_id, ).where( CmdbCloudScope.owner_person_id == str(rt.user.id), CmdbCloudAccessGrant.person_id != str(rt.user.id), ) rows = list((await rt.db.execute(q)).scalars().all()) # Eager-fetch scope display names so the AI sees "PingCAP" # rather than a raw UUID. One IN-list query — cheaper than N # per-row gets, and avoids the lazy-loader churn that ORM # access in a list comp would trigger. scope_ids = list({r.scope_id for r in rows}) scope_rows = ( (await rt.db.execute( _select(CmdbCloudScope).where( CmdbCloudScope.id.in_(scope_ids), ) )).scalars().all() if scope_ids else [] ) scope_by_id = {str(s.id): s for s in scope_rows} return { "items": [ { "grant_id": str(g.id), "scope_id": str(g.scope_id), "scope_label": ( (s := scope_by_id.get(str(g.scope_id))) is not None and (s.display_name or s.external_id) or None ), "person_id": str(g.person_id) if g.person_id else None, "role_key": g.role_key, "role_display_name": g.role_display_name, "requested_ttl_days": (g.detail or {}).get( "requested_ttl_days", ), "reason": (g.detail or {}).get("reason"), "created_at": ( _iso_utc(g.created_at) ), } for g in rows ], "total": len(rows), } @mcp_server.tool( name="request_cloud_grant_renewal", description=( "Ask the subscription owner to extend (or revive) the caller's " "cloud access on one scope. Renewal is NOT self-service: this " "files a request that the scope owner approves — 1:1 with POST " "/cmdb/scopes/{scope_id}/renewal-requests.\n\n" "By default every renewable grant the caller holds on the scope " "is covered by ONE submission the owner decides once; pass " "``grant_ids`` to narrow it. ``ttl_days`` defaults to 90 and is " "capped at 180 — anything over that is refused. The subscription " "owner's approval is final at every allowed length. ``reason`` is " "mandatory — the approver judges against it.\n\n" "Returns the request_group_id plus any grants that were skipped " "with a machine-readable reason (perpetual holder, scope owner " "exempt, prod scope, revival window expired, already pending)." ), ) async def request_cloud_grant_renewal( scope_id: str, reason: str, ttl_days: int = 90, grant_ids: list[str] | None = None, ) -> dict: async with tool_runtime( "request_cloud_grant_renewal", { "scope_id": scope_id, "ttl_days": ttl_days, "grant_ids": grant_ids, }, mutates=True, ) as rt: from app.domains.cmdb.renewal_service import CloudRenewalService # Self-only, as with every other ``*_my_*`` cloud tool: the # service also enforces that acting for another person requires # cloud-admin, but MCP never passes anyone else's person_id so an # AI client cannot file requests in a colleague's name at all. result = await CloudRenewalService(rt.db).submit_renewal_request( scope_id=scope_id, person_id=str(rt.user.id), grant_ids=list(grant_ids) if grant_ids else None, ttl_days=ttl_days, reason=reason, requested_by_person_id=rt.user.id, trace_id=_trace_str(), actor_role_names=list(rt.user.role_names), ) return { "request_group_id": result["request_group_id"], "grant_count": result["grant_count"], "requested_ttl_days": result["requested_ttl_days"], "lapse_at": ( _iso_utc(result["lapse_at"]) if result.get("lapse_at") else None ), "skipped": result.get("skipped") or [], } @mcp_server.tool( name="decide_cloud_grant_renewal", description=( "Approve or reject a cloud grant renewal request as the " "subscription owner (or a cloud admin) — 1:1 with POST " "/cmdb/renewal-requests/{request_group_id}/decide.\n\n" "Decides the whole submission at once (one person, one decision, " "not one call per role). The decision is final at every " "allowed length — no second signature. Refused when the caller " "is the holder (no self-approval). Approving a request whose " "grant was already recycled re-dispatches the access to the " "cloud provider." ), ) async def decide_cloud_grant_renewal( request_group_id: str, approve: bool, note: str | None = None, ) -> dict: async with tool_runtime( "decide_cloud_grant_renewal", {"request_group_id": request_group_id, "approve": approve}, mutates=True, ) as rt: from app.domains.cmdb.renewal_service import CloudRenewalService # Authority (scope owner / cloud admin, no self-approval) is # enforced inside the service so the HTTP route and this tool # cannot drift apart. return await CloudRenewalService(rt.db).decide_renewal_group( request_group_id=request_group_id, approve=approve, approver_id=rt.user.id, approver_role_names=list(rt.user.role_names), trace_id=_trace_str(), note=note, ) @mcp_server.tool( name="list_cloud_grant_renewal_requests", description=( "List open cloud grant renewal requests awaiting a decision on " "one subscription — 1:1 with GET " "/cmdb/scopes/{scope_id}/renewal-requests. Grouped by " "submission, so one entry per person rather than one per role. " "Use it to see who is waiting, for how long, what they asked " "for, and whether approving would revive already-recycled " "access." ), ) async def list_cloud_grant_renewal_requests(scope_id: str) -> dict: async with tool_runtime( "list_cloud_grant_renewal_requests", {"scope_id": scope_id}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( can_manage_cloud, can_read_cloud, is_cloud_scope_owner_of, ) from app.domains.cmdb.renewal_service import CloudRenewalService if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of(rt.db, str(rt.user.id), scope_id) ): raise ForbiddenError( "Only the subscription owner or a cloud admin can read the " "renewal approval queue.", ) rows = await CloudRenewalService(rt.db).list_open_for_scope( scope_id, # Same 工号 privacy gate as the HTTP twin — the two surfaces # return the same rows and must redact identically, or an AI # client becomes the easier way to harvest employee numbers. privileged_viewer=can_read_cloud(rt.user.role_names), ) return {"requests": rows, "total": len(rows)} @mcp_server.tool( name="list_my_cloud_grant_renewals", description=( "The caller's own cloud grant renewal requests, newest first — " "1:1 with GET /cmdb/me/renewal-requests. Shows status " "(pending_owner / approved / rejected / lapsed / " "cancelled), who decided, any approver note, and the new expiry " "an approval actually applied." ), ) async def list_my_cloud_grant_renewals(limit: int = 50) -> dict: async with tool_runtime( "list_my_cloud_grant_renewals", {"limit": limit}, ) as rt: from app.domains.cmdb.renewal_service import CloudRenewalService rows = await CloudRenewalService(rt.db).list_for_person( str(rt.user.id), limit=limit, ) return {"requests": rows, "total": len(rows)} @mcp_server.tool( name="list_my_pending_cloud_renewal_approvals", description=( "Cloud grant renewal submissions awaiting MY decision, across " "every subscription — 1:1 with GET " "/cmdb/my-pending-renewal-approvals. The cross-scope companion to " "`list_cloud_grant_renewal_requests` (which is one scope at a " "time), and the renewal twin of " "`list_my_pending_cloud_grant_approvals`.\n\n" "A cloud admin sees the org-wide queue; a plain subscription owner " "sees only their own scopes. Own submissions are excluded because " "self-approval is refused — surfacing them would be a to-do nobody " "can complete. Answers identically to the mobile to-do badge (both " "read the same authority rule) so the two can never disagree about " "who owes a decision." ), ) async def list_my_pending_cloud_renewal_approvals() -> dict: async with tool_runtime("list_my_pending_cloud_renewal_approvals", {}) as rt: from app.core.rbac import can_manage_cloud, can_read_cloud from app.domains.cmdb.renewal_service import CloudRenewalService rows = await CloudRenewalService(rt.db).list_pending_for_approver( str(rt.user.id), is_cloud_admin=can_manage_cloud(rt.user.role_names), # Same 工号 privacy gate as the HTTP twin. privileged_viewer=can_read_cloud(rt.user.role_names), ) return {"requests": rows, "total": len(rows)} @mcp_server.tool( name="cancel_my_cloud_grant_renewal", description=( "Withdraw one of the caller's own open cloud renewal requests — " "1:1 with POST /cmdb/renewal-requests/{id}/cancel. Use " "when the access turned out not to be needed after all. The " "grant then expires on its original schedule." ), ) async def cancel_my_cloud_grant_renewal(request_group_id: str) -> dict: async with tool_runtime( "cancel_my_cloud_grant_renewal", {"request_group_id": request_group_id}, mutates=True, ) as rt: from app.domains.cmdb.renewal_service import CloudRenewalService # The service allows the holder or a cloud admin; it deliberately # refuses the scope owner, who should REJECT (which tells the # requester why) rather than silently cancel. return await CloudRenewalService(rt.db).cancel_renewal_group( request_group_id=request_group_id, actor_id=rt.user.id, actor_role_names=list(rt.user.role_names), trace_id=_trace_str(), ) @mcp_server.tool( name="revoke_my_cloud_grant", description=( "Voluntarily revoke a cloud grant the caller holds (e.g. " "'I don't need this access anymore'). The provider IAM " "binding is removed. To revoke another user's grant, an " "admin must use the HTTP route. The audit reason is fixed to " "``user_released`` for self-revoke; the optional ``reason`` " "argument is accepted for API compatibility but ignored." ), ) async def revoke_my_cloud_grant( grant_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "revoke_my_cloud_grant", {"grant_id": grant_id, "reason": reason}, mutates=True, ) as rt: from app.core.exceptions import AppException, ForbiddenError from app.domains.cmdb.lifecycle_service import CloudLifecycleService from app.domains.cmdb.models import CmdbCloudAccessGrant grant = await rt.db.get(CmdbCloudAccessGrant, _u(grant_id)) if grant is None: raise AppException( code="NOT_FOUND", message=f"grant {grant_id} not found", status_code=404, ) if str(grant.person_id) != str(rt.user.id): raise ForbiddenError( "Can only revoke your own grants via MCP." ) # Prod cloud-account write lock — blocks self-release on a prod # scope too (the original incident was a self-release on prod). from app.domains.cmdb.models import CmdbCloudScope as _PWLScope from app.domains.cmdb.prod_write_lock import ( ensure_prod_scope_writable as _ensure_prod_writable, ) await _ensure_prod_writable( rt.db, await rt.db.get(_PWLScope, _u(str(grant.scope_id))), ) service = CloudLifecycleService(rt.db) # Parity with HTTP /grants/{id}/revoke (lifecycle_router.py): # 1. Hard-code reason="user_released" for self-revoke. The # user-supplied ``reason`` arg is ignored at the service # boundary so the audit field is not caller-controllable. # 2. ``suppress_driver_error=False`` (changed 2026-05-09 round # 16, mirrors the HTTP path's flip away from the legacy # suppress=True default). A genuine cloud failure now # surfaces as a 502-equivalent error to the caller instead # of silently flipping the Helix row to REVOKED while the # cloud-side grant is still active. Idempotent # already-absent responses (drivers return ``already_ # absent=True``) remain success at the service layer so # "user clicked revoke twice" / "sweeper-then-self-revoke" # races don't surface a spurious error. result = await service.revoke_grant( str(grant.id), reason="user_released", actor_user_id=rt.user.id, trace_id=_trace_str(), suppress_driver_error=False, ) return { "id": str(result.id), "status": result.status, "revoked_at": ( _iso_utc(result.revoked_at) if result.revoked_at else None ), } @mcp_server.tool( name="approve_cloud_grant", description=( "Approve a PENDING cloud-access grant request — the decision " "companion to `list_my_pending_cloud_grant_approvals`. Authority " "mirrors the HTTP /cmdb/grants/{id}/approve route exactly: the " "caller must be the scope OWNER or hold cloud-management authority " "(global_admin / cloud_admin); a scope owner cannot approve their " "OWN request (four-eyes — the service raises a conflict). The " "provider IAM binding is created on approval. Optional `ttl_days` " "(1-365) overrides the requested TTL; omit to honour the request's " "own TTL. Blocked by the prod cloud-account write lock, same as " "the HTTP route. NOT step-up-gated (a PAT may approve grants), " "unlike scope-request approval which stays HTTP/browser-only." ), ) async def approve_cloud_grant( grant_id: str, ttl_days: int | None = None, ) -> dict: async with tool_runtime( "approve_cloud_grant", {"grant_id": grant_id, "ttl_days": ttl_days}, mutates=True, ) as rt: from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_cloud, is_cloud_scope_owner_of from app.domains.cmdb.lifecycle_service import CloudLifecycleService from app.domains.cmdb.models import CmdbCloudAccessGrant, CmdbCloudScope from app.domains.cmdb.prod_write_lock import ensure_prod_scope_writable if ttl_days is not None and not (1 <= ttl_days <= 365): raise AppException( code="VALIDATION_ERROR", message="ttl_days must be between 1 and 365", status_code=422, ) grant = await rt.db.get(CmdbCloudAccessGrant, _u(grant_id)) if grant is None: raise AppException( code="NOT_FOUND", message=f"grant {grant_id} not found", status_code=404, ) # Same gate as _require_scope_owner_or_admin: cloud-manager OR the # scope's owner. Self-approve is blocked inside the service # (four-eyes ConflictError), not here. if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of( rt.db, str(rt.user.id), str(grant.scope_id), ) ): raise ForbiddenError( "Only the scope owner or a cloud admin may approve this grant.", ) await ensure_prod_scope_writable( rt.db, await rt.db.get(CmdbCloudScope, _u(str(grant.scope_id))), ) service = CloudLifecycleService(rt.db) outcome = await service.approve_grant( str(grant.id), approver_id=rt.user.id, ttl_days=ttl_days, trace_id=_trace_str(), approver_role_names=list(rt.user.role_names), ) refreshed = await rt.db.get(CmdbCloudAccessGrant, _u(grant_id)) return { "id": str(refreshed.id), "status": refreshed.status, "expires_at": ( _iso_utc(refreshed.expires_at) if refreshed.expires_at else None ), "warning": outcome.warning, } @mcp_server.tool( name="reject_cloud_grant", description=( "Reject a PENDING cloud-access grant request. Authority mirrors " "the HTTP /cmdb/grants/{id}/reject route: scope OWNER or " "cloud-management (global_admin / cloud_admin). Optional `reason` " "(<=500 chars) is recorded and shown to the requester. Blocked by " "the prod cloud-account write lock. Rejecting denies access " "(the requester may re-apply)." ), ) async def reject_cloud_grant( grant_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "reject_cloud_grant", {"grant_id": grant_id, "reason": reason}, mutates=True, ) as rt: from app.core.exceptions import AppException, ForbiddenError from app.core.rbac import can_manage_cloud, is_cloud_scope_owner_of from app.domains.cmdb.lifecycle_service import CloudLifecycleService from app.domains.cmdb.models import CmdbCloudAccessGrant, CmdbCloudScope from app.domains.cmdb.prod_write_lock import ensure_prod_scope_writable grant = await rt.db.get(CmdbCloudAccessGrant, _u(grant_id)) if grant is None: raise AppException( code="NOT_FOUND", message=f"grant {grant_id} not found", status_code=404, ) if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of( rt.db, str(rt.user.id), str(grant.scope_id), ) ): raise ForbiddenError( "Only the scope owner or a cloud admin may reject this grant.", ) await ensure_prod_scope_writable( rt.db, await rt.db.get(CmdbCloudScope, _u(str(grant.scope_id))), ) service = CloudLifecycleService(rt.db) await service.reject_grant( str(grant.id), rejector_id=rt.user.id, reason=(reason or None), trace_id=_trace_str(), ) refreshed = await rt.db.get(CmdbCloudAccessGrant, _u(grant_id)) return {"id": str(refreshed.id), "status": refreshed.status} @mcp_server.tool( name="submit_cloud_scope_request", description=( "Request creation of a NEW cloud sub-account / subscription / " "project ('scope'). Goes to a global_admin via Feishu approval " "before any cloud-side resource is created.\n\n" "- ``integration_id`` — UUID of a registered cloud integration " "(get the list from list_cloud_servers / scope endpoints).\n" "- ``name`` — proposed display name for the new scope (1..50, " "letters/digits/spaces/'-', at least one alphanumeric).\n" "- ``cloud_account_type`` — short free-text classifier the " "approver sees verbatim, e.g. ``production``, ``test``, ``poc``, " "``personal`` (1..64).\n" "- ``monthly_budget_usd`` — proposed monthly spend cap (must be " "positive, max 10,000,000).\n" "- ``description`` — optional rationale shown to the approver " "(max 2000 chars). Recommended." ), ) async def submit_cloud_scope_request( integration_id: str, name: str, monthly_budget_usd: float, cloud_account_type: str, description: str | None = None, ) -> dict: async with tool_runtime( "submit_cloud_scope_request", { "integration_id": integration_id, "name": name, "monthly_budget_usd": monthly_budget_usd, "cloud_account_type": cloud_account_type, "description_len": len(description or ""), }, mutates=True, ) as rt: # Bug context (caught pre-launch 2026-05-04, second-pass review): # an earlier revision of this tool exposed ``reason`` + ``environment`` # arguments and forwarded them to ``CloudScopeRequestService.submit`` # — but that method only accepts ``description`` + ``cloud_account_type``, # so every call raised TypeError. Match the HTTP route's # ``ScopeRequestSubmitBody`` shape exactly + run inputs through the # same Pydantic validators so ranges line up (budget > 0 not >= 0, # budget <= 10M not <= 100M, etc.). from app.domains.cmdb.lifecycle_router import ScopeRequestSubmitBody from app.domains.cmdb.scope_request_service import ( CloudScopeRequestService, ) body = ScopeRequestSubmitBody( integration_id=integration_id, name=name, description=description, monthly_budget_usd=monthly_budget_usd, cloud_account_type=cloud_account_type, ) # Boundary UUID check: the Pydantic body keeps it as a string, so # a malformed id would fall through to the service's # ``uuid.UUID(integration_id)`` and surface as INTERNAL_ERROR. # Validate here for a clean 422. _u(body.integration_id) service = CloudScopeRequestService(rt.db) req = await service.submit( requester=rt.user, integration_id=body.integration_id, name=body.name, description=body.description, monthly_budget_usd=body.monthly_budget_usd, cloud_account_type=body.cloud_account_type, trace_id=_trace_str(), ) return { "id": str(req.id), "status": req.status, "feishu_instance_code": req.feishu_instance_code, } @mcp_server.tool( name="list_my_cloud_scope_requests", description=( "List the caller's own scope-creation requests across all " "lifecycle states (pending / approved / failed / withdrawn / " "rejected)." ), ) async def list_my_cloud_scope_requests( status: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_cloud_scope_requests", {"status": status, "page": page, "page_size": page_size}, ) as rt: from sqlalchemy import select as _select from app.domains.cmdb.models import CmdbCloudScopeRequest # Bug context (caught pre-launch 2026-05-04, second-pass review): # this tool used to read ``r.environment`` and ``r.external_id`` # off the row, but ``CmdbCloudScopeRequest`` exposes neither column # — those names belong to ``CmdbCloudScope`` (a different table). # Every call would AttributeError. Mirror the HTTP route's # ``_serialize_scope_request`` shape now so the response stays in # lockstep with the web UI. base_q = _select(CmdbCloudScopeRequest).where( CmdbCloudScopeRequest.requester_person_id == str(rt.user.id), ) if status: base_q = base_q.where(CmdbCloudScopeRequest.status == status) count_q = select(func.count()).select_from(base_q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 rows = list((await rt.db.execute( base_q .order_by(CmdbCloudScopeRequest.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) )).scalars().all()) return { "items": [ { "id": str(r.id), "integration_id": str(r.integration_id), "provider": r.provider, "name": r.name, "description": r.description, "monthly_budget_usd": float(r.monthly_budget_usd), "status": r.status, "rejection_reason": r.rejection_reason, "failure_reason": r.failure_reason, "feishu_instance_code": r.feishu_instance_code, "created_scope_id": r.created_scope_id, "approved_at": ( _iso_utc(r.approved_at) ), "rejected_at": ( _iso_utc(r.rejected_at) ), "provisioned_at": ( _iso_utc(r.provisioned_at) if r.provisioned_at else None ), "created_at": _iso_utc(r.created_at), "updated_at": _iso_utc(r.updated_at), } for r in rows ], "total": int(total), "page": page, "page_size": page_size, } # NOTE: ``force_cloud_grants_drift_sweep`` was removed 2026-05-08 (this # pre-launch audit). The tool's name matched the # ``test_mcp_tools_surface._FORBIDDEN_TOOL_NAME_PATTERNS`` denylist # (``^force_*``) — admin-override semantics are deliberately kept off # every PAT-reachable surface so a stolen token cannot bypass the 50% # drift-sweep safety floor. The matching HTTP route # ``POST /cmdb/integrations/{id}/grants-sync/force-drift-sweep`` remains # (admin web UI only); this is the sole exception to the MCP-sync rule # (CLAUDE.md), and it's documented here so a future contributor doesn't # "restore parity" by reintroducing the tool. # # Same precedent applies to the resource-sweep override added 2026-05-14 # (PR for the "ack doesn't break the loop" report): the HTTP route # ``POST /cmdb/sync/{id}/force-sweep`` flips the drop-tolerance / 0-rows guards # in ``_safe_sweep`` and is admin web UI only — no matching MCP tool. # Both endpoints are pure safety-floor overrides; bypassing them via a # stolen PAT would mass-soft-delete real production rows (recoverable, # but loudly bad). Keeping the override surface off MCP is a defense- # in-depth call that complements the per-route ``can_manage_cloud`` # gate. Do NOT add a ``force_cloud_resource_sweep`` tool here. @mcp_server.tool( name="scan_cloud_orphan_assignments", description=( "Scan a cloud sub-account / subscription for cloud-side orphan " "principal assignments — typically AWS Identity Center 'User " "not found' rows where a permission set is still bound but the " "principal is deleted from the SCIM-synced directory. Read-only; " "no DB writes and no cloud-side mutations. Restricted to " "``global_admin`` and ``cloud_admin`` via ``can_manage_cloud`` " "(2026-05-20: cloud_admin promoted from perpetual-grant marker " "to real cloud-domain authority).\n\n" "**Always pass ``scope_id``** when investigating a specific " "sub-account (almost always). Without it the scan walks every " "active scope on the integration — on a real AWS Org with 50+ " "member accounts × 20 permission sets this routinely exceeds " "any reasonable timeout. Use the integration-wide form (no " "``scope_id``) only with explicit user intent for a batch sweep." ), ) async def scan_cloud_orphan_assignments( integration_id: str, scope_id: str | None = None, ) -> dict: async with tool_runtime( "scan_cloud_orphan_assignments", {"integration_id": integration_id, "scope_id": scope_id}, mutates=False, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud from app.domains.cmdb.orphan_cleanup import scan_orphan_assignments if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Orphan scan is restricted to cloud / global admins.", ) _u(integration_id) # validate shape early if scope_id: _u(scope_id) records = await scan_orphan_assignments( integration_id, scope_id=scope_id, ) return { "count": len(records), "candidates": [ { "integration_id": r.integration_id, "integration_name": r.integration_name, "provider": r.provider, "scope_id": r.scope_id, "scope_external_id": r.scope_external_id, "scope_display_name": r.scope_display_name, "principal_id": r.principal_id, "role_key": r.role_key, "role_display_name": r.role_display_name, "external_ref": r.external_ref, "recovered_email": r.recovered_email, "recovered_display_name": r.recovered_display_name, } for r in records ], } @mcp_server.tool( name="cleanup_cloud_orphan_assignments", description=( "Delete the admin-confirmed list of cloud-side orphan " "assignments. Each target is processed independently — a " "single cloud-side failure does NOT abort the rest. For every " "successful sweep the tool inserts a status='revoked' row into " "``cmdb_cloud_access_grants`` with revoke_reason=" "'orphan_principal_swept' (visible in the scope detail page's " "已结束 tab) and writes a ``cloud.grant.orphan_principal_swept`` " "audit row attributing the action to the caller.\n\n" "``targets`` is a list of " "``{scope_id, role_key, principal_id, " "external_ref?, recovered_email?, role_display_name?}`` " "dicts — typically the output of " "``scan_cloud_orphan_assignments`` filtered by the admin to " "the rows they actually want swept. Restricted to " "``global_admin`` and ``cloud_admin`` via ``can_manage_cloud`` " "(2026-05-20: cloud_admin promoted from perpetual-grant marker " "to real cloud-domain authority). " "Idempotent per-target: re-running on an already-deleted " "assignment returns ok=True with already_absent=True and skips " "the duplicate history row." ), ) async def cleanup_cloud_orphan_assignments( integration_id: str, targets: list[dict[str, Any]], ) -> dict: async with tool_runtime( "cleanup_cloud_orphan_assignments", {"integration_id": integration_id, "target_count": len(targets or [])}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import can_manage_cloud from app.domains.cmdb.orphan_cleanup import ( cleanup_orphan_assignments, ) if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Orphan cleanup is restricted to cloud / global admins.", ) if not isinstance(targets, list) or not targets: raise ValidationError( "targets must be a non-empty list of " "{scope_id, role_key, principal_id, ...} dicts", ) # The endpoint caps at 500; mirror that here so an over-sized # MCP call fails fast rather than mid-flight. if len(targets) > 500: raise ValidationError( f"too many targets ({len(targets)}); cap is 500 per call", ) _u(integration_id) results = await cleanup_orphan_assignments( integration_id, targets, actor_user_id=rt.user.id, trace_id=_trace_str(), ) summary = { "requested": len(targets), "succeeded": sum(1 for r in results if r.ok), "already_absent": sum( 1 for r in results if r.ok and r.already_absent ), "failed": sum(1 for r in results if not r.ok), } return { "summary": summary, "results": [ { "principal_id": r.principal_id, "role_key": r.role_key, "scope_id": r.scope_id, "ok": r.ok, "already_absent": r.already_absent, "grant_id": r.grant_id, "error_message": r.error_message, } for r in results ], } def _leaver_sweep_payload(result) -> dict: """Shared serializer for the leaver-sweep scan / run MCP tools.""" return { "person_id": result.person_id, "email": result.email, "dry_run": result.dry_run, "summary": result.summary(), "integration_errors": result.integration_errors, "assignments": [ { "integration_id": r.integration_id, "integration_name": r.integration_name, "account_id": r.account_id, "scope_id": r.scope_id, "role_key": r.role_key, "role_display_name": r.role_display_name, "principal_id": r.principal_id, "outcome": r.outcome, "grant_id": r.grant_id, "error_message": r.error_message, } for r in result.results ], } @mcp_server.tool( name="scan_leaver_cloud_assignments", description=( "Preview (read-only) every AWS Identity Center account " "assignment a person STILL holds, resolved from their work " "email — the delete plan the offboarding leaver-sweep would " "execute. No cloud-side mutation and no DB write.\n\n" "Use this to see what a departing employee is holding across " "AWS accounts before their SCIM deprovision deletes the " "Identity Store user (after which those assignments become " "un-attributable blank-name orphans). Each row carries an " "``outcome`` of what a real sweep would do: ``would_delete`` " "(non-prod, untracked → will be removed), " "``skipped_prod_locked`` (prod scope, prod-write lock engaged), " "``skipped_unmanaged_account`` (no Helix scope row → not " "touched), or ``skipped_tracked_grant`` (owned by the standard " "offboarding grant-revoke path). Restricted to ``global_admin`` " "and ``cloud_admin`` via ``can_manage_cloud``." ), ) async def scan_leaver_cloud_assignments(person_id: str) -> dict: async with tool_runtime( "scan_leaver_cloud_assignments", {"person_id": person_id}, mutates=False, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud from app.domains.cmdb.leaver_sso_sweep import ( sweep_leaver_sso_assignments, ) if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Leaver cloud-assignment scan is restricted to cloud / " "global admins.", ) _u(person_id) result = await sweep_leaver_sso_assignments( person_id, actor_user_id=rt.user.id, trace_id=_trace_str(), dry_run=True, ) return _leaver_sweep_payload(result) @mcp_server.tool( name="sweep_leaver_cloud_assignments", description=( "Delete every non-prod, untracked AWS Identity Center account " "assignment a person still holds (resolved from their work " "email), so those assignments never become blank-name orphans " "after their SCIM deprovision deletes the Identity Store user. " "This is the manual twin of the automatic offboarding sweep " "(``run_tick`` / leaver 立即推进 fire it before the offboard " "directory push).\n\n" "For each deleted assignment a status='revoked' row is inserted " "into ``cmdb_cloud_access_grants`` with revoke_reason=" "'leaver_principal_swept' (carrying the REAL person_id) plus a " "``cloud.grant.leaver_principal_swept`` audit row. Prod scopes " "are skipped while the prod-write lock is engaged; accounts with " "no Helix scope row are surfaced but never touched; assignments " "already tracked as an active grant are left to the standard " "offboarding revoke. Idempotent — a re-run after the " "assignments are gone is a clean no-op. Preview with " "``scan_leaver_cloud_assignments`` first. Restricted to " "``global_admin`` and ``cloud_admin`` via ``can_manage_cloud``." ), ) async def sweep_leaver_cloud_assignments(person_id: str) -> dict: async with tool_runtime( "sweep_leaver_cloud_assignments", {"person_id": person_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud from app.domains.cmdb.leaver_sso_sweep import ( sweep_leaver_sso_assignments, ) if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Leaver cloud-assignment sweep is restricted to cloud / " "global admins.", ) _u(person_id) result = await sweep_leaver_sso_assignments( person_id, actor_user_id=rt.user.id, trace_id=_trace_str(), dry_run=False, ) return _leaver_sweep_payload(result) @mcp_server.tool( name="transfer_cloud_scope_owner", description=( "Hand off a cloud sub-account / subscription / project ('scope') " "to a new owner. The caller must be the CURRENT owner of the " "scope or a cloud / global admin — bystanders 403. The new " "owner must be an active employee — leavers / pending-leavers " "are rejected with SCOPE_OWNER_LEAVER_FORBIDDEN. Closed scopes " "cannot be re-bound (use the dedicated decommission flow). " "Self-rebind (current owner = new owner) is a no-op and writes " "no audit row. Production write lock: a cloud / global admin " "may transfer ownership even while the prod cloud write lock " "is engaged (owner transfer is Helix-metadata only — no cloud " "IAM call); an owner self-transfer on a locked prod scope is " "still blocked with PROD_CLOUD_LOCKED.\n\n" "**Loss-of-management warning** — once the transfer commits the " "previous owner immediately loses unilateral management rights " "(no Settings / Alerts edit, no grant approve / revoke). Only " "the new owner or an admin can transfer again — there is no " "self-service reclaim path. Confirm with the human user before " "calling.\n\n" "Args:\n" "- ``scope_id`` — UUID of the cloud scope to transfer.\n" "- ``new_owner_person_id`` — UUID of the successor " "(``IdentityPerson.id``)." ), ) async def transfer_cloud_scope_owner( scope_id: str, new_owner_person_id: str, ) -> dict: async with tool_runtime( "transfer_cloud_scope_owner", {"scope_id": scope_id, "new_owner_person_id": new_owner_person_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud, is_cloud_scope_owner_of from app.domains.cmdb.lifecycle_service import CloudLifecycleService scope_uuid = _u(scope_id) successor_uuid = _u(new_owner_person_id) # Mirror ``_require_scope_owner_or_admin`` from the HTTP route: # cloud-admin OR current scope owner. Owner check uses the same # ``is_cloud_scope_owner_of`` helper so the two surfaces never # drift on what counts as owner authority. if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of(rt.db, str(rt.user.id), str(scope_uuid)) ): raise ForbiddenError( "Only the current scope owner or a cloud admin can " "transfer ownership.", ) # Prod cloud-account write lock — with the admin owner-transfer # carve-out (2026-06-12, mirrors PATCH /scopes/{id}): owner # transfer is pure Helix metadata (no provider IAM call), so a # cloud / global admin may re-seat ownership while prod stays # locked. Owner SELF-transfer on a locked prod scope still # trips the lock, same as the HTTP route. if not can_manage_cloud(rt.user.role_names): from app.domains.cmdb.models import CmdbCloudScope as _PWLScope from app.domains.cmdb.prod_write_lock import ( ensure_prod_scope_writable as _ensure_prod_writable, ) await _ensure_prod_writable( rt.db, await rt.db.get(_PWLScope, scope_uuid), ) service = CloudLifecycleService(rt.db) scope = await service.assign_scope_owner( str(scope_uuid), str(successor_uuid), actor_user_id=rt.user.id, trace_id=_trace_str(), ) # No explicit commit here: tool_runtime(mutates=True) commits on # clean exit. A double commit defeats the rollback-on-exception # guarantee and breaks session state on expire_on_commit=True. return { "id": str(scope.id), "owner_person_id": scope.owner_person_id, "display_name": scope.display_name, "external_id": scope.external_id, "status": scope.status, } @mcp_server.tool( name="snooze_cloud_scope_idle_reminder", description=( "Suppress the weekly idle-account reminder for one cloud scope. " "Default 30 days; range 1..365. The weekly " "``cmdb.scope_idle_sweep`` cron skips any scope whose " "``idle_snoozed_until`` is in the future, so the owner stops " "getting Feishu pings about a scope they intentionally kept.\n\n" "Use when the owner has confirmed the scope is still wanted " "despite looking idle (0 grants + $0 30-day spend) — e.g. a " "long-running PoC account that goes weeks between uses. The " "snooze is NOT a decommission decision: the scope stays " "``status=active`` and the cost / IAM dashboards still see it.\n\n" "RBAC: the CURRENT scope owner OR a cloud / global admin. " "Non-owner / non-admin callers get 403. Re-snoozing inside the " "existing window just extends the deadline; both old + new " "values are recorded in the ``cloud.scope.idle_snooze`` audit " "row.\n\n" "Args:\n" "- ``scope_id`` — UUID of the cloud scope to snooze.\n" "- ``days`` — optional, defaults to 30. Bounded 1..365." ), ) async def snooze_cloud_scope_idle_reminder( scope_id: str, days: int = 30, ) -> dict: async with tool_runtime( "snooze_cloud_scope_idle_reminder", {"scope_id": scope_id, "days": days}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.cmdb.lifecycle_service import CloudLifecycleService # Type-strict ``days`` validation matching the HTTP boundary's # Pydantic ``Field(ge=1, le=365, type=int)``. JSON clients can # send numbers as float (``30.5``) or strings (``"30"``); the # previous ``int(days)`` cast silently truncated floats and # raised TypeError on strings — inconsistent with HTTP's 422. # ``bool`` is a Python subclass of ``int`` so excluded # explicitly (a literal ``True`` would otherwise pass as 1). if isinstance(days, bool) or not isinstance(days, int): raise AppException( code="IDLE_SNOOZE_DAYS_OUT_OF_RANGE", message="days must be an integer between 1 and 365", status_code=400, ) scope_uuid = _u(scope_id) # Prod cloud-account write lock (role-independent kill-switch). from app.domains.cmdb.models import CmdbCloudScope as _PWLScope from app.domains.cmdb.prod_write_lock import ( ensure_prod_scope_writable as _ensure_prod_writable, ) await _ensure_prod_writable( rt.db, await rt.db.get(_PWLScope, scope_uuid), ) service = CloudLifecycleService(rt.db) # Service handles owner-or-admin RBAC inline so MCP and HTTP # routes can't drift on who's allowed to snooze. scope = await service.snooze_idle_scope( str(scope_uuid), days=days, actor_user_id=rt.user.id, actor_role_names=list(rt.user.role_names or []), trace_id=_trace_str(), ) return { "id": str(scope.id), "display_name": scope.display_name, "external_id": scope.external_id, "owner_person_id": scope.owner_person_id, "idle_snoozed_until": ( _iso_utc(scope.idle_snoozed_until) if scope.idle_snoozed_until else None ), "last_idle_notified_at": ( _iso_utc(scope.last_idle_notified_at) if scope.last_idle_notified_at else None ), } @mcp_server.tool( name="classify_cloud_iam_user", description=( "Say what kind of cloud IAM account this is: ``service`` (a " "robot that will never map to a person), ``human`` (a colleague " "Helix has not matched yet — still needs an owner), or " "``unset`` to clear.\n\n" "Why it exists: every compliance surface treats an IAM user with " "no owner as an orphan, and two different problems land in that " "bucket. Without somewhere to record the difference the orphan " "list can never reach zero, and a list that can never reach zero " "stops being read — which is how two departed employees' live " "AdministratorAccess went unnoticed for months.\n\n" "Purely a Helix-side statement: no cloud call, nothing granted " "or revoked. A ``service`` account drops out of the orphan and " "leaver-residue queues but keeps syncing, and its policies are " "still listed.\n\n" "Requires cloud-manage authority.\n\n" "Args:\n" "- ``iam_user_id`` — UUID of the cmdb_iam_users row\n" "- ``kind`` — human | service | unset\n" "- ``note`` — why (shown next to the badge, kept in the audit row)" ), ) async def classify_cloud_iam_user( iam_user_id: str, kind: str, note: str | None = None, ) -> dict: async with tool_runtime( "classify_cloud_iam_user", {"iam_user_id": iam_user_id, "kind": kind}, mutates=True, ) as rt: from datetime import UTC, datetime from app.core.exceptions import ( ForbiddenError, NotFoundError, ValidationError, ) from app.core.rbac import can_manage_cloud from app.domains.cmdb.models import CmdbIamUser if not can_manage_cloud(rt.user.role_names): raise ForbiddenError("Cloud manage authority required.") if kind not in ("human", "service", "unset"): raise ValidationError("kind must be human, service or unset") row = await rt.db.get(CmdbIamUser, _u(iam_user_id)) if row is None or row.deleted_at is not None: raise NotFoundError("CmdbIamUser", iam_user_id) if kind == "unset": row.account_kind = None row.account_kind_note = None row.account_kind_set_by_person_id = None row.account_kind_set_at = None else: row.account_kind = kind row.account_kind_note = (note or "").strip() or None row.account_kind_set_by_person_id = str(rt.user.id) row.account_kind_set_at = datetime.now(UTC).replace(tzinfo=None) await rt.db.commit() return { "id": str(row.id), "user_name": row.user_name, "account_kind": row.account_kind, "account_kind_note": row.account_kind_note, } @mcp_server.tool( name="list_leaver_cloud_iam_residue", description=( "Departed colleagues who still hold cloud IAM access.\n\n" "The nightly ``cmdb.leaver_iam_residue_sweep`` compares the cloud " "IAM mirror (``cmdb_iam_users``, refreshed every 4h straight from " "the provider) against everyone HR has marked as left and past " "their last working day. It exists because a revoke that reports " "success but does nothing is invisible from the Helix side: the " "grant row is already 'revoked' and every other sweep only looks " "at active/pending rows.\n\n" "Each row carries the exact residual policy + group names, how " "long the person has been gone, how long the finding has been " "open, and whether the row was matched by owner link or inferred " "from an email. ``mirror_stale`` means the tenant's IAM sync has " "not succeeded recently — report it, do not act on it.\n\n" "Read access: anyone who can read cloud (auditors included). " "Remediation is the admin UI's cleanup action (POST /cmdb/" "iam-users/{id}/cleanup) — irreversible, global_admin only, and " "deliberately not exposed as an MCP tool.\n\n" "Args:\n" "- ``status`` — open (default) | dismissed | resolved | all\n" "- ``integration_id`` — optional cloud tenant filter" ), ) async def list_leaver_cloud_iam_residue( status: str = "open", integration_id: str | None = None, ) -> dict: async with tool_runtime( "list_leaver_cloud_iam_residue", {"status": status, "integration_id": integration_id}, ) as rt: from sqlalchemy import select from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import can_read_cloud from app.domains.cmdb.leaver_iam_residue import summarize_residue from app.domains.cmdb.models import CmdbLeaverIamResidue from app.identity.person_models import IdentityPerson if not can_read_cloud(rt.user.role_names): raise ForbiddenError("Cloud read access required.") if status not in ("open", "dismissed", "resolved", "all"): raise ValidationError( "status must be one of: open, dismissed, resolved, all", ) stmt = select(CmdbLeaverIamResidue) if status != "all": stmt = stmt.where(CmdbLeaverIamResidue.status == status) if integration_id: stmt = stmt.where( CmdbLeaverIamResidue.integration_id == integration_id, ) rows = (await rt.db.execute( stmt.order_by(CmdbLeaverIamResidue.first_detected_at.asc()) )).scalars().all() people = {} if rows: people = { str(p.id): p for p in (await rt.db.execute( select(IdentityPerson).where( IdentityPerson.id.in_([ _u(str(r.person_id)) for r in rows ]), ) )).scalars().all() } items = [] for r in rows: person = people.get(str(r.person_id)) detail = r.detail or {} items.append({ "id": str(r.id), "iam_user_id": str(r.iam_user_id), "person_id": str(r.person_id), "person_display_name": ( getattr(person, "display_name", None) ), "person_work_email": getattr(person, "work_email", None), "termination_date": ( person.termination_date.isoformat() if person is not None and person.termination_date else None ), "integration_id": str(r.integration_id), "provider": r.provider, "user_name": r.user_name, "policy_count": r.policy_count, "group_count": r.group_count, "policy_names": detail.get("policy_names") or [], "group_names": detail.get("group_names") or [], "match_basis": detail.get("match_basis") or "owner", "mirror_stale": bool(detail.get("mirror_stale")), "status": r.status, "first_detected_at": _iso_utc(r.first_detected_at), "last_detected_at": _iso_utc(r.last_detected_at), "resolution": r.resolution, "resolution_note": r.resolution_note, }) return {"items": items, "summary": await summarize_residue(rt.db)} @mcp_server.tool( name="dismiss_leaver_cloud_iam_residue", description=( "Accept one leaver-IAM-residue finding as a known exception.\n\n" "Touches nothing on the cloud. The row leaves the open queue and " "stops feeding the nightly alert, but stays queryable under " "``status='dismissed'`` with the actor and reason recorded — a " "dismissal is a decision someone made, and an access review will " "ask who made it. A dismissed row still auto-resolves once the " "residue actually disappears from the provider mirror.\n\n" "To actually remove the access, use the admin UI's cleanup " "action (irreversible: deletes the sub-account, global_admin " "only).\n\n" "Requires cloud-manage authority.\n\n" "Args:\n" "- ``residue_id`` — UUID from ``list_leaver_cloud_iam_residue``\n" "- ``reason`` — why this is acceptable (>= 4 chars, required)" ), ) async def dismiss_leaver_cloud_iam_residue( residue_id: str, reason: str, ) -> dict: async with tool_runtime( "dismiss_leaver_cloud_iam_residue", {"residue_id": residue_id}, mutates=True, ) as rt: from datetime import UTC, datetime from app.core.exceptions import ( ForbiddenError, NotFoundError, ValidationError, ) from app.core.rbac import can_manage_cloud from app.domains.cmdb.models import ( RESIDUE_STATUS_DISMISSED, RESIDUE_STATUS_OPEN, CmdbLeaverIamResidue, ) if not can_manage_cloud(rt.user.role_names): raise ForbiddenError("Cloud manage authority required.") cleaned = (reason or "").strip() if len(cleaned) < 4: raise ValidationError("reason must be at least 4 characters") row = await rt.db.get(CmdbLeaverIamResidue, _u(residue_id)) if row is None: raise NotFoundError("CmdbLeaverIamResidue", residue_id) if row.status != RESIDUE_STATUS_OPEN: return {"id": str(row.id), "status": row.status, "changed": False} row.status = RESIDUE_STATUS_DISMISSED row.resolution = None row.resolution_note = cleaned row.resolved_by_person_id = str(rt.user.id) row.resolved_at = datetime.now(UTC).replace(tzinfo=None) await rt.db.commit() return {"id": str(row.id), "status": row.status, "changed": True} @mcp_server.tool( name="list_cloud_scope_owner_confirmations", description=( "Return the full owner-governance timeline for a cloud scope: " "every semiannual confirm prompt + response, every zero-spend " "alert + response, every 14-day IT escalation, plus every " "owner CHANGE (initial auto-claim, admin/self transfer, " "offboarding force-transfer), newest first. " "Each row carries the snapshot (spend / member count) that the " "owner was shown when prompted, plus the response status / " "reason / new-owner if applicable.\n\n" "Visible to the scope owner (their own scopes) and to cloud / " "global admins (any scope). Mirrors the detail-page tab " "'Owner 与治理记录'.\n\n" "Entries with ``derived: true`` are owner-change facts " "reconstructed from the audit log — they have no confirmation " "row behind them and cannot be responded to.\n\n" "Args:\n" "- ``scope_id`` — UUID of the cloud scope." ), ) async def list_cloud_scope_owner_confirmations(scope_id: str) -> dict: async with tool_runtime( "list_cloud_scope_owner_confirmations", {"scope_id": scope_id}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud, is_cloud_scope_owner_of from app.domains.cmdb.owner_confirmation_service import ( OwnerConfirmationService, ) scope_uuid = _u(scope_id) if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of(rt.db, str(rt.user.id), str(scope_uuid)) ): raise ForbiddenError( "Only the scope owner or a cloud admin can view this timeline.", ) rows = await OwnerConfirmationService( rt.db, ).list_timeline_for_scope(scope_uuid, limit=200) return { "items": [ { "id": str(r.id), "scope_id": str(r.scope_id), "kind": r.kind, "status": r.status, "owner_person_id": r.owner_person_id, "response_kind": r.response_kind, "response_at": ( _iso_utc(r.response_at) ), "responded_by_person_id": r.responded_by_person_id, "transferred_to_person_id": r.transferred_to_person_id, "reason": r.reason, "spend_recent_30d_usd": ( str(r.spend_recent_30d_usd) if r.spend_recent_30d_usd is not None else None ), "spend_recent_180d_usd": ( str(r.spend_recent_180d_usd) if r.spend_recent_180d_usd is not None else None ), "spend_last_month_usd": ( str(r.spend_last_month_usd) if r.spend_last_month_usd is not None else None ), "member_count_snapshot": r.member_count_snapshot, "notified_at": ( _iso_utc(r.notified_at) ), "escalated_at": ( _iso_utc(r.escalated_at) ), "parent_confirmation_id": r.parent_confirmation_id, "created_at": _iso_utc(r.created_at), # Synthesized from audit_logs (owner auto-claim / # offboarding force-transfer); read-only, cannot be # responded to. "derived": r.derived, # How many times an unanswered prompt has been # re-sent to its owner (daily cadence). "reminder_count": r.reminder_count, "last_reminded_at": _iso_utc(r.last_reminded_at), } for r in rows ], "count": len(rows), } @mcp_server.tool( name="respond_cloud_scope_semiannual_confirm", description=( "Confirm that the calling user is still the rightful owner of " "this cloud scope. Resets the 180-day confirmation clock — the " "next semiannual prompt will fire 180 days from this moment.\n\n" "Only valid against a pending semiannual prompt; respond on a " "zero-spend prompt or an already-responded row raises with " "OWNER_CONFIRMATION_KIND_MISMATCH / OWNER_CONFIRMATION_NOT_PENDING. " "Caller must be the current scope owner or a cloud / global " "admin.\n\n" "Args:\n" "- ``scope_id`` — UUID of the scope.\n" "- ``confirmation_id`` — UUID of the pending confirmation row " "(from ``list_cloud_scope_owner_confirmations``)." ), ) async def respond_cloud_scope_semiannual_confirm( scope_id: str, confirmation_id: str, ) -> dict: async with tool_runtime( "respond_cloud_scope_semiannual_confirm", {"scope_id": scope_id, "confirmation_id": confirmation_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud, is_cloud_scope_owner_of from app.domains.cmdb.owner_confirmation_service import ( OwnerConfirmationService, ) scope_uuid = _u(scope_id) conf_uuid = _u(confirmation_id) if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of(rt.db, str(rt.user.id), str(scope_uuid)) ): raise ForbiddenError( "Only the scope owner or a cloud admin can confirm.", ) # ``rt.user`` IS an IdentityPerson (AuthUser alias). The # caller's person id is just ``rt.user.id``. row = await OwnerConfirmationService(rt.db).respond_semiannual_confirm( scope_uuid, conf_uuid, responder_person_id=str(rt.user.id), actor_user_id=rt.user.id, trace_id=_trace_str(), actor_is_admin=can_manage_cloud(rt.user.role_names), ) return { "id": str(row.id), "status": row.status, "response_kind": row.response_kind, } @mcp_server.tool( name="respond_cloud_scope_zero_spend", description=( "Respond to a zero-spend alert on a cloud scope. Three actions:\n\n" "- ``keep`` — keep the scope active; ``reason`` is REQUIRED and " "is persisted to the timeline so the next IT review can read it.\n" "- ``cleanup`` — owner takes responsibility for the cloud-side " "decommission (the scope itself is NOT auto-closed by this call).\n" "- ``handover`` — owner hands the scope to IT for triage.\n\n" "Only valid against a pending zero-spend prompt. Caller must be " "the current scope owner or a cloud / global admin.\n\n" "Args:\n" "- ``scope_id`` — UUID of the scope.\n" "- ``confirmation_id`` — UUID of the pending zero-spend prompt.\n" "- ``action`` — ``keep`` | ``cleanup`` | ``handover``.\n" "- ``reason`` — required when ``action='keep'``, otherwise optional." ), ) async def respond_cloud_scope_zero_spend( scope_id: str, confirmation_id: str, action: str, reason: str | None = None, ) -> dict: async with tool_runtime( "respond_cloud_scope_zero_spend", { "scope_id": scope_id, "confirmation_id": confirmation_id, "action": action, "reason": reason, }, mutates=True, ) as rt: # Cap free-text at the MCP boundary — written to timeline / # confirmation row with no Pydantic length validator. (R1 # sweep: Class C cap.) reason = _bound_free_text(reason, field="reason", max_len=2000) from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud, is_cloud_scope_owner_of from app.domains.cmdb.owner_confirmation_service import ( OwnerConfirmationService, ) scope_uuid = _u(scope_id) conf_uuid = _u(confirmation_id) if not can_manage_cloud(rt.user.role_names) and not ( await is_cloud_scope_owner_of(rt.db, str(rt.user.id), str(scope_uuid)) ): raise ForbiddenError( "Only the scope owner or a cloud admin can respond.", ) # ``rt.user`` IS an IdentityPerson (AuthUser alias). The # caller's person id is just ``rt.user.id``. row = await OwnerConfirmationService(rt.db).respond_zero_spend( scope_uuid, conf_uuid, action=action, reason=reason, responder_person_id=str(rt.user.id), actor_user_id=rt.user.id, trace_id=_trace_str(), actor_is_admin=can_manage_cloud(rt.user.role_names), ) return { "id": str(row.id), "status": row.status, "response_kind": row.response_kind, "reason": row.reason, } @mcp_server.tool( name="withdraw_my_cloud_scope_request", description=( "Cancel a still-pending scope-creation request you submitted. " "Only PENDING requests can be withdrawn — once an approver " "has acted (approved / rejected) the state is terminal." ), ) async def withdraw_my_cloud_scope_request(req_id: str) -> dict: async with tool_runtime( "withdraw_my_cloud_scope_request", {"req_id": req_id}, mutates=True, ) as rt: from app.domains.cmdb.scope_request_service import ( CloudScopeRequestService, ) service = CloudScopeRequestService(rt.db) req = await service.withdraw( req_id=str(_u(req_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": str(req.id), "status": req.status, "feishu_cancel_synced": service.last_feishu_cancel_synced, } @mcp_server.tool( name="reject_cloud_scope_request", description=( "Reject a PENDING cloud SCOPE-creation request (cloud-admin " "decision). Authority mirrors the HTTP " "/cmdb/scope-requests/{id}/reject route: `can_manage_cloud` " "(global_admin / cloud_admin). Optional `reason` (<=500 chars) is " "recorded and shown to the requester. NOTE: there is deliberately " "no `approve_cloud_scope_request` MCP tool — approval triggers AWS " "Organizations account creation + budget binding and is guarded by " "a browser-session step-up (`require_browser_session`) that " "rejects PAT/MCP callers by design; approve from the web/mobile " "UI. Rejection carries no such trigger, so it is MCP-safe." ), ) async def reject_cloud_scope_request( req_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "reject_cloud_scope_request", {"req_id": req_id, "reason": reason}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_manage_cloud from app.domains.cmdb.scope_request_service import ( CloudScopeRequestService, ) if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Only a cloud admin may reject a scope request.", ) service = CloudScopeRequestService(rt.db) req = await service.reject( req_id=str(_u(req_id)), approver=rt.user, reason=(reason or None), trace_id=_trace_str(), ) return {"id": str(req.id), "status": req.status} # ════════════════════════════════════════════════════════════════════ # CMDB recycle bin (2026-05-28 — added after the legacy-CMDB- # migration → UCloud-sync collision incident silently soft-deleted # 116 UCloud servers). Mirrors the HTTP routes # ``GET /cmdb/recycle-bin/{resource_type}`` and # ``POST /cmdb/recycle-bin/{resource_type}/{id}/restore``. Both # admin-only via ``can_manage_cloud``. Scope: server / domain / # dns_record / load_balancer / cdn_domain (the five resource types # with admin-curated owner/app/remark fields worth preserving across # a delete/restore cycle). # ════════════════════════════════════════════════════════════════════ _CMDB_RECYCLE_BIN_TYPES = ( "server", "domain", "dns_record", "load_balancer", "cdn_domain", "certificate", ) @mcp_server.tool( name="list_cmdb_recycle_bin", description=( "**Cloud-admin only.** List soft-deleted CMDB rows of one " "resource type (newest-deleted first) so an AI client can " "answer 'what deleted servers / domains / DNS records / load " "balancers / CDN domains / certificates can I restore?' and find " "the id to hand to `restore_cmdb_resource`. Mirrors the HTTP " "`GET /cmdb/recycle-bin/{resource_type}` route's " "`can_manage_cloud` gate.\n\n" "Args:\n" "- ``resource_type`` — one of: server, domain, dns_record, " "load_balancer, cdn_domain, certificate.\n" "- ``integration_id`` — optional UUID, narrows to one tenant.\n" "- ``search`` — optional LIKE substring; for servers it also " "matches public_ips / private_ips (so e.g. ``10.9.84.114`` " "finds the swept UCloud DLP-test row).\n" "- ``page`` / ``page_size`` — bounded pagination (max 200)." ), ) async def list_cmdb_recycle_bin( resource_type: str, integration_id: str | None = None, search: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_cmdb_recycle_bin", { "resource_type": resource_type, "integration_id": integration_id, "search": search, "page": page, "page_size": page_size, }, ) as rt: from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import can_manage_cloud from app.domains.cmdb.service import CmdbService if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "CMDB recycle bin is restricted to cloud / global admins.", ) if resource_type not in _CMDB_RECYCLE_BIN_TYPES: raise ValidationError( f"resource_type must be one of " f"{_CMDB_RECYCLE_BIN_TYPES}; got {resource_type!r}", ) if integration_id: _u(integration_id) # validate UUID shape early list_fn = { "server": CmdbService.list_deleted_servers, "domain": CmdbService.list_deleted_domains, "dns_record": CmdbService.list_deleted_dns_records, "load_balancer": CmdbService.list_deleted_load_balancers, "cdn_domain": CmdbService.list_deleted_cdn_domains, "certificate": CmdbService.list_deleted_certificates, }[resource_type] rows, total = await list_fn( rt.db, integration_id=integration_id, search=search, page=page, page_size=page_size, ) return { "resource_type": resource_type, "total": total, "page": page, "page_size": page_size, "rows": [ { "id": str(r.id), # certificates can be manual/probed rows with a NULL # integration_id — don't stringify None into "None". "integration_id": ( str(r.integration_id) if r.integration_id else None ), "deleted_at": r.deleted_at.isoformat() if r.deleted_at else None, "label": ( getattr(r, "name", None) or getattr(r, "domain_name", None) or getattr(r, "instance_id", None) or getattr(r, "lb_id", None) or getattr(r, "record_id", None) or getattr(r, "common_name", None) or str(r.id) ), } for r in rows ], } @mcp_server.tool( name="restore_cmdb_resource", description=( "**Cloud-admin only.** Restore a soft-deleted CMDB row " "(server / domain / dns_record / load_balancer / cdn_domain / " "certificate) by clearing ``deleted_at``. Mirrors the HTTP " "`POST /cmdb/recycle-bin/{resource_type}/{id}/restore` route's " "`can_manage_cloud` gate. 409 if the row is not actually " "deleted (idempotency guard against double-click / stale " "list). Find restorable ids with `list_cmdb_recycle_bin`." ), ) async def restore_cmdb_resource( resource_type: str, resource_id: str, ) -> dict: async with tool_runtime( "restore_cmdb_resource", {"resource_type": resource_type, "resource_id": resource_id}, mutates=True, ) as rt: from app.audit.service import AuditService from app.core.exceptions import ForbiddenError, NotFoundError, ValidationError from app.core.rbac import can_manage_cloud from app.domains.cmdb.models import ( CmdbCdnDomain, CmdbCertificate, CmdbDnsRecord, CmdbDomain, CmdbLoadBalancer, CmdbServer, ) from app.domains.cmdb.service import CmdbService if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "CMDB restore is restricted to cloud / global admins.", ) if resource_type not in _CMDB_RECYCLE_BIN_TYPES: raise ValidationError( f"resource_type must be one of " f"{_CMDB_RECYCLE_BIN_TYPES}; got {resource_type!r}", ) rid = _u(resource_id) model_map = { "server": (CmdbServer, "cmdb_server"), "domain": (CmdbDomain, "cmdb_domain"), "dns_record": (CmdbDnsRecord, "cmdb_dns_record"), "load_balancer": (CmdbLoadBalancer, "cmdb_load_balancer"), "cdn_domain": (CmdbCdnDomain, "cmdb_cdn_domain"), "certificate": (CmdbCertificate, "cmdb_certificate"), } model_cls, audit_resource_type = model_map[resource_type] result = await CmdbService.restore_resource( rt.db, model=model_cls, resource_id=str(rid), actor_person_id=str(rt.user.id), ) if result is None: raise NotFoundError(audit_resource_type, str(rid)) row, forensics = result audit = AuditService(rt.db) await audit.log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="cmdb.resource.restore", status="success", resource_type=audit_resource_type, resource_id=str(rid), detail={"restored": True, "via": "mcp", **forensics}, ) return { "id": str(row.id), "resource_type": audit_resource_type, "restored": True, } # ════════════════════════════════════════════════════════════════════ # SSO grants self-service (migration 101) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="submit_sso_grant_request", description=( "Submit a request to be granted access to a third-party system " "via SSO. The Feishu approval flow notifies the system owner; " "approval flips status to 'approved'. system_owner_person_id is " "the person who manages the target system (often yourself if " "you ARE the owner registering the grant). Reason is required." ), ) async def submit_sso_grant_request( sso_provider: str, system_name: str, system_owner_person_id: str, reason: str, acs_url: str | None = None, entity_id: str | None = None, x509_cert: str | None = None, saml_metadata_xml: str | None = None, ) -> dict: async with tool_runtime( "submit_sso_grant_request", { "sso_provider": sso_provider, "system_name": system_name, "system_owner_person_id": system_owner_person_id, "reason": reason, "has_acs_url": bool(acs_url), "has_entity_id": bool(entity_id), "has_x509_cert": bool(x509_cert), "has_saml_metadata_xml": bool(saml_metadata_xml), }, mutates=True, ) as rt: from app.domains.sso.router import SsoGrantSubmitBody from app.domains.sso.service import SsoGrantService # Run the inputs through the SAME Pydantic body the HTTP route # uses — enforces sso_provider 1-64, system_name 1-255, reason # 1-2000, acs_url 0-1024, entity_id 0-1024, x509_cert 0-20_000, # saml_metadata_xml 0-1_000_000, and validates # system_owner_person_id is non-empty. Without this an LLM # client could DOS the server with multi-MB free-text payloads # or bypass min_length checks. The SAML attachment fields are # required for Google SAML grants (service rejects empty Google # submissions with "Google needs at least one shape") — pre-fix # they were unreachable via MCP. body = SsoGrantSubmitBody( sso_provider=sso_provider, system_name=system_name, system_owner_person_id=system_owner_person_id, reason=reason, acs_url=acs_url, entity_id=entity_id, x509_cert=x509_cert, saml_metadata_xml=saml_metadata_xml, ) # And validate the system-owner ID parses as a UUID. _u(body.system_owner_person_id) svc = SsoGrantService(rt.db) grant = await svc.submit( requester=rt.user, sso_provider=body.sso_provider, system_name=body.system_name, system_owner_person_id=body.system_owner_person_id, reason=body.reason, acs_url=body.acs_url, entity_id=body.entity_id, x509_cert=body.x509_cert, saml_metadata_xml=body.saml_metadata_xml, trace_id=_trace_str(), ) return { "id": grant.id, "person_id": grant.person_id, "sso_provider": grant.sso_provider, "system_name": grant.system_name, "system_owner_person_id": grant.system_owner_person_id, "status": grant.status, "feishu_instance_code": grant.feishu_instance_code, "created_at": _iso_utc(grant.created_at), } @mcp_server.tool( name="list_my_sso_grants", description=( "List YOUR SSO grants. status filter optional. Returns grants " "where person_id == you — never anyone else's." ), ) async def list_my_sso_grants( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_my_sso_grants", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.sso.models import SsoGrant q = select(SsoGrant).where(SsoGrant.person_id == str(rt.user.id)) if status: q = q.where(SsoGrant.status == status) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(SsoGrant.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "grants": [ { "id": r.id, "sso_provider": r.sso_provider, "system_name": r.system_name, "system_owner_label": r.system_owner_label, "status": r.status, "reason": r.reason, "feishu_instance_code": r.feishu_instance_code, "granted_at": ( _iso_utc(r.granted_at) ), "revoked_at": ( _iso_utc(r.revoked_at) ), "created_at": _iso_utc(r.created_at), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_sso_grant", description=( "Cancel one of YOUR pending SSO grant requests. The service " "rejects (403) attempts to cancel someone else's, and (409) " "attempts to cancel a non-pending row." ), ) async def cancel_my_sso_grant(grant_id: str) -> dict: async with tool_runtime( "cancel_my_sso_grant", {"grant_id": grant_id}, mutates=True, ) as rt: from app.domains.sso.service import SsoGrantService svc = SsoGrantService(rt.db) grant = await svc.cancel( grant_id=str(_u(grant_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": grant.id, "status": grant.status, "updated_at": _iso_utc(grant.updated_at), "feishu_cancel_synced": svc.last_feishu_cancel_synced, } @mcp_server.tool( name="list_sso_grants", description=( "List SSO grants across ALL employees, with optional status and " "person filters. This is the admin counterpart to " "``list_my_sso_grants`` — use it to answer 'who holds access to " "system X' or 'which grants are still pending'. Read-only. " "IT-admin only." ), ) async def list_sso_grants( status: str | None = None, person_id: str | None = None, system_name: str | None = None, removal: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_sso_grants", { "status": status, "person_id": person_id, "system_name": system_name, "removal": removal, "page": page, "page_size": page_size, }, mutates=False, ) as rt: _require_identity_sync_admin(rt) from app.domains.sso.models import ( REMOVAL_OPEN_STATUSES, REMOVAL_STATUSES, SsoGrant, ) from app.domains.sso.removal import SsoRemovalService q = select(SsoGrant) if status: q = q.where(SsoGrant.status == status) if person_id: q = q.where(SsoGrant.person_id == str(_u(person_id))) if system_name: q = q.where(SsoGrant.system_name.ilike(f"%{system_name}%")) if removal: # Removal-lifecycle filter (migration 603). ``open`` = the three # states where the IdP-side removal is not yet established. if removal == "open": q = q.where(SsoGrant.removal_status.in_(REMOVAL_OPEN_STATUSES)) elif removal == "check_error": q = q.where(SsoGrant.removal_check_error.isnot(None)) elif removal in REMOVAL_STATUSES: q = q.where(SsoGrant.removal_status == removal) else: raise ValueError( "removal must be one of open / check_error / " + " / ".join(REMOVAL_STATUSES) ) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(SsoGrant.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "grants": [ { "id": r.id, "person_id": r.person_id, "sso_provider": r.sso_provider, "system_name": r.system_name, "system_owner_label": r.system_owner_label, "status": r.status, "reason": r.reason, "created_app_id": r.created_app_id, "granted_at": _iso_utc(r.granted_at), "revoked_at": _iso_utc(r.revoked_at), "revoke_reason": r.revoke_reason, "created_at": _iso_utc(r.created_at), **SsoRemovalService.serialize(r), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="revoke_sso_grant", description=( "Revoke an APPROVED SSO grant belonging to any employee. Revoking " "records the decision in Helix and OPENS the IdP-side removal task " "for the system owner (IT when there is no active owner): the task " "can only be completed with evidence, the daily reconcile reminds on " "a widening ladder, opens a compliance incident at day 14, and for " "Google-IdP grants verifies against the SAML login audit " "(``removal_status`` in the result: pending → confirmed → verified, " "or contradicted). Only 'approved' grants can be revoked (409 " "otherwise); use ``cancel_my_sso_grant`` for a still-pending " "request. Decides on someone ELSE's access, so IT-admin only." ), ) async def revoke_sso_grant(grant_id: str, reason: str | None = None) -> dict: async with tool_runtime( "revoke_sso_grant", {"grant_id": grant_id, "reason": reason}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.sso.removal import SsoRemovalService from app.domains.sso.service import SsoGrantService svc = SsoGrantService(rt.db) grant = await svc.revoke( grant_id=str(_u(grant_id)), actor=rt.user, reason=reason, trace_id=_trace_str(), ) return { "id": grant.id, "status": grant.status, "revoked_at": _iso_utc(grant.revoked_at), "revoke_reason": grant.revoke_reason, **SsoRemovalService.serialize(grant), } @mcp_server.tool( name="sso_removal_summary", description=( "Status strip for SSO revocation follow-through: how many revoked " "grants still have NO established IdP-side removal (pending / " "contradicted / unverified_legacy), how many are overdue past the " "incident threshold, how many could not be verified against the IdP " "(and why), plus the cadence settings in force (reminder ladder, " "verified_after_days, incident_after_days). Read-only; admin, " "auditor and management." ), ) async def sso_removal_summary() -> dict: async with tool_runtime("sso_removal_summary", {}, mutates=False) as rt: _require_admin_read(rt.user.role_names) from app.domains.sso.removal import SsoRemovalService return await SsoRemovalService(rt.db).summary() @mcp_server.tool( name="open_sso_grant_removal_tasks", description=( "Turn revoked SSO grants that never had an IdP-side removal task " "(``removal_status='unverified_legacy'`` — revoked before the " "removal lifecycle existed — or a task-less ``pending``) into real " "work: one manual_notify provisioning task per grant, assigned to " "the system owner (IT fallback), evidence required to complete. " "Returns which ids were opened and which were skipped with a reason. " "IT-admin only." ), ) async def open_sso_grant_removal_tasks(grant_ids: list[str]) -> dict: async with tool_runtime( "open_sso_grant_removal_tasks", {"grant_ids": grant_ids}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.sso.removal import SsoRemovalService ids = [str(_u(g)) for g in grant_ids][:200] return await SsoRemovalService(rt.db).open_review_for_legacy( ids, actor_user_id=rt.user.id, trace_id=_trace_str(), ) @mcp_server.tool( name="verify_sso_grant_removal", description=( "Run the IdP evidence check for one revoked SSO grant NOW (Google " "IdP only): reads Admin Reports' SAML login audit for the holder. A " "login after the revoke → ``contradicted`` (task re-opened, owner + " "IT paged); no login and the account/app history aligns → " "``verified`` once enough days have passed since the human " "confirmation; an app name the IdP has never used for this person → " "``unverifiable`` (recorded, not guessed). Bypasses the once-a-day " "floor. IT-admin only." ), ) async def verify_sso_grant_removal(grant_id: str) -> dict: async with tool_runtime( "verify_sso_grant_removal", {"grant_id": grant_id}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from datetime import UTC as _UTC from datetime import datetime as _dt from app.core.coordinators.access_certification_coordinator import ( sync_cert_items_for_sso_grant, ) from app.core.exceptions import NotFoundError as _NotFound from app.domains.sso.models import SsoGrant from app.domains.sso.removal import ( SsoRemovalService, google_driver_for_org, load_removal_settings, ) grant = await rt.db.get(SsoGrant, str(_u(grant_id))) if grant is None: raise _NotFound("SsoGrant", grant_id) settings = await load_removal_settings(rt.db) driver = await google_driver_for_org(rt.db) svc = SsoRemovalService(rt.db) now = _dt.now(_UTC) outcome = await svc.verify_grant( grant, settings=settings, now=now, driver=driver, trace_id=_trace_str(), force=True, ) if outcome.get("result") in ("verified", "contradicted"): await sync_cert_items_for_sso_grant( rt.db, grant_id=grant.id, removal_status=grant.removal_status, now=now, ) return {"outcome": outcome, **SsoRemovalService.serialize(grant), "id": grant.id} @mcp_server.tool( name="set_sso_app_idp_config", description=( "Record the IdP-side SAML config Google generated for a catalog " "app (SSO URL, Entity ID, X.509 certificate) and optionally " "deliver it to the employee who requested the system. Google is " "the IdP in this flow, so these three values are what the " "requester must configure on the vendor side — without this hop " "the integration never works. Pass deliver=false to save " "partially (e.g. URL and Entity ID before the certificate). " "deliver=true notifies the requester and stamps the delivery; it " "requires all three values plus a linked grant, and refuses " "(409) instead of recording a delivery that did not happen. " "IT-admin only." ), ) async def set_sso_app_idp_config( app_id: str, idp_sso_url: str | None = None, idp_entity_id: str | None = None, idp_certificate: str | None = None, deliver: bool = False, ) -> dict: async with tool_runtime( "set_sso_app_idp_config", { "app_id": app_id, "has_sso_url": bool(idp_sso_url), "has_entity_id": bool(idp_entity_id), "has_certificate": bool(idp_certificate), "deliver": deliver, }, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.sso.app_catalog_service import ( # noqa: PLC0415 set_app_idp_config, ) from app.identity.person_models import IdentityPerson # noqa: PLC0415 actor_person_id: str | None = None person = await rt.db.scalar( select(IdentityPerson).where( IdentityPerson.work_email == rt.user.email, IdentityPerson.deleted_at.is_(None), ) ) if person is not None: actor_person_id = str(person.id) outcome = await set_app_idp_config( rt.db, str(_u(app_id)), idp_sso_url=idp_sso_url, idp_entity_id=idp_entity_id, idp_certificate=idp_certificate, deliver=deliver, actor_user_id=str(rt.user.id), actor_person_id=actor_person_id, trace_id=_trace_str(), ) # Annotated rather than returned inline: ``serialize`` is untyped, so # ``return serialize(...)`` trips the no-any-return ratchet. payload: dict = serialize(outcome) return payload # ════════════════════════════════════════════════════════════════════ # Domain registrations self-service (migration 102) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="submit_domain_registration", description=( "Register a domain you (will) own with the company registry. " "Triggers a Feishu approval flow that loops in IT; on approval " "the row is marked 'approved' and you become the registered " "owner. domain_name is the FQDN, purpose is the reason." ), ) async def submit_domain_registration( domain_name: str, purpose: str, ) -> dict: async with tool_runtime( "submit_domain_registration", {"domain_name": domain_name}, mutates=True, ) as rt: from app.domains.domain_registration.router import ( DomainRegistrationSubmitBody, ) from app.domains.domain_registration.service import ( DomainRegistrationService, ) # Same Pydantic body the HTTP route validates against — # domain_name 1-253, purpose 1-2000. body = DomainRegistrationSubmitBody( domain_name=domain_name, purpose=purpose, ) svc = DomainRegistrationService(rt.db) row = await svc.submit( requester=rt.user, domain_name=body.domain_name, purpose=body.purpose, trace_id=_trace_str(), ) return { "id": row.id, "person_id": row.person_id, "domain_name": row.domain_name, "purpose": row.purpose, "status": row.status, "feishu_instance_code": row.feishu_instance_code, "created_at": _iso_utc(row.created_at), } @mcp_server.tool( name="request_identity_consumer", description=( "Apply to register an identity consumer (a downstream system that " "pulls people/org data from Helix's Directory API / SCIM). Triggers " "the Feishu approval flow; on approval the consumer is provisioned " "and you receive a one-time token-issue link. You become the owner " "unless an owner is set on the web form. scopes ⊆ " "{directory.persons:read, directory.org:read, directory.pii:read}; " "granted_fields optionally narrows the person fields (least " "privilege) — omit for the full bundle. token_validity_days is a " "preset (30/90/180/365) or null=never. PII fields require " "pii_justification. Population filter defaults to active employees " "— use the web form for a custom population." ), ) async def request_identity_consumer( name: str, slug: str, scopes: list[str], description: str | None = None, granted_fields: list[str] | None = None, rate_limit_per_minute: int | None = None, token_validity_days: int | None = None, pii_justification: str | None = None, ) -> dict: async with tool_runtime( "request_identity_consumer", {"slug": slug}, mutates=True, ) as rt: from app.identity.consumers.request_schemas import ConsumerRequestCreate from app.identity.consumers.request_service import ConsumerRequestService body = ConsumerRequestCreate( name=name, slug=slug, scopes=scopes, description=description, granted_fields=granted_fields, rate_limit_per_minute=rate_limit_per_minute, token_validity_days=token_validity_days, pii_justification=pii_justification, ) req = await ConsumerRequestService(rt.db).submit( requester=rt.user, body=body, trace_id=_trace_str(), ) return { "id": str(req.id), "slug": req.slug, "name": req.name, "status": req.status, "scopes": list(req.scopes or []), "granted_fields": list(req.granted_fields or []), "feishu_instance_code": req.feishu_instance_code, "created_at": _iso_utc(req.created_at), } @mcp_server.tool( name="list_my_domain_registrations", description=( "List YOUR registered / pending domain registrations. status " "filter optional. Self-scoped — never returns someone else's." ), ) async def list_my_domain_registrations( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_my_domain_registrations", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.domain_registration.models import DomainRegistration q = select(DomainRegistration).where( DomainRegistration.person_id == str(rt.user.id), ) if status: q = q.where(DomainRegistration.status == status) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(DomainRegistration.created_at.desc()) .offset((page - 1) * page_size).limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "registrations": [ { "id": r.id, "domain_name": r.domain_name, "purpose": r.purpose, "status": r.status, "feishu_instance_code": r.feishu_instance_code, "granted_at": ( _iso_utc(r.granted_at) ), "revoked_at": ( _iso_utc(r.revoked_at) ), "created_at": _iso_utc(r.created_at), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_domain_registration", description=( "Cancel one of YOUR pending domain registrations. Service " "rejects (403) on someone else's row, (409) on non-pending rows." ), ) async def cancel_my_domain_registration(reg_id: str) -> dict: async with tool_runtime( "cancel_my_domain_registration", {"reg_id": reg_id}, mutates=True, ) as rt: from app.domains.domain_registration.service import ( DomainRegistrationService, ) svc = DomainRegistrationService(rt.db) row = await svc.cancel( reg_id=str(_u(reg_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": row.id, "status": row.status, "updated_at": _iso_utc(row.updated_at), "feishu_cancel_synced": svc.last_feishu_cancel_synced, } @mcp_server.tool( name="handover_domain_registration", description=( "Transfer an APPROVED domain registration to a successor " "person. Mirrors HTTP `POST /domain-registrations/{reg_id}/" "handover` — admin-only (it_admin). The current holder's row " "is rewritten with the new ``person_id`` and ``handover_at`` " "is stamped; ``handover_from_person_id`` preserves the prior " "owner. Service rejects (409) on non-approved rows and (400) " "if the successor is the current holder. ``successor_person_id`` " "must be the IdentityPerson UUID — use search_directory_people " "to resolve a human name (or partial work email) to one." ), ) async def handover_domain_registration( reg_id: str, successor_person_id: str, ) -> dict: async with tool_runtime( "handover_domain_registration", {"reg_id": reg_id, "successor_person_id": successor_person_id}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.domain_registration.service import ( DomainRegistrationService, ) svc = DomainRegistrationService(rt.db) row = await svc.handover( reg_id=str(_u(reg_id)), successor_person_id=str(_u(successor_person_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": row.id, "domain_name": row.domain_name, "status": row.status, "person_id": row.person_id, "handover_from_person_id": row.handover_from_person_id, "handover_at": ( _iso_utc(row.handover_at) ), "updated_at": _iso_utc(row.updated_at), } @mcp_server.tool( name="revoke_domain_registration", description=( "Revoke an APPROVED domain registration (record the row as " "``revoked`` with an optional reason). Mirrors HTTP " "`POST /domain-registrations/{reg_id}/revoke` — admin-only " "(it_admin). The row's ``status`` flips to ``revoked`` and " "``revoked_at`` / ``revoke_reason`` are stamped; the original " "owner FK is preserved so audit forensics can still trace who " "held the registration. Service rejects (409) on non-approved " "rows. ``reason`` is a free-text note ≤ 255 chars (use None " "to skip)." ), ) async def revoke_domain_registration( reg_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "revoke_domain_registration", {"reg_id": reg_id, "reason_present": reason is not None}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.domain_registration.service import ( DomainRegistrationService, ) svc = DomainRegistrationService(rt.db) row = await svc.revoke( reg_id=str(_u(reg_id)), actor=rt.user, reason=reason, trace_id=_trace_str(), ) return { "id": row.id, "domain_name": row.domain_name, "status": row.status, "revoked_at": ( _iso_utc(row.revoked_at) if row.revoked_at else None ), "revoke_reason": row.revoke_reason, "updated_at": _iso_utc(row.updated_at), } @mcp_server.tool( name="annotate_domain", description=( "Edit metadata on a domain — owner, ICP filing number, environment " "label, DNS-request gate, free-text remark, and (migration 218) the " "sync-owned columns ``registrar`` / ``expiry_date`` / ``auto_renew`` " "for rows where no live cloud driver writes them (off-platform " "registrars like GoDaddy / Markmonitor / Namecheap, or legacy " "imports tagged under integrations without a domain driver such " "as UCloud). Mirrors HTTP `PATCH /api/v1/cmdb/domains/{domain_id}` " "— restricted to ``global_admin`` and ``cloud_admin`` via " "``can_manage_cloud``. Pass only the fields you want to change; " "unset kwargs are NOT treated as ``set to None`` so a no-op call " "cannot accidentally clear an existing owner. Touching ANY of " "{registrar, expiry_date, auto_renew} flips ``data_source`` to " "``manual`` so the next cloud sync that returns the same domain " "still wins (it re-stamps ``synced`` — cloud is authoritative). " "``expiry_date`` is an ISO ``yyyy-mm-dd`` date string; ``null`` " "clears the column. ``auto_renew`` MUST be true or false — " "sending ``null`` is rejected (the NOT NULL column would otherwise " "silently coerce to False). Owner transitions are recorded in " "``cmdb_network_owner_history``; the audit row " "``cmdb.domain.annotate`` captures before / after for ALL touched " "fields including ``data_source``." ), ) async def annotate_domain( domain_id: str, owner_person_id: str | None = None, icp_filing_no: str | None = None, environment: str | None = None, allow_dns_request: bool | None = None, grade: str | None = None, possessor: str | None = None, remark: str | None = None, registrar: str | None = None, expiry_date: str | None = None, auto_renew: bool | None = None, ) -> dict: """MCP-tool note on null semantics — divergence from HTTP: The MCP transport delivers tool args as Python kwargs and cannot distinguish "client omitted the argument" from "client supplied JSON ``null``". To preserve the "unset = no change" contract we skip every kwarg whose value is ``None`` before building the Pydantic body. Two real-world consequences vs. the HTTP twin: * ``auto_renew=None`` is SILENTLY treated as "no change" by MCP, whereas HTTP ``{"auto_renew": null}`` returns 422. MCP clients cannot trigger the explicit-null rejection; they MUST send a literal ``true`` / ``false`` or omit. The HTTP path is the source-of-truth for that contract. * ``expiry_date=None`` / ``registrar=None`` cannot CLEAR those columns via MCP (the kwargs are filtered as "omitted"). HTTP PATCH with explicit ``null`` does clear. If you need to clear, use the HTTP API or send ``registrar=""`` (empty string — service normalises to NULL). Owner is by ``owner_person_id`` (UUID) only — ``CmdbDomain`` has no ``owner_raw`` column (was dropped in migration 042; only LB / CDN / object_storage still carry it). A copy-paste from ``annotate_cdn_domain`` would crash on the first ``pre.owner_raw`` access — round-2 review catch. """ async with tool_runtime( "annotate_domain", { "domain_id": domain_id, "owner_set": owner_person_id is not None, "icp_set": icp_filing_no is not None, "env_set": environment is not None, "allow_dns_set": allow_dns_request is not None, "grade_set": grade is not None, "possessor_set": possessor is not None, "remark_set": remark is not None, # The three sync-owned columns get individual "_set" flags so # the audit trail surfaces precisely which provenance-breaking # field an LLM client touched (the bare "this call flipped # data_source to manual" signal isn't enough — admins want # "the bot edited the expiry, not the registrar"). "registrar_set": registrar is not None, "expiry_set": expiry_date is not None, "auto_renew_set": auto_renew is not None, }, mutates=True, ) as rt: from datetime import date as _date_type from app.audit.service import AuditService from app.core.exceptions import ( ForbiddenError, NotFoundError, ValidationError, ) from app.core.rbac import can_manage_cloud from app.domains.cmdb.models import CmdbDomain from app.domains.cmdb.schemas import DomainAnnotation from app.domains.cmdb.service import CmdbService if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Domain annotation is restricted to cloud / global admins.", ) dom_uuid = _u(domain_id) # Build the annotation Pydantic model from ONLY the kwargs the # caller actually passed so ``model_fields_set`` matches the HTTP # body contract the service expects. Passing all kwargs (including # default None) would mark every field as "set" and clear unrelated # columns on every call — same bug class the # ``annotate_cdn_domain`` docstring above warns about. payload: dict[str, Any] = {} if owner_person_id is not None: payload["owner_person_id"] = _u(owner_person_id) if icp_filing_no is not None: payload["icp_filing_no"] = icp_filing_no if environment is not None: payload["environment"] = environment if allow_dns_request is not None: payload["allow_dns_request"] = allow_dns_request if grade is not None: payload["grade"] = grade if possessor is not None: payload["possessor"] = possessor if remark is not None: payload["remark"] = remark if registrar is not None: payload["registrar"] = registrar if expiry_date is not None: # Pydantic accepts ISO strings for ``date`` fields but rejects # malformed ones with a 422-friendly message; pre-parse here # so the MCP error envelope carries the precise reason instead # of an opaque "Pydantic validation failed". try: payload["expiry_date"] = _date_type.fromisoformat(expiry_date) except ValueError as exc: raise ValidationError( f"expiry_date must be ISO yyyy-mm-dd (got {expiry_date!r}): {exc}" ) from exc if auto_renew is not None: payload["auto_renew"] = auto_renew body = DomainAnnotation.model_validate(payload) # Capture before-snapshot for the audit row — mirrors the HTTP # path so the audit_logs surface is identical regardless of # whether the call came via HTTP or MCP. Includes all sync-owned # columns + data_source so a forensic query can answer "who # flipped pingcap.com auto-renew on" from the log alone. # ``CmdbDomain`` has no ``owner_raw`` column (dropped in # migration 042) so we don't read it — mirrors HTTP router. pre = await rt.db.scalar( select(CmdbDomain).where( CmdbDomain.id == dom_uuid, CmdbDomain.deleted_at.is_(None), ), ) before_owner = pre.owner_person_id if pre else None before_icp = pre.icp_filing_no if pre else None before_env = pre.environment if pre else None before_allow = pre.allow_dns_request if pre else None before_registrar = pre.registrar if pre else None before_expiry = ( pre.expiry_date.isoformat() if pre and pre.expiry_date else None ) before_auto_renew = pre.auto_renew if pre else None before_data_source = pre.data_source if pre else None trace_id = _trace_str() domain = await CmdbService.annotate_domain( rt.db, str(dom_uuid), body, actor_person_id=str(rt.user.id), trace_id=trace_id, ) if domain is None: raise NotFoundError("Domain", domain_id) audit = AuditService(rt.db) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action="cmdb.domain.annotate", status="success", resource_type="cmdb_domain", resource_id=str(dom_uuid), detail={ "patch": body.model_dump(exclude_unset=True, mode="json"), "before": { "owner_person_id": before_owner, "icp_filing_no": before_icp, "environment": before_env, "allow_dns_request": before_allow, "registrar": before_registrar, "expiry_date": before_expiry, "auto_renew": before_auto_renew, "data_source": before_data_source, }, "after": { "owner_person_id": domain.owner_person_id, "icp_filing_no": domain.icp_filing_no, "environment": domain.environment, "allow_dns_request": domain.allow_dns_request, "registrar": domain.registrar, "expiry_date": ( domain.expiry_date.isoformat() if domain.expiry_date else None ), "auto_renew": domain.auto_renew, "data_source": domain.data_source, }, }, ) return { "id": str(domain.id), "domain_name": domain.domain_name, "owner_person_id": ( str(domain.owner_person_id) if domain.owner_person_id else None ), "registrar": domain.registrar, "expiry_date": ( domain.expiry_date.isoformat() if domain.expiry_date else None ), "auto_renew": domain.auto_renew, "icp_filing_no": domain.icp_filing_no, "environment": domain.environment, "allow_dns_request": domain.allow_dns_request, "data_source": domain.data_source, } @mcp_server.tool( name="annotate_cdn_domain", description=( "Edit metadata on a CDN domain — owner, certificate id, free-" "text remark. Mirrors HTTP `PATCH /api/v1/cmdb/cdn-domains/" "{cdn_id}` — restricted to ``global_admin`` and ``cloud_admin`` " "via ``can_manage_cloud`` (2026-05-20: cloud_admin promoted from " "perpetual-grant marker to real cloud-domain authority). Pass " "only the fields you want to " "change; unset kwargs are NOT treated as ``set to None`` so a " "no-op call cannot accidentally clear an existing owner. " "``owner_person_id`` is an IdentityPerson UUID; ``owner_raw`` " "is the upstream provider's free-text owner string. " "``cert_id`` accepts up to 128 chars; ``remark`` up to 2000. " "Owner transitions are recorded in " "``cmdb_network_owner_history``." ), ) async def annotate_cdn_domain( cdn_id: str, owner_person_id: str | None = None, owner_raw: str | None = None, cert_id: str | None = None, remark: str | None = None, ) -> dict: async with tool_runtime( "annotate_cdn_domain", { "cdn_id": cdn_id, "owner_set": owner_person_id is not None or owner_raw is not None, "cert_set": cert_id is not None, "remark_set": remark is not None, }, mutates=True, ) as rt: from app.audit.service import AuditService from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_cloud from app.domains.cmdb.models import CmdbCdnDomain from app.domains.cmdb.schemas import CdnDomainAnnotation from app.domains.cmdb.service import CmdbService if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "CDN annotation is restricted to cloud / global admins.", ) # Build the annotation pydantic model from ONLY the kwargs the # caller actually passed so ``model_fields_set`` matches the # HTTP body the service expects. Using ``model_construct`` / # passing all kwargs would mark every field as "set" and clear # owner_raw to None on every call. payload: dict[str, Any] = {} if owner_person_id is not None: payload["owner_person_id"] = _u(owner_person_id) if owner_raw is not None: payload["owner_raw"] = owner_raw if cert_id is not None: payload["cert_id"] = cert_id if remark is not None: payload["remark"] = remark body = CdnDomainAnnotation.model_validate(payload) # Capture before-snapshot for the audit row, mirroring the # router's pattern so the audit_logs surface is identical # whether the call came via HTTP or MCP. pre = await rt.db.scalar( select(CmdbCdnDomain).where( CmdbCdnDomain.id == _u(cdn_id), CmdbCdnDomain.deleted_at.is_(None), ), ) before_owner = pre.owner_person_id if pre else None before_owner_raw = pre.owner_raw if pre else None before_cert = pre.cert_id if pre else None trace_id = _trace_str() cdn = await CmdbService.annotate_cdn_domain( rt.db, str(_u(cdn_id)), body, actor_person_id=str(rt.user.id), trace_id=trace_id, ) if cdn is None: raise NotFoundError("CdnDomain", cdn_id) # ``cmdb.cdn_domain.annotate`` audit row — same action / # resource_type the HTTP route writes. tool_runtime also # writes an mcp.annotate_cdn_domain row capturing the AI- # client side; together they answer both "what changed on # this CDN" and "what did the LLM do". audit = AuditService(rt.db) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action="cmdb.cdn_domain.annotate", status="success", resource_type="cmdb_cdn_domain", resource_id=str(_u(cdn_id)), detail={ "patch": body.model_dump(exclude_unset=True, mode="json"), "before": { "owner_person_id": before_owner, "owner_raw": before_owner_raw, "cert_id": before_cert, }, "after": { "owner_person_id": cdn.owner_person_id, "owner_raw": cdn.owner_raw, "cert_id": cdn.cert_id, }, }, ) return { "id": str(cdn.id), "domain_name": cdn.domain_name, "owner_person_id": ( str(cdn.owner_person_id) if cdn.owner_person_id else None ), "owner_raw": cdn.owner_raw, "cert_id": cdn.cert_id, "remark": cdn.remark, } # ════════════════════════════════════════════════════════════════════ # VPN application (Feilian Full Mode, migration 105 from PR #149) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="submit_vpn_application", description=( "Apply for VPN Full Mode access via the Feishu approval flow. " "vpn_node is the geographic node (e.g. 'beijing', 'shanghai', " "'singapore' — the registered set is enforced by the regex). " "usage_description: why you need Full Mode. resource_checklist: " "internal IP / domain / port list you'll be reaching." ), ) async def submit_vpn_application( vpn_node: str, usage_description: str, resource_checklist: str, ) -> dict: async with tool_runtime( "submit_vpn_application", { "vpn_node": vpn_node, "usage_description": usage_description, "resource_checklist": resource_checklist, }, mutates=True, ) as rt: from app.domains.vpn_application.router import ( VpnApplicationSubmitBody, ) from app.domains.vpn_application.service import VpnApplicationService # Same Pydantic body the HTTP route validates against — vpn_node # regex-checked against the registered node set, usage_description # 1-2000, resource_checklist 1-2000. body = VpnApplicationSubmitBody( vpn_node=vpn_node, usage_description=usage_description, resource_checklist=resource_checklist, ) svc = VpnApplicationService(rt.db) row = await svc.submit( requester=rt.user, vpn_node=body.vpn_node, usage_description=body.usage_description, resource_checklist=body.resource_checklist, trace_id=_trace_str(), ) return { "id": row.id, "person_id": row.person_id, "vpn_node": row.vpn_node, "usage_description": row.usage_description, "resource_checklist": row.resource_checklist, "status": row.status, "feishu_instance_code": row.feishu_instance_code, "created_at": _iso_utc(row.created_at), } @mcp_server.tool( name="list_my_vpn_applications", description=( "List YOUR VPN applications. Self-scoped. status filter optional." ), ) async def list_my_vpn_applications( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_my_vpn_applications", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.vpn_application.models import VpnApplication q = select(VpnApplication).where( VpnApplication.person_id == str(rt.user.id), ) if status: q = q.where(VpnApplication.status == status) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(VpnApplication.created_at.desc()) .offset((page - 1) * page_size).limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "applications": [ { "id": r.id, "vpn_node": r.vpn_node, "usage_description": r.usage_description, "resource_checklist": r.resource_checklist, "status": r.status, "feishu_instance_code": r.feishu_instance_code, "granted_at": ( _iso_utc(r.granted_at) ), "revoked_at": ( _iso_utc(r.revoked_at) ), "created_at": _iso_utc(r.created_at), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_vpn_application", description=( "Cancel one of YOUR pending VPN applications. Service rejects " "(403) on someone else's, (409) on non-pending rows." ), ) async def cancel_my_vpn_application(app_id: str) -> dict: async with tool_runtime( "cancel_my_vpn_application", {"app_id": app_id}, mutates=True, ) as rt: from app.domains.vpn_application.service import VpnApplicationService svc = VpnApplicationService(rt.db) row = await svc.cancel( app_id=str(_u(app_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": row.id, "status": row.status, "updated_at": _iso_utc(row.updated_at), "feishu_cancel_synced": svc.last_feishu_cancel_synced, } # ════════════════════════════════════════════════════════════════════ # Announcement requests (migration 106 from PR #149) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="submit_announcement_request", description=( "Submit a company-wide announcement for review. Routes through " "the Feishu approval flow; approval triggers the announcement " "publish. ``description`` is the full body of the announcement " "(up to 4000 chars)." ), ) async def submit_announcement_request(description: str) -> dict: async with tool_runtime( "submit_announcement_request", # Capture both the length AND a 2000-char head of the proposed # announcement body. The Pydantic schema allows up to 4000 chars; # ``_summarise_args`` further caps each string at 500 — too tight # for a forensic record of "what was actually proposed". The full # text lives durably in the announcement_request domain row; # audit gives a head + length so reviewers can confirm intent # without joining tables. { "description_len": len(description or ""), "description_head": (description or "")[:2000], }, mutates=True, ) as rt: from app.domains.announcement_request.router import ( AnnouncementRequestSubmitBody, ) from app.domains.announcement_request.service import ( AnnouncementRequestService, ) # Same Pydantic body the HTTP route validates against — # description 1-4000 chars. body = AnnouncementRequestSubmitBody(description=description) svc = AnnouncementRequestService(rt.db) row = await svc.submit( requester=rt.user, description=body.description, trace_id=_trace_str(), ) return { "id": row.id, "person_id": row.person_id, "description": row.description, "status": row.status, "feishu_instance_code": row.feishu_instance_code, "created_at": _iso_utc(row.created_at), } @mcp_server.tool( name="list_my_announcement_requests", description=( "List YOUR announcement requests. Self-scoped. status filter " "optional." ), ) async def list_my_announcement_requests( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_my_announcement_requests", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.announcement_request.models import ( AnnouncementRequest, ) q = select(AnnouncementRequest).where( AnnouncementRequest.person_id == str(rt.user.id), ) if status: q = q.where(AnnouncementRequest.status == status) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(AnnouncementRequest.created_at.desc()) .offset((page - 1) * page_size).limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "requests": [ { "id": r.id, "description": r.description, "status": r.status, "feishu_instance_code": r.feishu_instance_code, "granted_at": ( _iso_utc(r.granted_at) ), "created_at": _iso_utc(r.created_at), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_announcement_request", description=( "Cancel one of YOUR pending announcement requests. Service " "rejects (403) on someone else's, (409) on non-pending rows." ), ) async def cancel_my_announcement_request(req_id: str) -> dict: async with tool_runtime( "cancel_my_announcement_request", {"req_id": req_id}, mutates=True, ) as rt: from app.domains.announcement_request.service import ( AnnouncementRequestService, ) svc = AnnouncementRequestService(rt.db) row = await svc.cancel( req_id=str(_u(req_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": row.id, "status": row.status, "updated_at": _iso_utc(row.updated_at), "feishu_cancel_synced": svc.last_feishu_cancel_synced, } # ════════════════════════════════════════════════════════════════════ # AI tool applications + public registry (migration 107/108 from PR #149) # ════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="submit_ai_tool_application", description=( "Submit an AI tool / function intake for security review. " "how_to_use is a list of usage modes (e.g. 'web', 'api', " "'plugin'). data_level is a list of data sensitivity tiers " "your usage will touch. Both are validated server-side against " "the registry. purpose / data_type / privacy_policy must each " "be a non-empty description (up to 4000 chars). Approval " "publishes the row onto the public /company/ai-tools registry." ), ) async def submit_ai_tool_application( ai_name: str, how_to_use: list[str], data_level: list[str], purpose: str, data_type: str, privacy_policy: str, ) -> dict: async with tool_runtime( "submit_ai_tool_application", { "ai_name": ai_name, "how_to_use": how_to_use, "data_level": data_level, "purpose": purpose, "data_type": data_type, "privacy_policy": privacy_policy, }, mutates=True, ) as rt: from app.domains.ai_tool_application.router import ( AiToolApplicationSubmitBody, ) from app.domains.ai_tool_application.service import ( AiToolApplicationService, ) # Same Pydantic body the HTTP route validates against — ai_name # 1-255, the four free-text fields 1-4000 each, the two list # fields min_length=1. body = AiToolApplicationSubmitBody( ai_name=ai_name, how_to_use=how_to_use, data_level=data_level, purpose=purpose, data_type=data_type, privacy_policy=privacy_policy, ) svc = AiToolApplicationService(rt.db) row = await svc.submit( requester=rt.user, ai_name=body.ai_name, how_to_use=body.how_to_use, data_level=body.data_level, purpose=body.purpose, data_type=body.data_type, privacy_policy=body.privacy_policy, trace_id=_trace_str(), ) return { "id": row.id, "person_id": row.person_id, "ai_name": row.ai_name, "status": row.status, "feishu_instance_code": row.feishu_instance_code, "created_at": _iso_utc(row.created_at), } @mcp_server.tool( name="list_my_ai_tool_applications", description=( "List YOUR AI tool applications. Self-scoped. status filter " "optional." ), ) async def list_my_ai_tool_applications( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_my_ai_tool_applications", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.ai_tool_application.models import AiToolApplication q = select(AiToolApplication).where( AiToolApplication.person_id == str(rt.user.id), ) if status: q = q.where(AiToolApplication.status == status) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(AiToolApplication.created_at.desc()) .offset((page - 1) * page_size).limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "applications": [ { "id": r.id, "ai_name": r.ai_name, "status": r.status, "how_to_use": [ s.strip() for s in r.how_to_use.split(",") if s.strip() ], "data_level": [ s.strip() for s in r.data_level.split(",") if s.strip() ], "purpose": r.purpose, "data_type": r.data_type, "privacy_policy": r.privacy_policy, "feishu_instance_code": r.feishu_instance_code, "granted_at": ( _iso_utc(r.granted_at) ), "created_at": _iso_utc(r.created_at), } for r in rows ], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="cancel_my_ai_tool_application", description=( "Cancel one of YOUR pending AI tool applications. Service " "rejects (403) on someone else's, (409) on non-pending rows." ), ) async def cancel_my_ai_tool_application(app_id: str) -> dict: async with tool_runtime( "cancel_my_ai_tool_application", {"app_id": app_id}, mutates=True, ) as rt: from app.domains.ai_tool_application.service import ( AiToolApplicationService, ) svc = AiToolApplicationService(rt.db) row = await svc.cancel( app_id=str(_u(app_id)), actor=rt.user, trace_id=_trace_str(), ) return { "id": row.id, "status": row.status, "updated_at": _iso_utc(row.updated_at), "feishu_cancel_synced": svc.last_feishu_cancel_synced, } @mcp_server.tool( name="list_approved_ai_tools", description=( "Browse the company-wide registry of APPROVED AI tools. Visible " "to every authenticated employee — same data the public " "/company/ai-tools page renders. Use this as the lookup before " "submitting an AI tool application: if your tool is already on " "the list, you don't need a fresh review." ), ) async def list_approved_ai_tools( keyword: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: # The HTTP /approved route caps page_size at 200; mirror that here. page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_approved_ai_tools", {"keyword": keyword, "page": page, "page_size": page_size}, ) as rt: from app.domains.ai_tool_application.models import ( AI_TOOL_STATUS_APPROVED, AiToolApplication, ) q = select(AiToolApplication).where( AiToolApplication.status == AI_TOOL_STATUS_APPROVED, ) if keyword: # Escape LIKE metacharacters so user input like "%" or "_" # doesn't silently match every row. kw = keyword.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") q = q.where(AiToolApplication.ai_name.ilike(f"%{kw}%", escape="\\")) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 # MySQL/TiDB don't support NULLS LAST — emulate it. q = ( q.order_by( AiToolApplication.granted_at.is_(None), AiToolApplication.granted_at.desc(), AiToolApplication.created_at.desc(), ) .offset((page - 1) * page_size).limit(page_size) ) rows = (await rt.db.execute(q)).scalars().all() return { "tools": [ { "id": r.id, "ai_name": r.ai_name, "purpose": r.purpose, "how_to_use": [ s.strip() for s in r.how_to_use.split(",") if s.strip() ], "data_level": [ s.strip() for s in r.data_level.split(",") if s.strip() ], "granted_at": ( _iso_utc(r.granted_at) ), "created_at": _iso_utc(r.created_at), } for r in rows ], "total": total, "page": page, "page_size": page_size, } # ── Business-Unit / cost analytics / notifications (2026-05-03) ── @mcp_server.tool( name="list_business_units", description=( "List active Business Units (top-level org nodes — e.g. " "'Engineering Group', 'Finance', 'AI Innovation'). Visible " "to every authenticated user; the same data feeds the BU " "filter on the software-cost and AI-spending dashboards. " "Returns ``[{name, member_count}]`` sorted alphabetically." ), ) async def list_business_units() -> dict: async with tool_runtime("list_business_units", {}) as rt: from app.identity.bu_helpers import list_bus items = await list_bus(rt.db) return {"items": items, "total": len(items)} @mcp_server.tool( name="my_software_cost_summary", description=( "THIS MONTH's software-seat cost for YOU, split by billing cycle " "(monthly=full price, yearly=price/12, one-time=price only in its " "purchase month). Counts every seat billable this month INCLUDING " "any cancelled or tier-changed mid-month — monthly SaaS bills the " "whole month, no mid-cycle refund — so the figure matches the " "/my/software-costs live-view tab and the eventual closed bill. " "``active_seats`` counts only seats still live now. The per-cycle " "totals are ``null`` when the seats mix currencies (read " "``by_currency`` instead — a {currency: amount} map); " "``currency_warning`` flags that case. Read-only." ), ) async def my_software_cost_summary() -> dict: async with tool_runtime("my_software_cost_summary", {}) as rt: from app.domains.asset.router import _compute_cost_stats data = await _compute_cost_stats(rt.db, user_id=rt.user.id) # Trim the per-allocation list — the LLM rarely needs every # row, and a long ``items`` payload eats prompt budget. The # summary fields are the headline number; admins who need # detail call ``my_software_seats`` already. return { "total_monthly_cost": data["total_monthly_cost"], "total_yearly_cost": data["total_yearly_cost"], "total_one_time_cost": data["total_one_time_cost"], # Authoritative when currencies mix — the per-cycle totals # above are null in that case, so this is the only correct # spend figure. {currency: period_total}. "by_currency": data["by_currency"], "currency_warning": data["currency_warning"], "active_seats": data["active_seats"], "items_count": len(data["items"]), } @mcp_server.tool( name="subordinates_software_cost_summary", description=( "THIS MONTH's software-seat cost for the caller PLUS every direct " "and transitive report (BFS over identity_persons.manager_person_id, " "skipping employment_status='left'). Same this-month-bill semantics " "as my_software_cost_summary — includes seats cancelled / tier-" "changed mid-month (billed for the full month). Falls back to 'my' " "semantics when the caller has no reports — same behaviour " "as the HTTP /assets/cost-stats/subordinates endpoint. Per-cycle " "totals are ``null`` when the team's seats mix currencies (read " "``by_currency``); ``currency_warning`` flags that case." ), ) async def subordinates_software_cost_summary() -> dict: async with tool_runtime("subordinates_software_cost_summary", {}) as rt: from app.domains.asset.router import ( _compute_cost_stats, _resolve_subordinate_ids, ) visited = await _resolve_subordinate_ids(rt.db, rt.user.id) if len(visited) <= 1: data = await _compute_cost_stats(rt.db, user_id=rt.user.id) user_count = 1 else: data = await _compute_cost_stats(rt.db, user_ids=list(visited)) user_count = len(visited) return { "total_monthly_cost": data["total_monthly_cost"], "total_yearly_cost": data["total_yearly_cost"], "total_one_time_cost": data["total_one_time_cost"], "by_currency": data["by_currency"], "currency_warning": data["currency_warning"], "active_seats": data["active_seats"], "items_count": len(data["items"]), "user_count": user_count, } @mcp_server.tool( name="ai_spending_overview", description=( "AI-spending dashboard headline KPIs and the 4-bucket " "breakdown (seats / bailian / foundry / bedrock, + vertex when " "GCP whitelist is populated). Returns grand total, MoM/YoY %, " "active-user count, leaver residual, and per-bucket amount + " "share. Optional ``period`` (YYYY-MM) defaults to current " "month; optional ``bu`` narrows to a single Business Unit. " "Requires IT admin / finance / auditor role." ), ) async def ai_spending_overview( period: str | None = None, bu: str | None = None, ) -> dict: async with tool_runtime( "ai_spending_overview", {"period": period, "bu": bu}, ) as rt: _require_ai_spending_view(rt) from app.domains.ai_spending.service import AiSpendingService svc = AiSpendingService(rt.db) from datetime import UTC, datetime p = period or datetime.now(UTC).strftime("%Y-%m") data = await svc.get_overview(period=p, bu=bu) return serialize(data) @mcp_server.tool( name="ai_spending_movers", description=( "Top jumpers + droppers vs the previous period. ``dimension`` " "∈ {bucket, user, department, model, service}. ``bucket`` " "compares the 4 AI sources (seats / bailian / foundry / " "bedrock); the others slice across all buckets. Returns up " "to ``limit`` rows on each side. Requires IT admin / finance " "/ auditor role." ), ) async def ai_spending_movers( period: str | None = None, dimension: str = "user", limit: int = 10, bu: str | None = None, ) -> dict: async with tool_runtime( "ai_spending_movers", {"period": period, "dimension": dimension, "limit": limit, "bu": bu}, ) as rt: _require_ai_spending_view(rt) from app.core.exceptions import ValidationError if limit < 1 or limit > 50: raise ValidationError("limit must be between 1 and 50") from app.domains.ai_spending.service import AiSpendingService svc = AiSpendingService(rt.db) from datetime import UTC, datetime p = period or datetime.now(UTC).strftime("%Y-%m") data = await svc.get_movers( period=p, dimension=dimension, limit=limit, bu=bu, ) return serialize(data) @mcp_server.tool( name="ai_spending_forecast", description=( "Linear month-end projection: MTD total, daily pace, projected " "EOM total, and % vs previous month. Past months collapse to " "the actual total (days_remaining=0). Requires IT admin / " "finance / auditor role." ), ) async def ai_spending_forecast( period: str | None = None, bu: str | None = None, ) -> dict: async with tool_runtime( "ai_spending_forecast", {"period": period, "bu": bu}, ) as rt: _require_ai_spending_view(rt) from app.domains.ai_spending.service import AiSpendingService svc = AiSpendingService(rt.db) from datetime import UTC, datetime p = period or datetime.now(UTC).strftime("%Y-%m") data = await svc.get_forecast(period=p, bu=bu) return serialize(data) @mcp_server.tool( name="ai_spending_by_bu", description=( "AI spend rolled up per Business Unit for the period. Each row " "carries amount, share %, member count + active-user count so " "the LLM can spot a BU whose AI usage is concentrated in a " "few people. Requires IT admin / finance / auditor role." ), ) async def ai_spending_by_bu(period: str | None = None) -> dict: async with tool_runtime("ai_spending_by_bu", {"period": period}) as rt: _require_ai_spending_view(rt) from app.domains.ai_spending.service import AiSpendingService svc = AiSpendingService(rt.db) from datetime import UTC, datetime p = period or datetime.now(UTC).strftime("%Y-%m") data = await svc.get_by_bu(period=p) return serialize(data) @mcp_server.tool( name="my_notification_summary", description=( "Quick counts for the inbox bell: total + unread + most-recent " "created_at. Use this before listing if you only need the " "unread badge." ), ) async def my_notification_summary() -> dict: async with tool_runtime("my_notification_summary", {}) as rt: from app.notifications.inbox_service import InboxService svc = InboxService(rt.db) result = await svc.summary_for_recipient(recipient_id=rt.user.id) return serialize(result) # ─── Identity Sync Center (admin only) ─────────────────────────────── def _mask_idsync(value): """Display-boundary PII masking for identity-sync payload echoes — same helper the HTTP serializers use (phones / id numbers).""" from app.domains.identity_sync.service import mask_sensitive_display return mask_sensitive_display(value) def _require_identity_sync_admin(rt) -> None: """IT-admin-only gate shared across several admin surfaces. Mirrors ``require_admin()`` in app.core.dependencies but operates on the MCP runtime's already-resolved AuthUser. Originally added for the Identity Sync Center; subsequently reused by SSO catalog, offboarding blocker, scheduled-job ack, and other IT-admin tools so the error message is intentionally generic. """ from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE): raise ForbiddenError( "This action is restricted to IT (global) admins.", ) @mcp_server.tool( name="list_identity_sync_targets", description=( "List all configured outbound identity-sync targets (Navan, " "Google Workspace dept push, generic SCIM endpoints, …). " "Returns each target's id, slug, kind, enabled state, last " "run summary, and next scheduled run. Requires IT admin role." ), ) async def list_identity_sync_targets() -> dict: async with tool_runtime("list_identity_sync_targets", {}) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) rows = await svc.list_targets() return serialize({ "targets": [ { "id": str(r.id), "name": r.name, "slug": r.slug, "kind": r.kind, "enabled": r.enabled, "on_offboard": r.on_offboard, "last_run_at": r.last_run_at, "last_run_status": r.last_run_status, "next_run_at": r.next_run_at, # Master failure-alert switch + how many people get # paged when this target fails. With alerting off, or # count 0, nobody is notified — there is NO IT-admin # fallback (see DECISIONS.md 2026-06-19). "alert_enabled": r.alert_enabled, "alert_recipient_count": len(r.alert_recipient_ids or []), } for r in rows ], }) @mcp_server.tool( name="identity_sync_overview", description=( "Identity-sync cockpit aggregation: upstream posture (headcount " "by lifecycle status, HRIS source integrations, data-quality " "headline) plus one health card per outbound sync target — " "integration test status, last/next run, per-status state " "counts, filter-evaluated in-scope count, and a recent-run " "sparkline. Read-only. Requires IT admin role." ), ) async def identity_sync_overview() -> dict: async with tool_runtime("identity_sync_overview", {}) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) return serialize(await svc.overview()) @mcp_server.tool( name="get_identity_sync_metrics", description=( "Time-windowed identity-sync analytics (the 监控看板 dashboard): " "run KPI strip (total runs, success rate, person-level " "created/updated/skipped/deactivated/failed totals, avg & p95 " "run duration), a daily status/volume trend, a per-target " "breakdown, the trigger-source mix (manual/scheduled/event), " "the upstream→downstream data-volume overview (golden-record " "headcount + org nodes vs. covered persons + vendor records), " "and the most-recent failed runs and failed per-person items. " "window is one of today | 7d | 30d | 90d (default 7d), anchored " "to Asia/Shanghai day boundaries; optional target_id scopes to " "one target. Read-only. Requires IT admin role." ), ) async def get_identity_sync_metrics( window: str = "7d", target_id: str | None = None, ) -> dict: async with tool_runtime( "get_identity_sync_metrics", {"window": window, "target_id": target_id}, ) as rt: _require_identity_sync_admin(rt) from datetime import datetime as _dt from datetime import time as _time from datetime import timedelta as _td from zoneinfo import ZoneInfo from app.domains.identity_sync.service import IdentitySyncService windows = {"today": 1, "7d": 7, "30d": 30, "90d": 90} days = windows.get(window) if days is None: window, days = "7d", 7 tz_name = "Asia/Shanghai" tz = ZoneInfo(tz_name) now_local = _dt.now(tz) start_day = (now_local - _td(days=days - 1)).date() from_dt = _dt.combine(start_day, _time.min, tzinfo=tz) svc = IdentitySyncService(rt.db) return serialize(await svc.metrics( from_dt=from_dt, to_dt=now_local, tz_name=tz_name, target_id=_u(target_id) if target_id else None, window_label=window, )) @mcp_server.tool( name="list_identity_sync_run_items", description=( "Per-run / per-person identity-sync ledger: one row per " "vendor-touching operation (created/updated/skipped/failed/" "deactivated/…) with the rendered request payload as sent and " "the vendor's response detail or error. Filter by run_id for a " "run drill-down, or person_id/target_id for the cross-run " "history of one person ('what has Helix ever sent for them'). " "At least one of run_id/person_id/target_id is required. " "Read-only. Requires IT admin role." ), ) async def list_identity_sync_run_items( run_id: str | None = None, person_id: str | None = None, target_id: str | None = None, item_type: str | None = None, outcome: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_identity_sync_run_items", { "run_id": run_id, "person_id": person_id, "target_id": target_id, "item_type": item_type, "outcome": outcome, "page": page, "page_size": page_size, }, ) as rt: _require_identity_sync_admin(rt) from app.core.exceptions import AppException from app.domains.identity_sync.service import IdentitySyncService if not (run_id or person_id or target_id): raise AppException( code="IDENTITY_SYNC_ITEMS_FILTER_REQUIRED", message="provide at least one of run_id / person_id / target_id", status_code=400, ) svc = IdentitySyncService(rt.db) rows, total = await svc.list_run_items( run_id=_u(run_id) if run_id else None, person_id=_u(person_id) if person_id else None, target_id=_u(target_id) if target_id else None, item_type=item_type, outcome=outcome, page=max(page, 1), page_size=min(max(page_size, 1), 200), ) return serialize({ "total": total, "items": [ { "id": str(r.id), "run_id": str(r.run_id), "target_id": str(r.target_id), "item_type": r.item_type, "person_id": str(r.person_id) if r.person_id else None, "subject_display": r.subject_display, "external_key": r.external_key, "outcome": r.outcome, "request_payload": _mask_idsync(r.request_payload), "response_detail": _mask_idsync(r.response_detail), "error": r.error, "duration_ms": r.duration_ms, "created_at": r.created_at, } for r in rows ], }) @mcp_server.tool( name="refresh_identity_sync_vendor_snapshot", description=( "Pull a sync target's downstream system roster (users + " "departments / groups, read-only on the vendor side) into the " "local vendor snapshot, replacing the previous snapshot. Each " "user record is matched against identity_persons; unmatched " "rows are the vendor-only population to review before cutover. " "Works on disabled targets ('look before you flip'). Returns " "counts + matched/vendor-only totals. Requires IT admin role." ), ) async def refresh_identity_sync_vendor_snapshot(target_id: str) -> dict: async with tool_runtime( "refresh_identity_sync_vendor_snapshot", {"target_id": target_id}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.core.distributed_lock import LockBusy, distributed_lock from app.core.exceptions import AppException from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) target = await svc.get_target(_u(target_id)) lock_key = f"identity_sync:target:{target.slug}" try: async with distributed_lock(lock_key, ttl=600): result = await svc.refresh_vendor_snapshot( target_id=_u(target_id), actor_id=rt.user.id, ) except LockBusy: raise AppException( code="IDENTITY_SYNC_TARGET_BUSY", message=( f"target {target.slug!r} is busy; retry when the " "in-flight sweep/snapshot finishes." ), status_code=409, ) from None return serialize(result) @mcp_server.tool( name="list_identity_sync_vendor_records", description=( "Page a sync target's stored vendor snapshot (对端数据): the " "users / departments / groups as they currently exist on the " "downstream system, with normalized display_name / email / " "is_active, the matched Helix person (null = vendor-only), and " "the trimmed raw vendor record. Filters: record_type " "(user|department|group), q (substring), matched (true/false), " "is_active. Read-only. Requires IT admin role." ), ) async def list_identity_sync_vendor_records( target_id: str, record_type: str | None = None, q: str | None = None, matched: bool | None = None, is_active: bool | None = None, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_identity_sync_vendor_records", { "target_id": target_id, "record_type": record_type, "q": q, "matched": matched, "is_active": is_active, "page": page, "page_size": page_size, }, ) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) rows, total, fetched_at = await svc.list_vendor_records( target_id=_u(target_id), record_type=record_type, q=q, matched=matched, is_active=is_active, page=max(page, 1), page_size=min(max(page_size, 1), 200), ) return serialize({ "total": total, "fetched_at": fetched_at, "records": [ { "id": str(r.id), "record_type": r.record_type, "external_key": r.external_key, "display_name": r.display_name, "email": r.email, "vendor_status": r.vendor_status, "is_active": r.is_active, "matched_person_id": ( str(r.matched_person_id) if r.matched_person_id else None ), "raw": _mask_idsync(r.raw), } for r in rows ], }) @mcp_server.tool( name="get_identity_sync_drift", description=( "Per-person drift diff for a sync target: the payload Helix " "WOULD send right now (rendered via the target's mapping) " "compared field-by-field against the stored vendor snapshot. " "Verdicts: match / drift (value mismatch — real divergence) / " "not_comparable (vendor read API doesn't echo the field). " "Buckets: drifted / clean / vendor_missing / render_failed. " "Zero vendor API calls — refresh the vendor snapshot first " "(refresh_identity_sync_vendor_snapshot) for a current " "comparison. Requires IT admin role." ), ) async def get_identity_sync_drift( target_id: str, q: str | None = None, only_drift: bool = False, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "get_identity_sync_drift", { "target_id": target_id, "q": q, "only_drift": only_drift, "page": page, "page_size": page_size, }, ) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) return serialize(await svc.compute_drift( target_id=_u(target_id), q=q, only_drift=only_drift, page=max(page, 1), page_size=min(max(page_size, 1), 200), )) @mcp_server.tool( name="trigger_identity_sync_target", description=( "Run a sync target's sweep right now (admin trigger). Manual " "runs are PUSH-ONLY by default: they create/update the " "in-scope set but never deactivate out-of-scope people. Pass " "full_reconcile=true to also apply the on_offboard policy to " "out-of-scope people — required for targets whose own trigger " "is manual/event_only (no cron backstop reconciles them). " "Pass force_reverify=true to bypass the payload-hash skip AND " "the drift re-verify window (default 7 days) and re-push EVERY " "in-scope person/dept now, re-asserting all Helix-managed fields " "against live vendor state (immediate full drift correction). " "Acquires the same per-target lock the dispatcher uses. " "Returns the new run id + status. Requires IT admin role." ), ) async def trigger_identity_sync_target( target_id: str, full_reconcile: bool = False, force_reverify: bool = False, ) -> dict: async with tool_runtime( "trigger_identity_sync_target", { "target_id": target_id, "full_reconcile": full_reconcile, "force_reverify": force_reverify, }, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.core.distributed_lock import LockBusy, distributed_lock from app.core.exceptions import AppException from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) target = await svc.get_target(_u(target_id)) if not target.enabled: raise AppException( code="IDENTITY_SYNC_DISABLED", message=f"target {target.slug!r} disabled — enable it first", status_code=409, ) lock_key = f"identity_sync:target:{target.slug}" try: # auto_renew (#1754): same long sweep as the cron/manual paths; # keep the lock alive so a scheduled tick can't start a second # concurrent sweep on lock expiry. async with distributed_lock(lock_key, ttl=900, auto_renew=True): run = await svc.run_target( target, trigger_source=f"manual:mcp:{rt.user.id}", push_only=False if full_reconcile else None, force_reverify=force_reverify, ) except LockBusy: raise AppException( code="IDENTITY_SYNC_TARGET_BUSY", message=f"target {target.slug!r} already running", status_code=409, ) from None return serialize({ "run_id": str(run.id), "status": run.status, "summary": run.result_summary, }) @mcp_server.tool( name="dry_run_identity_sync_target", description=( "Read-only sync PLAN for a target — what a real run WOULD do, " "with zero writes. For feishu_contact targets returns the full " "users (create/update/excluded-non-employee) + departments " "(create/adopt/skip-empty/soft-delete) breakdown computed with " "live read-only Feishu reads, plus warnings (e.g. soft-delete " "mass-action brake). For huilianyi targets returns the full " "cutover reconciliation plan (create/update/email-conflict/" "resigned/unmapped-company/vendor-only users + dept plan) " "computed against a live full-roster read of 汇联易. For other " "kinds returns the in-scope / out-of-scope person split. Use this BEFORE enabling or " "triggering a target to verify it'll sync correctly. (Distinct " "from preview_identity_sync_target, which renders ONE person's " "mapping payload.) Requires IT admin role." ), ) async def dry_run_identity_sync_target(target_id: str) -> dict: async with tool_runtime( "dry_run_identity_sync_target", {"target_id": target_id}, ) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) target = await svc.get_target(_u(target_id)) if target.kind == "feishu_contact": return serialize(await svc.dry_run_feishu(_u(target_id))) if target.kind == "huilianyi": return serialize(await svc.dry_run_huilianyi(_u(target_id))) return serialize(await svc.dry_run(_u(target_id))) @mcp_server.tool( name="sync_person_to_identity_target", description=( "Push one person to one outbound sync target immediately, " "bypassing the cron schedule. Useful right after a manual " "data correction (dept fix, title typo) when you don't want " "to wait until the next scheduled run. Requires IT admin role." ), ) async def sync_person_to_identity_target( target_id: str, person_id: str, ) -> dict: async with tool_runtime( "sync_person_to_identity_target", {"target_id": target_id, "person_id": person_id}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) outcome = await svc.sync_person_to_target( target_id=_u(target_id), person_id=_u(person_id), trigger_source=f"manual:mcp:{rt.user.id}", force_in_scope=True, ) return serialize({ "status": outcome.status, "external_id": outcome.external_id, "error": outcome.error, }) @mcp_server.tool( name="get_identity_sync_state_for_person", description=( "List per-target sync state for one person — where they're " "synced, when they were last pushed, last status, last error. " "Useful for debugging 'why isn't X showing up in Navan'. " "Requires IT admin role." ), ) async def get_identity_sync_state_for_person(person_id: str) -> dict: async with tool_runtime( "get_identity_sync_state_for_person", {"person_id": person_id}, ) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService # ``_u`` raises a typed VALIDATION_ERROR (422) on malformed # input. Bare ``UUID(...)`` would raise ``ValueError`` which # the runtime catch-all maps to INTERNAL_ERROR (500). svc = IdentitySyncService(rt.db) items, total = await svc.list_state( person_id=_u(person_id), page=1, page_size=200, ) return serialize({"states": items, "total": total}) @mcp_server.tool( name="preview_identity_sync_target", description=( "Render the saved mapping config of a sync target against one " "person WITHOUT pushing. Returns per-rule trace (helix source → " "transform → downstream target field), the final JSON payload, " "the raw context dict, and any warnings. Powers the same " "mapping-preview drawer the admin UI shows. Read-only — no " "downstream API calls, no state mutations. Requires IT admin role." ), ) async def preview_identity_sync_target( target_id: str, person_id: str, ) -> dict: async with tool_runtime( "preview_identity_sync_target", {"target_id": target_id, "person_id": person_id}, ) as rt: _require_identity_sync_admin(rt) from app.domains.identity_sync.service import IdentitySyncService svc = IdentitySyncService(rt.db) target = await svc.get_target(_u(target_id)) # Use the target's stored config — this MCP tool exposes the # "what would the next sweep push for this person" view, not # a draft-config tester (which is what the HTTP endpoint # supports for the editor). result = await svc.preview_payload( person_id=_u(person_id), mapping_config=target.mapping_config or {}, runner_options=target.runner_options or {}, ) # Mask PII (mobile_phone, id numbers) in the embedded person # context + rendered_payload before echoing — same contract as the # HTTP twin (identity_sync/router preview) and the sibling MCP tools # (list_identity_sync_run_items / _vendor_records). A stolen # read-scoped admin PAT must not be able to scrape plaintext PII # here when every other surface masks it. Hash is computed pre-mask # inside preview_payload, so drift comparison is unaffected. return serialize(_mask_idsync(result)) # ─── SSO Apps catalog (admin only) ──────────────────────────────── @mcp_server.tool( name="list_sso_apps", description=( "List the local SSO Apps catalog — every Google Workspace SAML " "app the daily sync has discovered, plus any apps Helix created " "via approved sso_grant_request flows. Each row carries the " "Helix-side owner_person_id (admin-assigned), source provenance " "(google_synced vs helix_created), certificate expiry, and the " "count of sso_grants currently linked. Requires IT admin role." ), ) async def list_sso_apps( source: str | None = None, expiring_within_days: int | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size) async with tool_runtime( "list_sso_apps", { "source": source, "expiring_within_days": expiring_within_days, "page": page, "page_size": page_size, }, ) as rt: _require_identity_sync_admin(rt) from datetime import UTC, datetime, timedelta from app.core.exceptions import ValidationError from app.domains.sso.models import SsoApp, SsoGrant # Out-of-range values must FAIL, not silently drop the filter — # otherwise the caller gets the whole catalog believing it's # the expiring slice. if expiring_within_days is not None and not ( 1 <= int(expiring_within_days) <= 3650 ): raise ValidationError( "expiring_within_days must be between 1 and 3650" ) q = select(SsoApp).where(SsoApp.provider == "google_workspace") if source in ("google_synced", "helix_created"): q = q.where(SsoApp.source == source) if expiring_within_days is not None: cutoff = datetime.now(UTC) + timedelta( days=int(expiring_within_days), ) q = q.where(SsoApp.certificate_expires_at <= cutoff) count_q = select(func.count()).select_from(q.subquery()) total = (await rt.db.execute(count_q)).scalar() or 0 q = ( q.order_by(SsoApp.display_name.asc()) .offset((page - 1) * page_size) .limit(page_size) ) rows = list((await rt.db.execute(q)).scalars()) grant_counts: dict[str, int] = {} if rows: grant_count_rows = (await rt.db.execute( select(SsoGrant.created_app_id, func.count()) .where(SsoGrant.created_app_id.in_([r.id for r in rows])) .group_by(SsoGrant.created_app_id) )).all() grant_counts = {row[0]: int(row[1]) for row in grant_count_rows} return serialize({ "apps": [ { "id": r.id, "external_id": r.external_id, "display_name": r.display_name, "platform": r.platform, "auth_type": r.auth_type, "user_access_state": r.user_access_state, "certificate_expires_at": r.certificate_expires_at, "owner_person_id": r.owner_person_id, "source": r.source, "last_synced_at": r.last_synced_at, "grant_count": grant_counts.get(r.id, 0), } for r in rows ], "total": total, "page": page, "page_size": page_size, }) @mcp_server.tool( name="set_sso_app_owner", description=( "Set or clear the Helix-side owner of one SSO app. Owner is the " "person responsible for the SAML integration in Helix's eyes — " "used by the leaver flow to ping the right person when an " "owner offboards. Pass owner_person_id='' to clear. Requires " "IT admin role." ), ) async def set_sso_app_owner( app_id: str, owner_person_id: str = "", ) -> dict: async with tool_runtime( "set_sso_app_owner", {"app_id": app_id, "owner_person_id": owner_person_id}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from datetime import UTC, datetime from app.core.exceptions import AppException, NotFoundError from app.domains.sso.models import SsoApp from app.identity.person_models import IdentityPerson app_id_uuid = _u(app_id) app = await rt.db.get(SsoApp, app_id_uuid) if app is None: raise NotFoundError("SsoApp", app_id) new_owner: str | None = None if owner_person_id: try: owner_uuid = _u(owner_person_id) except AppException: raise person = await rt.db.get(IdentityPerson, owner_uuid) if person is None: raise NotFoundError("IdentityPerson", owner_person_id) # Pre-launch P1 fix (Round 45 follow-up, CR-310): refuse # to bind an SSO app's owner to an inactive / leaving / # deleted person. Without this, an admin (or LLM) can pin # an SSO app's owner to a leaver, which immediately # resurfaces as an offboarding blocker on the next scan. # Mirrors the service-layer gate the HTTP twin enforces. person_status = (person.employment_status or "").lower() if person.deleted_at is not None or person_status in { "left", "leaver_pending", }: raise AppException( code="SSO_APP_OWNER_INACTIVE", message=( f"Cannot bind SSO app owner to " f"{person.work_email or person.display_name} — " f"they are {person_status or 'deleted'}. " "Pick an active employee." ), status_code=409, ) new_owner = str(person.id) app.owner_person_id = new_owner app.updated_at = datetime.now(UTC) # No explicit commit: tool_runtime(mutates=True) commits on clean exit. return serialize({ "id": app.id, "owner_person_id": app.owner_person_id, "updated_at": app.updated_at, }) @mcp_server.tool( name="create_sso_app", description=( "Add one SAML app to the catalog manually (admin-only). " "Idempotent on display_name — calling twice with the same " "name updates the existing row in place rather than " "duplicating; on update, OMITTED fields are left unchanged " "(this tool cannot clear a field — use the HTTP admin UI to " "clear values). Useful for AI-assisted onboarding when the " "admin pastes a vendor's metadata in chat. Returns the new " "row's id + serialised fields. Requires IT admin role." ), ) async def create_sso_app( display_name: str, platform: str | None = None, user_access_state: str | None = None, acs_url: str | None = None, entity_id: str | None = None, x509_cert: str | None = None, owner_person_id: str | None = None, external_id: str | None = None, certificate_expires_at: str | None = None, ) -> dict: async with tool_runtime( "create_sso_app", { "display_name": display_name, "platform": platform, "user_access_state": user_access_state, "owner_person_id": owner_person_id, }, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.sso.app_catalog_service import ( _UNSET, SSO_APP_SOURCE_ADMIN_ADDED, upsert_app, ) from app.identity.person_models import IdentityPerson owner_uuid_str: str | None = None if owner_person_id: person = await rt.db.get(IdentityPerson, _u(owner_person_id)) if person is None: from app.core.exceptions import NotFoundError raise NotFoundError("IdentityPerson", owner_person_id) # CR-310 parity: ``set_sso_app_owner`` refuses to bind an # SSO app to an inactive / leaving / deleted person; this # twin used to skip that check, letting an LLM bypass the # gate by calling ``create_sso_app`` with display_name of # an existing app + a leaver as owner_person_id — because # ``upsert_app`` is keyed on display_name and overwrites # ``owner_person_id`` on the existing row. (R1 sibling-twin # finding 2026-05-28.) from app.core.exceptions import AppException person_status = (person.employment_status or "").lower() if person.deleted_at is not None or person_status in { "left", "leaver_pending", }: raise AppException( code="SSO_APP_OWNER_INACTIVE", message=( f"Cannot bind SSO app owner to " f"{person.work_email or person.display_name} — " f"they are {person_status or 'deleted'}. " "Pick an active employee." ), status_code=409, ) owner_uuid_str = str(person.id) cert_dt = None if certificate_expires_at: # ``_parse_iso_datetime`` raises VALIDATION_ERROR with a # bounded ``_short_repr`` of the offending input. cert_dt = _parse_iso_datetime( certificate_expires_at, field="certificate_expires_at", ) # OMITTED-vs-clear discrimination: this tool's docstring # advertises "update in place" on an existing display_name, # but a previous revision forwarded every omitted optional # arg as ``None`` — and ``upsert_app``'s contract is # sentinel-based ("Pass None explicitly to CLEAR"). An LLM # calling ``create_sso_app(display_name="")`` # therefore wiped owner_person_id / acs_url / entity_id / # x509_cert / user_access_state / certificate_expires_at / # external_id on the existing row (admin-curated owner lost # permanently; SAML metadata gone until next Google sync). # Map omitted → _UNSET so omitted fields are left untouched; # over MCP there is deliberately NO clear path. Same class of # bug ``annotate_domain`` already guards against. (Launch # audit 2026-06-10.) def _set_or_unset(v): return v if v is not None else _UNSET app = await upsert_app( rt.db, display_name=display_name, # ``None`` platform → update keeps the existing value # (``platform or existing.platform``), insert defaults to # "Web" inside upsert_app. platform=platform, user_access_state=_set_or_unset(user_access_state), certificate_expires_at=_set_or_unset(cert_dt), acs_url=_set_or_unset(acs_url), entity_id=_set_or_unset(entity_id), x509_cert=_set_or_unset(x509_cert), owner_person_id=_set_or_unset(owner_uuid_str), external_id=_set_or_unset(external_id), source=SSO_APP_SOURCE_ADMIN_ADDED, ) # ``tool_runtime(mutates=True)`` commits on clean exit — no # explicit commit here. return serialize({ "id": app.id, "display_name": app.display_name, "platform": app.platform, "owner_person_id": app.owner_person_id, "source": app.source, "created_at": app.created_at, }) @mcp_server.tool( name="delete_sso_app", description=( "Hard-delete one SSO app from the catalog (admin-only). " "REFUSES the delete if any sso_grants row still references the " "catalog entry (active OR historical) — keeps grant history " "joinable to a real app name instead of an orphaned `null` " "after the FK SET NULL fires. Requires IT admin role." ), ) async def delete_sso_app(app_id: str) -> dict: async with tool_runtime( "delete_sso_app", {"app_id": app_id}, mutates=True, ) as rt: from app.core.exceptions import AppException, NotFoundError from app.domains.sso.models import SsoApp, SsoGrant _require_identity_sync_admin(rt) app_id_uuid = _u(app_id) app = await rt.db.get(SsoApp, app_id_uuid) if app is None: raise NotFoundError("SsoApp", app_id) # Active-or-historical grant guard. SsoGrant.created_app_id FK # is ``ON DELETE SET NULL``, so a delete would silently strand # every historical grant tied to this app with a null label. # Refuse fast — admin can re-route or hide the catalog row by # other means (rename, etc.) without destroying audit context. linked_count = await rt.db.scalar( select(func.count(SsoGrant.id)).where( SsoGrant.created_app_id == app.id, ), ) or 0 if linked_count > 0: raise AppException( code="SSO_APP_HAS_LINKED_GRANTS", message=( f"Cannot delete SSO app {app.display_name!r}: " f"{linked_count} sso_grants row(s) reference it. " "Resolve the grants first (revoke / re-link) " "before deleting the catalog entry." ), status_code=409, ) display = app.display_name await rt.db.delete(app) # ``tool_runtime(mutates=True)`` commits on clean exit. return serialize({"id": app_id, "display_name": display, "deleted": True}) @mcp_server.tool( name="import_sso_apps_csv", description=( "Bulk-import a CSV of SAML apps into the catalog (admin-" "only). Header columns (case-insensitive): display_name " "(required), platform, user_access_state, acs_url, " "entity_id, certificate_expires_at, owner_email. Idempotent " "on display_name. Returns inserted/updated/failed counts + " "the list of unresolved owner_emails. CSV ≤ 1 MB." ), ) async def import_sso_apps_csv(csv_text: str) -> dict: async with tool_runtime( "import_sso_apps_csv", {"csv_text_len": len(csv_text or "")}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.core.exceptions import ValidationError from app.domains.sso.app_catalog_service import import_apps_from_csv # Enforce the "CSV ≤ 1 MB" contract stated in the tool description. # Without this an LLM client could pass an arbitrarily large string # that OOMs the worker before any downstream validator sees it. if len(csv_text or "") > 1_048_576: raise ValidationError("csv_text must be ≤ 1 MB") summary = await import_apps_from_csv( rt.db, csv_text=csv_text, actor_user_id=str(rt.user.id), ) return serialize(summary) @mcp_server.tool( name="resend_sso_app_notification", description=( "Re-send the IT-admin notification asking them to create a " "matching SAML app in Google admin. Only valid for catalog " "rows tagged `helix_created` (the notification template " "needs the linked grant's context). Used when the post-" "approval auto-notify failed (e.g. Feishu webhook flap). " "Requires IT admin role." ), ) async def resend_sso_app_notification(app_id: str) -> dict: async with tool_runtime( "resend_sso_app_notification", {"app_id": app_id}, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.sso.app_catalog_service import ( resend_it_notification_for_app, ) app_id_uuid = str(_u(app_id)) outcome = await resend_it_notification_for_app( rt.db, app_id_uuid, actor_user_id=str(rt.user.id), ) return serialize(outcome) @mcp_server.tool( name="get_offboarding_preview", description=( "Read-only dry-run that mirrors what ``mark_person_leaver_manual`` " "would scan if invoked now. Returns the resource list (hardware, " "software seats, cloud scopes, SSO grants, etc.) the leaver " "currently holds, plus per-resource policy + auto-picked successor " "and a summary of which items would need a manual successor pick. " "Persists nothing, fires no Google API call. Use this before " "calling ``mark_person_leaver_manual`` so the AI client (or " "operator) can confirm the offboarding scope before committing. " "IT-admin only." ), ) async def get_offboarding_preview(person_id: str) -> dict: async with tool_runtime( "get_offboarding_preview", {"person_id": person_id}, mutates=False, ) as rt: _require_identity_sync_admin(rt) from app.domains.offboarding.service import OffboardingService # ``_u`` raises a typed VALIDATION_ERROR with a bounded # ``_short_repr`` of the offending value. pid = _u(person_id) svc = OffboardingService(rt.db) data = await svc.get_offboarding_preview(pid) return serialize(data) @mcp_server.tool( name="mark_person_leaver_manual", description=( "Manually flip an active person to ``leaver_pending`` and " "start the full offboarding flow (blocker scan + SSO force-" "logout + audit). Mirrors the HRIS-driven path so the resulting " "process is indistinguishable from a SuccessFactors-triggered " "termination — useful for end-to-end testing the offboarding " "pipeline on tenants we haven't onboarded HRIS for yet, or for " "emergency manual terminations the HR team needs without " "waiting for the next SF sync window. ``person_id`` is the " "IdentityPerson UUID; ``last_working_day`` is ISO date " "(YYYY-MM-DD). Returns 409 if the person is already in " "``leaver_pending`` / ``left`` (cancel the existing process " "first via /offboarding/processes/{id}/cancel). IT-admin only." ), ) async def mark_person_leaver_manual( person_id: str, last_working_day: str, reason: str | None = None, ) -> dict: async with tool_runtime( "mark_person_leaver_manual", { "person_id": person_id, "last_working_day": last_working_day, "reason": reason, }, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.audit.service import AuditService from app.core.exceptions import AppException from app.domains.offboarding.service import OffboardingService from app.identity.person_models import IdentityPerson # ``_u`` / ``_parse_iso_date`` raise typed VALIDATION_ERROR # with a bounded ``_short_repr`` of the offending value. pid = _u(person_id) lwd = _parse_iso_date(last_working_day, field="last_working_day") # Cap free-text — written to ``audit_logs.detail.reason``. reason = _bound_free_text(reason, field="reason", max_len=2000) # P1-5 fix (2026-05-08): mirror the date-range gate from the # HTTP path so MCP callers can't sneak around it. ±7d / +365d. # Pre-launch P1 fix (Round 45 follow-up, CR-318): anchor on # UTC.date() to match the HTTP twin (which lives in worker- # UTC). ``_date.today()`` uses the worker's local TZ; an MCP # caller could otherwise game the 7-day floor by a multi-hour # window depending on deployment region. from datetime import UTC as _UTC # noqa: PLC0415 from datetime import datetime as _dt # noqa: PLC0415 from datetime import timedelta as _timedelta today = _dt.now(_UTC).date() if lwd < today - _timedelta(days=7): raise AppException( code="LAST_WORKING_DAY_TOO_OLD", message=( f"last_working_day {lwd.isoformat()} is more than 7 " "days in the past — outside the allowed window." ), status_code=400, ) if lwd > today + _timedelta(days=365): raise AppException( code="LAST_WORKING_DAY_TOO_FAR", message=( f"last_working_day {lwd.isoformat()} is more than 365 " "days in the future — long-notice exits should land " "via HRIS." ), status_code=400, ) person = await rt.db.get(IdentityPerson, pid) if person is None or person.deleted_at is not None: raise AppException( code="PERSON_NOT_FOUND", message=f"Person {person_id} not found", status_code=404, ) if person.employment_status != "active": raise AppException( code="PERSON_NOT_ACTIVE", message=( f"Person {person.work_email} is already in " f"'{person.employment_status}' status — cannot " "re-mark as leaver." ), status_code=409, ) person.employment_status = "leaver_pending" person.termination_date = lwd await rt.db.flush() off_svc = OffboardingService(rt.db) proc = await off_svc.start_process( person.id, lwd, trace_id=_trace_str(), actor_user_id=rt.user.id, ) audit = AuditService(rt.db) await audit.log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="hr.person.mark_leaver_manual", resource_type="identity_person", resource_id=str(person.id), status="success", detail={ "work_email": person.work_email, "last_working_day": lwd.isoformat(), "reason": reason, "offboarding_process_id": str(proc.id), "via": "mcp", }, ) await rt.db.commit() # D-2 fix (2026-05-08): post-commit force-logout + notifications. # Wrapped in try/except + best-effort rollback for the same # reason as the HR router path: the offboarding is durable by # this point, partial post-commit failure must not bubble out # to the AI client as an error. post_commit_summary: dict = {} try: post_commit_summary = await off_svc.dispatch_post_commit_actions( person.id, proc.id, trace_id=_trace_str(), actor_user_id=rt.user.id, ) await rt.db.commit() except Exception as exc: # noqa: BLE001 import logging as _mcp_logging # noqa: PLC0415 _mcp_logging.getLogger("app.mcp.tools").exception( "mark_person_leaver_manual MCP post-commit dispatch " "second-commit failed for person %s — process is " "committed; force-logout / notifications may be partial.", person.id, ) # Round-20 audit (A3F12): scrub vendor-token shapes from the # error message before returning to the LLM. A failing # notification dispatch that surfaces a Feishu / Google # bearer token in its raise message would otherwise leak it # to the LLM client. Use the same _scrub_message helper the # audit/notification layer already applies to vendor error # bodies, then cap at 200 chars (narrower than 500) to # tighten the surface. from app.integrations.service import _scrub_message post_commit_summary = { "post_commit_commit_error": _scrub_message(str(exc))[:200], "google_force_logout_pushed": False, "notifications": {"leaver": None, "manager": None}, } try: await rt.db.rollback() except Exception: # noqa: BLE001 pass return serialize({ "person_id": str(person.id), "employment_status": person.employment_status, "termination_date": person.termination_date.isoformat(), "offboarding_process_id": str(proc.id), "post_commit": post_commit_summary, }) @mcp_server.tool( name="hr_refresh_person_from_hris", description=( "Sync ONE employee from SuccessFactors to Helix and then push " "them to Feishu (+ the other event-driven identity-sync targets " "in the background) — the low-latency path for when HR just " "edited a person's record in SF (a role move, dept re-org, " "title change) and wants it reflected now instead of waiting for " "the daily sync tick + per-target cron. ``person_id`` is the " "Helix IdentityPerson UUID. Reuses the normal staged-batch " "confirm (dept / manager / title resolution, role-change " "coordinator, audit) so the result is identical to a scheduled " "sync. SAFETY: if the SF pull reveals a genuine lifecycle " "transition (new hire / rehire / termination) the tool does NOT " "apply it — it returns ``blocked=true`` with a reason telling " "you to use the onboarding / offboarding flow. Returns the " "before/after field diff and the Feishu push round-trip. " "Restricted to global_admin / HR." ), ) async def hr_refresh_person_from_hris(person_id: str) -> dict: async with tool_runtime( "hr_refresh_person_from_hris", {"person_id": person_id}, mutates=True, ) as rt: _require_hr_admin(rt.user.role_names) from app.audit.service import AuditService from app.core.coordinators.hris_person_refresh_coordinator import ( refresh_person_from_hris, ) pid = _u(person_id) try: result = await refresh_person_from_hris( rt.db, person_id=pid, actor_user_id=rt.user.id, ) except Exception as exc: # Mirror the HTTP endpoint: a failed sync must still land a # ``failure`` audit row so the HR-facing "recent syncs" history # shows who tried, on whom, and why it failed — regardless of # which surface (UI or MCP) drove it. Fresh session because # ``rt.db`` may be in a poisoned transaction after a DB error. from app.core.database import async_session_factory try: async with async_session_factory() as audit_session: await AuditService(audit_session).log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="hr.person.hris_refresh", resource_type="identity_person", resource_id=str(pid), status="failure", detail={"error": str(exc)[:2000], "via": "mcp"}, ) await audit_session.commit() except Exception: # noqa: BLE001 — best-effort, never mask the real error logger.exception( "mcp hr_refresh_person_from_hris: failed to write " "failure audit for person=%s", pid, ) raise audit = AuditService(rt.db) await audit.log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="hr.person.hris_refresh", resource_type="identity_person", resource_id=str(pid), status="blocked" if result.blocked else "success", detail={ "hris_user_id": result.hris_user_id, "staged_action": result.staged_action, "blocked": result.blocked, "block_reason": result.block_reason, "helix_applied": result.helix_applied, "changed_fields": [c["field"] for c in result.changed_fields], "feishu_status": ( result.feishu_push.status if result.feishu_push else None ), "via": "mcp", }, ) await rt.db.commit() return serialize(result.to_dict()) @mcp_server.tool( name="scheduled_job_chains", description=( "Every scheduled-job chain, rooted at its clock-triggered job, " "with each member's measured average duration and the chain's " "end-to-end cost. A chained job runs when its predecessor " "finishes rather than at a time of its own, which is how " "ordering dependencies are expressed since 2026-09-06 — before " "that they were a gap between two cron minute fields with the " "intent written only in a docstring. Use this to answer 'what " "runs after what' and 'does this chain still fit in its window' " "before changing any cadence. Also lists ``orphans``: chained " "rows whose predecessor was deleted, which can never fire again " "and look completely normal in the job list. Read-only; " "IT-admin only." ), ) async def scheduled_job_chains() -> dict: async with tool_runtime("scheduled_job_chains", {}) as rt: _require_identity_sync_admin(rt) from app.scheduled_jobs import chain # Annotated local, matching its two siblings: ``serialize`` is # untyped, so returning its result directly trips the # ``no-any-return`` ratchet. payload: dict = serialize(await chain.chain_graph(rt.db)) return payload @mcp_server.tool( name="scheduled_job_capacity", description=( "Execution-capacity model for the scheduled-job fleet: how many " "slot-seconds per day the fleet demands against the concurrency " "ceiling (global + heavy sub-gate), which hour of the day that " "demand lands in, the ranked per-job consumers, and the jobs that " "spend the longest queueing for a slot. Every figure is observed " "from ``scheduled_job_runs`` wall-clock, not estimated from cron " "expressions, so it reflects misfires, queueing and disabled " "windows. Use it to answer 'why did this job start late' before " "changing any cadence. ``window_days`` clamps to 1..30 — the " "ledger keeps only the last 100 runs per job, so a longer window " "mixes different amounts of history per job and the per-day " "arithmetic stops meaning anything. Read-only; IT-admin only." ), ) async def scheduled_job_capacity(window_days: int = 7) -> dict: async with tool_runtime( "scheduled_job_capacity", {"window_days": window_days}, ) as rt: _require_identity_sync_admin(rt) from app.scheduled_jobs import hygiene window = max(1, min(int(window_days or 7), 30)) payload: dict = serialize( await hygiene.capacity_summary(rt.db, window_days=window), ) return payload @mcp_server.tool( name="scheduled_job_hygiene", description=( "Ledger-hygiene census of ``scheduled_jobs``: every row that looks " "alive but cannot do work, grouped by cause — ``handler_missing`` " "(the handler was renamed out from under the row, so it can never " "run again), ``integration_missing`` (its integration row was " "deleted), ``integration_disabled`` (the integration is off, so " "the row skips every tick), ``never_run`` (enabled for over two " "days and never fired) and ``idle_skipping`` (at least ten runs in " "the last fortnight, all skipped). These are invisible in the job " "list, where all of them render as a healthy enabled row. " "Read-only; IT-admin only." ), ) async def scheduled_job_hygiene() -> dict: async with tool_runtime("scheduled_job_hygiene", {}) as rt: _require_identity_sync_admin(rt) from app.scheduled_jobs import hygiene payload: dict = serialize(await hygiene.health_census(rt.db)) return payload @mcp_server.tool( name="acknowledge_scheduled_job_run", description=( "Acknowledge a failed scheduled-job run so the default red queue " "stops surfacing it. The run row keeps ``status='failed'`` (we " "never rewrite history — audit + ``failure_count`` depend on the " "truth), but ``acknowledged_at`` is flipped to the call time and " "the optional ``reason`` is recorded for audit. Future runs that " "reproduce the *same* failure signature will re-notify (acked → " "next failure deserves a fresh push). 422 if the run isn't " "``status='failed'``; idempotent for already-acked runs. IT-admin " "only." ), ) async def acknowledge_scheduled_job_run( run_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "acknowledge_scheduled_job_run", {"run_id": run_id, "reason": reason}, mutates=True, ) as rt: _require_identity_sync_admin(rt) # Cap free-text at the MCP boundary — stored in # ``scheduled_job_runs.ack_reason`` (TEXT). reason = _bound_free_text(reason, field="reason", max_len=1000) from app.audit.service import AuditService from app.scheduled_jobs import service as sched_service run = await sched_service.acknowledge_run( rt.db, _u(run_id), actor_person_id=rt.user.id, reason=reason, ) audit = AuditService(rt.db) await audit.log( trace_id=_trace_str(), actor_user_id=rt.user.id, actor_type="user", action="mcp.acknowledge_scheduled_job_run", resource_type="scheduled_job_run", resource_id=str(run.id), status="success", detail={"reason": reason or None, "job_id": str(run.job_id)}, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) # ``tool_runtime(mutates=True)`` commits on clean exit. return serialize({ "run_id": str(run.id), "job_id": str(run.job_id), "status": run.status, "acknowledged_at": ( _iso_utc(run.acknowledged_at) if run.acknowledged_at else None ), "acknowledged_by_id": ( str(run.acknowledged_by_id) if run.acknowledged_by_id else None ), "ack_reason": run.ack_reason, }) @mcp_server.tool( name="get_cloud_scope_last_sync_summary", description=( "Return the grants summary from the most recent completed sync for a " "cloud scope. Specifically exposes ``skipped_unresolved`` — the count " "of GCP/AWS/Azure principals found in the cloud IAM policy that could " "not be matched to a Helix identity (their email is not registered in " "identity_persons). AI clients use this to explain why a person " "manually added in the cloud console does not appear in the Helix " "members list. Returns null when no sync has run yet. Cloud-admin or " "IT-admin only." ), ) async def get_cloud_scope_last_sync_summary(scope_id: str) -> dict: async with tool_runtime( "get_cloud_scope_last_sync_summary", {"scope_id": scope_id}, mutates=False, ) as rt: from app.audit.models import AuditLog as _AuditLog from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_read_cloud as _can_read_cloud from app.domains.cmdb.models import CmdbCloudScope as _CmdbCloudScope # Use the typed AppException subclasses so the runtime emits a # proper 403/404 envelope. Bare ``PermissionError`` / ``ValueError`` # are not AppException and fall through to INTERNAL_ERROR (500), # masking real failures in audit + forensics. if not _can_read_cloud(rt.user.role_names): raise ForbiddenError("cloud_admin or it_admin required") scope = await rt.db.get(_CmdbCloudScope, _u(scope_id)) if scope is None or scope.deleted_at is not None: raise NotFoundError("CmdbCloudScope", str(scope_id)) row = (await rt.db.execute( select(_AuditLog) .where( _AuditLog.resource_id == scope_id, _AuditLog.action == "cloud.scope.sync_now", _AuditLog.status == "success", ) .order_by(_AuditLog.created_at.desc()) .limit(1) )).scalars().first() if row is None: return serialize({"scope_id": scope_id, "last_sync": None}) detail = row.detail or {} grants_detail = detail.get("grants") or {} per_scope = grants_detail.get("skipped_per_scope") or {} return serialize({ "scope_id": scope_id, "last_sync": { # Use _iso_utc — ``row.created_at`` is loaded fresh from # MySQL and arrives as a naive datetime even on a # ``DateTime(tz=True)`` column. A bare ``.isoformat()`` # would emit an unsuffixed string that downstream chart / # alert consumers silently treat as local time. (R1 # finding, 2026-05-28.) "synced_at": _iso_utc(row.created_at), "skipped_unresolved": per_scope.get(scope_id, 0), "skipped_integration_total": grants_detail.get("skipped_unresolved", 0), "upserted": grants_detail.get("upserted", 0), "touched": grants_detail.get("touched", 0), "errors": grants_detail.get("errors") or [], }, }) @mcp_server.tool( name="get_cloud_scope_billing_detail", description=( "Return rich month-anchored billing detail for one cloud scope " "(subscription / sub-account / project): identity header, " "month total + budget progress + linear month-end projection, " "daily trend across the month, trailing 6-month history, " "cost-by-service top-N + 'other' bucket, top-N resources with " "14-day sparkline + user-defined tags dict per resource (latest " "non-empty wins — surfaces creator/owner attribution from the " "upstream bill), and members list. Pairs with the matching " "HTTP route GET /cmdb/scopes/{id}/billing-detail. ``month`` is " "optional YYYY-MM and defaults to the current UTC month " "(clamped to today). Authz: scope member or owner, OR " "cloud_admin / it_admin / auditor / finance role. AI clients " "use this to answer 'how much did subscription X spend last " "month and on what?' without scraping the dashboard.\n\n" "Completeness contract (2026-09-07): the ``coverage`` block " "says whether this month's number can be trusted, so a $0 is " "never ambiguous. ``coverage.status`` is one of: ``complete`` " "(every day this cloud has data for is present — a $0 here is " "a REAL zero), ``gap`` (this subscription's billing stops at " "``covered_through`` while the same cloud has data through " "``expected_through``, and the subscription bills continuously " "— the total is UNDERSTATED by roughly " "``estimated_missing_usd``), ``pipeline_stale`` (the whole " "cloud's ingest is behind — not specific to this scope), " "``no_history`` (never billed), ``indeterminate`` (data stops " "early but this subscription bills sporadically, so we cannot " "tell a real zero from a collection gap). Agents MUST NOT " "report a ``gap`` or ``pipeline_stale`` total as the " "subscription's spend without saying it is incomplete; on " "``gap`` the likely causes are a billing-account move, a " "changed billing-export config, or billing genuinely turned " "off — Helix cannot distinguish those three, so do not assert " "one.\n\n" "Freshness contract (Round-32, 2026-05-18): " "``daily_trend`` runs from month_start through " "``summary.last_fact_date`` — trailing days for which the " "cost-facts ingest cron has not yet caught up are OMITTED " "(not emitted as $0 bars). For current month + lagging cron, " "the series may be shorter than the days-into-month. " "``summary.days_elapsed`` is anchored on ``last_fact_date`` for " "the current partial month (so daily_average is the average " "of OBSERVED days), and on the full calendar month for past " "settled months (conventional total/days_in_month finance " "figure). Agents should cite ``last_fact_date`` as the " "as-of date when reporting MTD numbers.\n\n" "AWS Savings Plans note (PR #339): for AWS scopes, the " "``cost_by_service`` array may include an entry with " "``service='Savings Plans'`` whose ``usd`` and ``share`` are " "NEGATIVE on linked accounts (the SP discount netted from " "gross usage) or POSITIVE on the payer account (the SP " "commitment fee). This is correct accounting — summing all " "``cost_by_service[].usd`` equals ``summary.total_usd`` " "(matches AWS Cost Explorer's 'Net unblended cost' view). " "Do NOT abs-sum and do NOT filter out 'Savings Plans' when " "reporting totals to users — that would re-introduce the same " "~30%% overstatement bug this PR fixed." ), ) async def get_cloud_scope_billing_detail( scope_id: str, month: str | None = None, ) -> dict: async with tool_runtime( "get_cloud_scope_billing_detail", {"scope_id": scope_id, "month": month}, mutates=False, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import ( can_read_cloud as _can_read_cloud, ) from app.core.rbac import ( can_view_cloud_resources as _can_view_cloud_resources, ) from app.core.rbac import ( can_view_finance as _can_view_finance, ) from app.domains.cmdb.models import ( GRANT_STATUS_ACTIVE as _GS_ACTIVE, ) from app.domains.cmdb.models import ( GRANT_STATUS_PENDING as _GS_PENDING, ) from app.domains.cmdb.models import ( SCOPE_STATUS_CLOSED as _SS_CLOSED, ) from app.domains.cmdb.models import ( CmdbCloudAccessGrant as _CmdbCloudAccessGrant, ) from app.domains.cmdb.models import CmdbCloudScope as _CmdbCloudScope from app.domains.cmdb.scope_billing_detail import ( get_scope_billing_detail as _get_detail, ) scope = await rt.db.get(_CmdbCloudScope, _u(scope_id)) if scope is None: raise NotFoundError("CmdbCloudScope", str(scope_id)) if not _can_view_cloud_resources(rt.user.role_names): raise ForbiddenError("cloud read access required") privileged = ( _can_read_cloud(rt.user.role_names) or _can_view_finance(rt.user.role_names) ) if not privileged: # Same authz pattern as the HTTP route — scope membership # OR ownership, with parent-subscription inheritance for # RG-typed scopes (closed parent does NOT grant). scope_ids_for_perm = {str(scope.id)} owner_ids: set[str] = set() if scope.owner_person_id: owner_ids.add(scope.owner_person_id) if scope.parent_scope_id: try: parent_uuid = _u(scope.parent_scope_id) except Exception: parent_uuid = None if parent_uuid is not None: parent = await rt.db.get(_CmdbCloudScope, parent_uuid) if ( parent is not None and parent.status != _SS_CLOSED ): scope_ids_for_perm.add(str(parent.id)) if parent.owner_person_id: owner_ids.add(parent.owner_person_id) is_owner = str(rt.user.id) in owner_ids is_member = await rt.db.scalar( select(func.count(_CmdbCloudAccessGrant.id)).where( _CmdbCloudAccessGrant.scope_id.in_(scope_ids_for_perm), _CmdbCloudAccessGrant.person_id == str(rt.user.id), _CmdbCloudAccessGrant.status.in_((_GS_ACTIVE, _GS_PENDING)), ), ) or 0 if not is_owner and not is_member: raise ForbiddenError("not a member of this scope") redact = not _can_read_cloud(rt.user.role_names) data = await _get_detail( rt.db, scope=scope, month=month, redact_member_roles=redact, ) return serialize(data) @mcp_server.tool( name="revoke_offboarding_blocker", description=( "Admin-only — revoke a single offboarding blocker through the " "auto-revoke path. Same flow as POST /offboarding/blockers/" "{blocker_id}/revoke. Supports software_seat, software_permission, " "api_key, hardware, vpn_application, ai_tool_application, AND " "cloud_access_grant. The seat / permission / api_key / hardware " "branches enqueue a ProvisioningTask (the blocker stays pending " "until the dispatcher completes the task); the vpn / ai-tool / " "cloud_access_grant branches revoke directly at the source " "domain and flip the blocker to revoked_auto on success. " "Returns the blocker's new resolution + a hint indicating " "whether the call enqueued a task or completed inline. For " "seat blockers on drivers that require successor handoff " "(Zoom / Google), call GET /offboarding/blockers/{id}/revoke-" "preview first to learn whether successor_user_id is required " "and the suggested default. Idempotent on already-resolved " "blockers (returns 400 with code=BLOCKER_RESOLVED so the AI " "client knows the work is done)." ), ) async def revoke_offboarding_blocker( blocker_id: str, notes: str | None = None, successor_user_id: str | None = None, ) -> dict: async with tool_runtime( "revoke_offboarding_blocker", { "blocker_id": blocker_id, "notes": notes, "successor_user_id": successor_user_id, }, mutates=True, ) as rt: _require_identity_sync_admin(rt) from app.domains.offboarding.service import OffboardingService # ``_u`` raises a typed VALIDATION_ERROR with bounded # ``_short_repr`` of the offending value (avoids echoing raw # multi-MB caller input into the LLM context). bid = _u(blocker_id) succ_uuid = _u(successor_user_id) if successor_user_id else None # Cap free-text — written to ``offboarding_blockers.notes``. notes = _bound_free_text(notes, field="notes", max_len=2000) svc = OffboardingService(rt.db) blocker = await svc.resolve_blocker_by_revoke( bid, notes=notes, trace_id=_trace_str(), actor_user_id=rt.user.id, successor_user_id=succ_uuid, ) # ``revoked_auto`` for the direct-revoke branches (vpn / ai-tool # / cloud_access_grant) and the provisioning-pipeline branches # only flip to revoked_auto once the dispatcher completes the # task. ``pending`` means the task was enqueued — the AI client # should poll the blocker / process detail to learn the # eventual outcome. completed_inline = blocker.resolution != "pending" return serialize({ "blocker_id": str(blocker.id), "resource_type": blocker.resource_type, "resolution": blocker.resolution, "resolution_ref": blocker.resolution_ref, "provisioning_task_id": ( str(blocker.provisioning_task_id) if blocker.provisioning_task_id else None ), "completed_inline": completed_inline, }) @mcp_server.tool( name="set_offboarding_default_handover_person", description=( "Admin-only — set or clear the per-process default handover " "successor on an offboarding process (migration 211). Mirrors " "HTTP `PUT /offboarding/processes/{process_id}/default-handover`. " "The default prefills the per-blocker 指定接手人 modal in the " "admin console (and the handover_offboarding_blocker MCP path " "is unaffected — actual successor_person_id is still passed " "per-blocker so audit attribution stays at the blocker level). " "Pass ``successor_person_id=None`` to clear the default. A " "non-NULL value must be the IdentityPerson UUID of an active, " "non-deleted employee; the service rejects (400/409) on " "missing / deleted / non-active / self-handover. Use " "search_directory_people to resolve a human name to a UUID " "first." ), ) async def set_offboarding_default_handover_person( process_id: str, successor_person_id: str | None = None, ) -> dict: async with tool_runtime( "set_offboarding_default_handover_person", { "process_id": process_id, "successor_person_id": successor_person_id, }, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.offboarding.service import OffboardingService pid = _u(process_id) succ = _u(successor_person_id) if successor_person_id else None svc = OffboardingService(rt.db) proc = await svc.set_default_handover_person( pid, successor_person_id=succ, trace_id=_trace_str(), actor_user_id=rt.user.id, ) return serialize({ "process_id": str(proc.id), "default_handover_person_id": ( str(proc.default_handover_person_id) if proc.default_handover_person_id else None ), }) @mcp_server.tool( name="calculate_annual_leave_balance", description=( "HR-only — compute statutory + welfare annual-leave entitlement " "and unused balance for one employee under the company policy " "(fiscal year Apr 1 — Mar 31, social-tenure × company-tenure " "segmentation, employed_days / 365.25 × basis, truncate to 2 " "decimals then round up to 0.5 day, capped at the employee's own " "leave basis, then a 15-day combined cap with statutory kept / " "welfare reduced). Inputs: ISO-date strings for " "work_start_date (career start), hire_date (current company), " "termination_date (or today for projection); decimals for " "carry_legal_days, carry_welfare_days, used_legal_days, " "used_welfare_days. Returns the full computation graph: per-segment " "formulas, raw / rounded / capped values for entitlement and " "settlement blocks, plus the final unused balances HR pays out. " "Result is persisted to hr_tools_annual_leave_calculations and " "an audit row is written. Requires the caller to have the 'hr' " "or 'global_admin' role." ), ) async def calculate_annual_leave_balance( work_start_date: str, hire_date: str, termination_date: str, carry_legal_days: str = "0", carry_welfare_days: str = "0", used_legal_days: str = "0", used_welfare_days: str = "0", person_id: str | None = None, employee_no: str | None = None, employee_name: str | None = None, notes: str | None = None, ) -> dict: async with tool_runtime( "calculate_annual_leave_balance", { "person_id": person_id, "employee_no": employee_no, "work_start_date": work_start_date, "hire_date": hire_date, "termination_date": termination_date, }, mutates=True, ) as rt: # ``_date`` / ``_UUID`` were used by the pre-fix bare parsers; # the typed helpers (``_u`` / ``_parse_iso_date``) replaced them # so the original imports are dead. Only ``Decimal`` is still # used in the body below. from decimal import Decimal as _Decimal from app.core.exceptions import ForbiddenError from app.core.rbac import GLOBAL_ADMIN_ROLE as _ADMIN from app.core.rbac import HR_ROLE as _HR from app.core.rbac import has_role as _has_role from app.domains.hr_tools.schemas import AnnualLeaveCalculateRequest from app.domains.hr_tools.service import AnnualLeaveCalculatorService # Authz check BEFORE any further DB work. ``tool_runtime`` has # already loaded the user + leaver-gate by this point — but # raising ``ForbiddenError`` (an ``AppException`` subclass) # rather than ``PermissionError`` lets the tool runtime emit a # proper 403/FORBIDDEN envelope instead of a 500 INTERNAL_ERROR # that masks the real reason in the audit row. if not _has_role(rt.user.role_names, _ADMIN, _HR): raise ForbiddenError("hr or global_admin role required") # Use the typed-exception helpers so a malformed LLM date / # UUID surfaces as VALIDATION_ERROR (422), not INTERNAL_ERROR # (500). Bare ``_date.fromisoformat`` / ``_UUID`` raise # ``ValueError`` which the runtime catch-all maps to 500 — # and echoes the raw input verbatim into audit + the LLM # context (``_short_repr`` would otherwise bound that surface). body = AnnualLeaveCalculateRequest( person_id=_u(person_id) if person_id else None, employee_no=employee_no, employee_name=employee_name, work_start_date=_parse_iso_date( work_start_date, field="work_start_date", ), hire_date=_parse_iso_date(hire_date, field="hire_date"), termination_date=_parse_iso_date( termination_date, field="termination_date", ), carry_legal_days=_Decimal(carry_legal_days), carry_welfare_days=_Decimal(carry_welfare_days), used_legal_days=_Decimal(used_legal_days), used_welfare_days=_Decimal(used_welfare_days), notes=notes, ) # MCP runtime sets its own audit envelope (ip / user-agent are # part of ``tool_runtime``'s audit wrapping). The service-level # audit row therefore intentionally does not duplicate those — # the resource-typed row records `actor_user_id + trace_id` # which joins back to the runtime row via trace_id. data = await AnnualLeaveCalculatorService(rt.db).calculate_and_record( body=body, actor=rt.user, trace_id=_trace_str(), ) return serialize(data.model_dump(mode="json")) @mcp_server.tool( name="get_cloud_scope_request_timeline", description=( "Lifecycle timeline (submit → approve → cloud_created → " "provisioned, or → reject / withdraw / cancelled_via_feishu) " "for one cloud scope-creation request. Sourced from the " "append-only audit_logs table; backend curates which actions " "are user-meaningful and enriches actor labels. RBAC: admin " "(can_manage_cloud) OR the original requester. Returns events " "oldest-first with actor_user_id, actor_label, occurred_at, " "status (success / failure / skipped), and any operator-" "redacted detail JSON the audit row carries." ), ) async def get_cloud_scope_request_timeline(request_id: str) -> dict: async with tool_runtime( "get_cloud_scope_request_timeline", {"request_id": request_id}, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_manage_cloud as _can_manage_cloud from app.domains.cmdb.models import CmdbCloudScopeRequest from app.domains.cmdb.timeline_service import ( fetch_scope_request_timeline as _fetch_sr_timeline, ) # Validate the LLM-supplied id at the boundary — a malformed # value surfaces as VALIDATION_ERROR (422) instead of a silent # NotFoundError (404), and the audit row's args don't capture # an unbounded garbage string. Matches the sibling pattern in # ``get_cloud_access_grant_timeline`` (line 10803). (R1 code # review finding 2026-05-28.) request_uuid_str = str(_u(request_id)) req = await rt.db.get(CmdbCloudScopeRequest, request_uuid_str) if req is None: raise NotFoundError("CmdbCloudScopeRequest", request_uuid_str) if not ( _can_manage_cloud(rt.user.role_names) or str(req.requester_person_id) == str(rt.user.id) ): raise ForbiddenError( "Only the original requester or a cloud admin can " "read this scope request's timeline.", ) events = await _fetch_sr_timeline(rt.db, request_uuid_str) return {"items": [e.to_dict() for e in events]} @mcp_server.tool( name="get_cloud_access_grant_timeline", description=( "Lifecycle timeline (request → approve / reject / owner_add → " "renew → revoke / disable) for one cloud access grant. Sourced " "from audit_logs and curated to the events a human reader " "would expect. RBAC mirrors the HTTP route: ``can_read_cloud`` " "(global_admin / auditor / cloud_admin / management) OR " "``can_view_finance`` (finance / auditor / management) OR the " "grant holder OR the scope owner — this is the surface the " "scope owner uses to answer \"who has applied for access to " "my subscription, what was approved / rejected, when revoked\"." ), ) async def get_cloud_access_grant_timeline(grant_id: str) -> dict: async with tool_runtime( "get_cloud_access_grant_timeline", {"grant_id": grant_id}, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import ( can_read_cloud as _can_read_cloud, ) from app.core.rbac import ( can_view_finance as _can_view_finance, ) from app.core.rbac import ( is_cloud_scope_owner_of as _is_scope_owner, ) from app.domains.cmdb.models import CmdbCloudAccessGrant from app.domains.cmdb.timeline_service import ( fetch_grant_timeline as _fetch_g_timeline, ) grant = await rt.db.get(CmdbCloudAccessGrant, _u(grant_id)) if grant is None: raise NotFoundError("CmdbCloudAccessGrant", grant_id) if not ( _can_read_cloud(rt.user.role_names) or _can_view_finance(rt.user.role_names) or (grant.person_id and str(grant.person_id) == str(rt.user.id)) ): if not await _is_scope_owner( rt.db, str(rt.user.id), str(grant.scope_id), ): raise ForbiddenError( "Only the grant holder, the scope owner, or a " "cloud / finance admin can read this grant's " "timeline.", ) events = await _fetch_g_timeline(rt.db, str(grant_id)) return {"items": [e.to_dict() for e in events]} # ───────────────────────────────────────────────────────────────────── # Object storage (S3) — presigned URLs for the upload flow. # Mirrors POST /api/v1/uploads/presign-{put,get} so an AI client can # participate in the same self-service flows the browser uses (the # primary motivator is letting an agent attach SN-evidence photos when # responding to an inventory item with response_type=`not_mine`). # ───────────────────────────────────────────────────────────────────── @mcp_server.tool( name="presign_upload_put", description=( "Get a single-create presigned S3 PUT the caller can use to upload " "a file directly to object storage. Pass the exact `size_bytes` and " "`checksum_sha256` (the base64-encoded 32-byte SHA-256 digest); " "send the raw file body with every returned `headers` entry to `url`; " "the HTTP client must also emit the exact declared Content-Length. " "A successful write cannot be replayed because `If-None-Match: *` is " "signed; HTTP 412 means an earlier attempt already created this exact " "key, so continue to the registering tool, whose HEAD checksum check " "is authoritative. Any other non-2xx response is an upload failure. " "Returns `object_key` " "(persist THIS string in the calling tool's payload — never " "the URL, which expires).\n\n" "Allowed `purpose` values:\n" "- `inventory_evidence` — photos backing a `not_mine` response " "via `respond_inventory_item`.\n" "- `feed_post_image` — image attachments for `feed_create_post`.\n" "- `feed_announcement` — images embedded in HR announcements.\n\n" "Allowed `content_type`: `image/jpeg`, `image/png`, " "`image/webp`, `image/heic`, `image/heif`. Anything else 400s. " "Read the object back later with `presign_upload_get`." ), ) async def presign_upload_put( purpose: str, filename: str, content_type: str, size_bytes: int, checksum_sha256: str, ) -> dict: async with tool_runtime( "presign_upload_put", { "purpose": purpose, "filename": filename, "content_type": content_type, "size_bytes": size_bytes, "checksum_sha256": checksum_sha256, }, mutates=True, ) as rt: from app.core.exceptions import AppException from app.core.storage import StorageNotConfiguredError, presign_put from app.core.storage_uploads import ( create_upload_grant, ) # Pre-launch P1 fix (Round 45, CR-502): use typed AppException # so the MCP runtime maps to 422 / 503 rather than INTERNAL_ERROR # (500). Previously these bare ValueError/RuntimeError raises # bypassed the structured error envelope, polluted error logs # with "real crash" alerts, and leaked status-code behavior to # clients probing for allowed purposes. try: grant = await create_upload_grant( rt.db, person_id=rt.user.id, purpose=purpose, filename=filename, content_type=content_type, size_bytes=size_bytes, checksum_sha256=checksum_sha256, ) # Persist the cleanup ledger before issuing the S3 capability. await rt.db.commit() except StorageNotConfiguredError as e: raise AppException( code="STORAGE_NOT_CONFIGURED", message="storage backend not configured", status_code=503, ) from e try: result = await presign_put( rt.db, object_key=grant.object_key, content_type=grant.content_type, size_bytes=grant.declared_size_bytes, checksum_sha256=grant.checksum_sha256, ) except StorageNotConfiguredError as e: raise AppException( code="STORAGE_NOT_CONFIGURED", message=f"storage backend not configured: {e}", status_code=503, ) from e return result @mcp_server.tool( name="presign_upload_get", description=( "Get a short-lived (10 min) presigned S3 GET URL for an " "object already uploaded via `presign_upload_put`. Per-purpose " "ACL is enforced here in addition to authentication — for " "`inventory_evidence` keys the caller must be the uploader or " "hold an asset-read role (global_admin / asset_admin / " "auditor / management); `feed_post_image` and " "`feed_announcement` keys are company-internal and readable by " "any authenticated employee; AI Labs attachments " "(`ai_labs_*`) are refused outright, because that surface has " "no MCP twin and its contents are the employee's private " "workspace. Otherwise mirrors the HTTP " "`/uploads/presign-get` policy so MCP callers don't bypass " "the gate." ), ) async def presign_upload_get(object_key: str) -> dict: async with tool_runtime( "presign_upload_get", {"object_key_len": len(object_key)}, ) as rt: from app.core.exceptions import ( AppException, ForbiddenError, ValidationError, ) from app.core.rbac import can_read_all_assets from app.core.storage import StorageNotConfiguredError, presign_get from app.storage_router import AI_LABS_PURPOSES, _parse_object_key # Pre-launch P1 fix (Round 45, CR-502): typed exceptions — # 422 for malformed key, 403 for ACL denial, 503 for missing # backend. Previously these raised PermissionError/RuntimeError # which the runtime mapped to INTERNAL_ERROR (500), leaking # authorisation behaviour as a generic crash signal. parsed = _parse_object_key(object_key) if parsed is None: raise ValidationError("object_key shape not recognised") purpose, uploader_id = parsed if purpose == "inventory_evidence": if rt.user.id != uploader_id and not can_read_all_assets( rt.user.role_names, ): raise ForbiddenError( "not authorised to view this object", ) elif purpose in {"feed_post_image", "feed_announcement"}: # Feed images + announcement attachments are company- # internal — any authenticated active employee may view. # Mirrors the HTTP presign-get clause exactly (the post- # level visibility gate lives in ``FeedService``; callers # never see the object_key of a post they can't view). pass elif purpose in AI_LABS_PURPOSES: # The ONE deliberate asymmetry with the HTTP policy, stated # explicitly so it does not read as an oversight a later # contributor should "fix": over HTTP the uploader may read # their own AI Labs attachment, and here nobody may. AI Labs # is a web/mobile surface with no MCP twin by decision # (DECISIONS 2026-09-03), so no MCP caller has a reason to # hold one of these keys — and a PAT is exactly the credential # an AI client could be talked into replaying. Refusing costs # nothing and keeps the private workspace off the agent # surface entirely. raise ForbiddenError( "AI Labs attachments are not readable over MCP", ) else: # New purpose added to ``_ALLOWED_PURPOSES`` without a # matching ACL clause here — refuse rather than fall # through to "anyone authenticated" (same fail-closed # default as the HTTP route). raise ForbiddenError( f"no read policy registered for purpose '{purpose}'", ) try: url = await presign_get(rt.db, object_key=object_key) except StorageNotConfiguredError as e: raise AppException( code="STORAGE_NOT_CONFIGURED", message=f"storage backend not configured: {e}", status_code=503, ) from e return {"url": url, "expires_in": 600} # ───────────────────────────────────────────────────────────────────── # Cloud server lifecycle (PR #559) — open/close/restart/destroy MCP twins # ───────────────────────────────────────────────────────────────────── # # 1:1 with the HTTP routes ``POST /cmdb/servers/{id}/{action}``. Same # RBAC gate (can_manage_cloud), same provider dispatch via # ``_load_provider_for_dns``, same idempotent transitional-status # update + soft-delete-on-destroy. Pairs with the MCP-sync rule — # every new self-service / admin flow ships with its MCP twin. async def _mcp_run_server_lifecycle(action: str, server_id: str) -> dict: """Shared driver between the four MCP lifecycle tools. Mirrors ``_execute_server_lifecycle`` in cmdb/router.py — separate code path (the HTTP one runs inside the FastAPI request scope, the MCP one inside ``tool_runtime``), but the contract / audit row / error mapping must stay identical so an AI-client and a human admin look the same in audit_logs. """ async with tool_runtime( f"server_{action}", {"server_id": server_id}, mutates=True, ) as rt: from datetime import UTC, datetime from app.audit.service import AuditService from app.core.exceptions import ( AppException, ForbiddenError, NotFoundError, ValidationError, ) from app.core.rbac import can_manage_cloud from app.domains.cmdb.models import CmdbServer from app.domains.cmdb.providers.exceptions import ( CapabilityNotSupported, ProviderAuthError, ProviderTransientError, redact_provider_error, ) from app.domains.cmdb.router import _load_provider_for_dns if not can_manage_cloud(rt.user.role_names): raise ForbiddenError( "Cloud server lifecycle is restricted to cloud / global admins.", ) # Lock the row for the lifecycle op, matching the HTTP twin # ``_execute_server_lifecycle`` (cmdb/router.py). ``destroy`` is # irreversible; without this, two concurrent callers (MCP+UI, or # double-fire) both pass the ``deleted_at IS NULL`` read and both # fire the provider terminate. The lock serialises them — the second # waits, re-reads after the first commits the soft-delete, no longer # matches ``deleted_at IS NULL``, and gets a clean NotFound. srv = ( await rt.db.execute( select(CmdbServer) .where( CmdbServer.id == _u(server_id), CmdbServer.deleted_at.is_(None), ) .with_for_update() ) ).scalar_one_or_none() if srv is None: raise NotFoundError("Cloud server", server_id) # Honour the production cloud-write kill-switch — the HTTP twin # ``_execute_server_lifecycle`` (cmdb/router.py) gates destroy/ # power_off/restart on it, and the MCP surface must not be a # bypass (destroy = irreversible TerminateInstances). power_on is # non-destructive recovery and stays allowed. Server env is # inherited from its integration's cloud scopes; treat as prod if # ANY active prod scope exists (fail-safe over-protect). if action in ("destroy", "power_off", "restart"): from app.domains.cmdb.models import PROD_ENVIRONMENTS, CmdbCloudScope from app.domains.cmdb.prod_write_lock import ( LOCKED_MESSAGE, prod_cloud_writes_enabled, ) has_prod_scope = (await rt.db.execute( select(CmdbCloudScope.id).where( CmdbCloudScope.integration_id == str(srv.integration_id), CmdbCloudScope.environment.in_(tuple(PROD_ENVIRONMENTS)), CmdbCloudScope.deleted_at.is_(None), ).limit(1) )).first() is not None if has_prod_scope and not await prod_cloud_writes_enabled(rt.db): from app.core.exceptions import AppException raise AppException( code="PROD_CLOUD_WRITES_LOCKED", message=LOCKED_MESSAGE, status_code=409, ) provider = await _load_provider_for_dns(rt.db, str(srv.integration_id)) method_name = { "power_on": "power_on", "power_off": "power_off", "restart": "restart_server", "destroy": "destroy_server", }[action] transitional = { "power_on": "starting", "power_off": "stopping", "restart": "rebooting", "destroy": "terminated", }[action] trace_id = _trace_str() audit = AuditService(rt.db) try: await getattr(provider, method_name)( instance_id=srv.instance_id, region=srv.region, project_id=srv.project_id, ) except CapabilityNotSupported as exc: await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action=f"cmdb.server.{action}", status="failure", resource_type="cmdb_server", resource_id=str(srv.id), detail={ "reason": "capability_not_supported", "provider_message": str(exc)[:300], }, ) await rt.db.commit() raise AppException( code="CLOUD_LIFECYCLE_NOT_SUPPORTED", message=str(exc), status_code=501, ) from exc except ProviderAuthError as exc: await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action=f"cmdb.server.{action}", status="failure", resource_type="cmdb_server", resource_id=str(srv.id), detail={ "reason": "provider_auth_error", "provider_message": redact_provider_error(exc, max_len=300), }, ) await rt.db.commit() raise AppException( code="CLOUD_LIFECYCLE_AUTH_ERROR", message=redact_provider_error(exc, max_len=200), status_code=502, ) from exc except ValueError as exc: raise ValidationError(str(exc)) from exc except ProviderTransientError as exc: msg = redact_provider_error(exc, max_len=200) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action=f"cmdb.server.{action}", status="failure", resource_type="cmdb_server", resource_id=str(srv.id), detail={ "reason": "provider_transient_error", "provider_message": msg, }, ) await rt.db.commit() raise AppException( code="CLOUD_LIFECYCLE_FAILED", message=msg or "Cloud provider rejected the request.", status_code=502, ) from exc except Exception as exc: msg = redact_provider_error(exc, max_len=200) await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action=f"cmdb.server.{action}", status="failure", resource_type="cmdb_server", resource_id=str(srv.id), detail={ "reason": "unexpected_error", "error_class": type(exc).__name__, "provider_message": msg, }, ) await rt.db.commit() raise AppException( code="CLOUD_LIFECYCLE_FAILED", message=msg or "Cloud provider rejected the request.", status_code=502, ) from exc finally: await provider.close() srv.status = transitional srv.last_modified_by_person_id = str(rt.user.id) srv.last_modified_at = datetime.now(UTC) if action == "destroy": srv.deleted_at = datetime.now(UTC) # Single commit for state-change + success audit row (the # earlier shape committed state first then logged audit, which # left the audit row uncommitted — same bug fixed in the HTTP # router's _execute_server_lifecycle). await audit.log( trace_id=trace_id, actor_type="user", actor_user_id=rt.user.id, action=f"cmdb.server.{action}", status="success", resource_type="cmdb_server", resource_id=str(srv.id), detail={ "instance_id": srv.instance_id, "region": srv.region, "project_id": srv.project_id, "new_status": transitional, "soft_deleted": action == "destroy", }, ) await rt.db.flush() await rt.db.commit() await rt.db.refresh(srv) return _serialize_server(srv) @mcp_server.tool( name="server_power_on", description=( "Power on a cloud server (1:1 with HTTP POST /cmdb/servers/" "{id}/power-on). RBAC: ``can_manage_cloud`` (it_admin / " "cloud_admin). Provider must implement lifecycle methods — " "ucloud / aliyun / aws / alicloud_intl supported today; " "azure / gcp / tencent / ksyun return 501. Idempotent: a " "second call on an already-running instance is a no-op. " "Updates the cmdb row's status to ``starting``; the next " "cmdb.servers sync cron reconciles to the cloud-authoritative " "state." ), ) async def server_power_on(server_id: str) -> dict: return await _mcp_run_server_lifecycle("power_on", server_id) @mcp_server.tool( name="server_power_off", description=( "Power off a cloud server (1:1 with HTTP POST /cmdb/servers/" "{id}/power-off). Same RBAC + provider coverage + idempotency " "contract as ``server_power_on``. Updates status to " "``stopping``. Aliyun uses ForceStop=true to match legacy CMDB " "behavior (immediate stop, not graceful)." ), ) async def server_power_off(server_id: str) -> dict: return await _mcp_run_server_lifecycle("power_off", server_id) @mcp_server.tool( name="server_restart", description=( "Restart a cloud server (1:1 with HTTP POST /cmdb/servers/" "{id}/restart). Same RBAC + provider coverage as the other " "lifecycle tools. Updates status to ``rebooting``. Not " "idempotent in the sense that a stopped instance can't be " "restarted — that's a provider-side reject surfaced as 502." ), ) async def server_restart(server_id: str) -> dict: return await _mcp_run_server_lifecycle("restart", server_id) @mcp_server.tool( name="server_destroy", description=( "Terminate a cloud server and soft-delete the cmdb row (1:1 " "with HTTP POST /cmdb/servers/{id}/destroy). IRREVERSIBLE on " "the cloud side — UCloud releases the EIP + data disks, " "Aliyun honors Force=true, AWS terminate_instances is " "destructive. The cmdb row is recoverable via the recycle bin " "(``restore_cmdb_resource``) but the underlying VM is gone. " "AI clients SHOULD confirm with the human before invoking; " "RBAC still gates on ``can_manage_cloud`` server-side." ), ) async def server_destroy(server_id: str) -> dict: return await _mcp_run_server_lifecycle("destroy", server_id) @mcp_server.tool( name="offboard_jamf_device", description=( "Run a confirm-gated offboarding action on ONE Jamf-managed Apple " "device. action='unbind' (clear the assigned user — reversible " "metadata edit), 'lock' (remote lock), or 'wipe' (IRREVERSIBLE " "factory erase). Restricted to global_admin. WIPE IS REFUSED for a " "device the leaver retains (held_by_leaver / buyback) — unbind is " "still allowed. Does NOT auto-run: one device per call, fired " "deliberately. Args: device_type ('computer'|'mobile'), external_id " "(Jamf inventory id), action, optional reason." ), ) async def offboard_jamf_device( device_type: str, external_id: str, action: str, reason: str | None = None, ) -> dict: async with tool_runtime( "offboard_jamf_device", {"device_type": device_type, "external_id": external_id, "action": action}, mutates=True, ) as rt: from app.core.coordinators.jamf_device_action_coordinator import ( execute_jamf_device_action, ) _require_global_admin(rt.user.role_names) res = await execute_jamf_device_action( rt.db, external_id=external_id, device_type=device_type, action=action, actor_user_id=rt.user.id, trace_id=_trace_str(), reason=reason, ) return {"data": res} @mcp_server.tool( name="backfill_jamf_device_owners", description=( "Backfill Jamf's blank assigned-user from Helix's asset system " "(match by serial) so the two systems agree on who holds each Mac. " "DEFAULTS TO dry_run=True — returns the plan WITHOUT writing. Pass " "dry_run=false to actually write (reversible Jamf metadata edit; " "global_admin only). Only ACTIVE-employee owners are filled (never a " "leaver). Optional limit caps the batch. Use dry_run first to review." ), ) async def backfill_jamf_device_owners( dry_run: bool = True, limit: int | None = None, ) -> dict: async with tool_runtime( "backfill_jamf_device_owners", {"dry_run": dry_run, "limit": limit}, mutates=True, ) as rt: from app.core.coordinators.jamf_device_action_coordinator import ( backfill_jamf_assignments_from_assets, ) _require_global_admin(rt.user.role_names) res = await backfill_jamf_assignments_from_assets( rt.db, actor_user_id=rt.user.id, trace_id=_trace_str(), dry_run=dry_run, limit=limit, ) return {"data": res} @mcp_server.tool( name="list_my_server_owner_confirmations", description=( "Pending half-yearly cloud-server inventory confirmations for " "the signed-in owner (1:1 with HTTP GET /cmdb/server-confirmations/mine). " "Returns the snapshot server list per pending confirmation so the " "caller can decide between confirming all vs picking a subset. " "Empty list when the owner has no pending cards — i.e. they've " "already responded to every prior cron tick OR they have no due " "servers (cron skips: no-owner / leaver / newborn <90d / stopped)." ), ) async def list_my_server_owner_confirmations() -> dict: async with tool_runtime("list_my_server_owner_confirmations", {}) as rt: from sqlalchemy import select as _select from app.domains.cmdb.models import CmdbServer from app.domains.cmdb.server_owner_confirmation_service import ( ServerOwnerConfirmationService, ) service = ServerOwnerConfirmationService(rt.db) rows = await service.list_pending_for_owner(str(rt.user.id)) if not rows: return {"data": []} all_ids: set[str] = set() for r in rows: all_ids.update(r.server_ids) meta: dict[str, dict[str, Any]] = {} if all_ids: result = await rt.db.execute( _select(CmdbServer).where(CmdbServer.id.in_(list(all_ids))), ) for s in result.scalars().all(): meta[str(s.id)] = { "id": str(s.id), "instance_id": s.instance_id, "name": s.name, "region": s.region, "status": s.status, } return { "data": [ { "id": str(r.id), "period_start": r.period_start.isoformat(), "status": r.status, "server_count": len(r.server_ids), "servers": [meta[sid] for sid in r.server_ids if sid in meta], } for r in rows ] } @mcp_server.tool( name="respond_server_owner_confirmation", description=( "Owner responds to a pending half-yearly server-inventory " "confirmation. ``kind`` must be 'confirmed_all' (bumps every " "snapshot server's last_owner_confirmed_at) or 'partial' " "(requires ``server_ids`` — the subset the owner confirms; " "the confirmation row stays pending if the snapshot isn't " "fully covered yet). Mirrors HTTP " "POST /cmdb/server-confirmations/{id}/respond. Owner-scope " "only — admins cannot respond on someone else's behalf, by " "design (server-side RBAC check). Marks the underlying " "``CmdbServer.last_owner_confirmed_at`` so the next cron tick " "skips the now-confirmed machines for ~180 days." ), ) async def respond_server_owner_confirmation( confirmation_id: str, kind: str, server_ids: list[str] | None = None, ) -> dict: async with tool_runtime( "respond_server_owner_confirmation", {"confirmation_id": confirmation_id, "kind": kind, "server_ids": server_ids}, mutates=True, ) as rt: from app.core.exceptions import ( AppException, ForbiddenError, ValidationError, ) from app.domains.cmdb.server_owner_confirmation_service import ( ServerOwnerConfirmationService, ) if kind not in ("confirmed_all", "partial"): raise ValidationError( f"unknown kind {kind!r}; expected 'confirmed_all' or 'partial'", ) if kind == "partial" and not server_ids: raise ValidationError("server_ids required for kind='partial'") service = ServerOwnerConfirmationService(rt.db) try: if kind == "confirmed_all": row = await service.respond_confirm_all( confirmation_id=confirmation_id, actor_person_id=str(rt.user.id), ) else: row = await service.respond_per_server( confirmation_id=confirmation_id, actor_person_id=str(rt.user.id), confirmed_server_ids=server_ids, ) # The service raises stdlib exception types; map them 1:1 to the # typed envelope errors so the runtime preserves code/status — # ``raise ValueError(str(exc))`` flattened all of these into # INTERNAL_ERROR 500s. (Launch audit 2026-06-10.) except PermissionError as exc: await _safe_rollback(rt.db) raise ForbiddenError(str(exc) or "Not allowed.") from exc except LookupError as exc: await _safe_rollback(rt.db) raise AppException( code="NOT_FOUND", message=str(exc) or "Not found.", status_code=404, ) from exc except ValueError as exc: await _safe_rollback(rt.db) raise ValidationError(str(exc) or "Invalid input.") from exc # Mirror the HTTP route's domain-specific audit row so MCP- # initiated confirmations are visible on the audit timeline # exactly like browser-initiated ones. Without this, AI agents # that respond on behalf of the owner show up only as the # generic mutates=True runtime audit — losing the kind / # snapshot_size / responded_server_ids forensic detail. from app.audit.service import AuditService await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="cmdb.server_owner_confirmation.respond", status="success", resource_type="cmdb_server_owner_confirmation", resource_id=str(row.id), detail={ "kind": kind, "snapshot_size": len(row.server_ids), "responded_server_ids": server_ids or row.server_ids, "via": "mcp", }, ip_address=mcp_client_ip.get(), user_agent=mcp_user_agent.get(), ) return { "data": { "id": str(row.id), "status": row.status, "response_kind": row.response_kind, "responded_at": row.responded_at.isoformat() if row.responded_at else None, } } @mcp_server.tool( name="next_employee_number", description=( "Preview the next 工号 (employee number) the allocator will hand " "out, without allocating it. Format is 'P' + zero-padded-5 (e.g. " "P02389). Returns next_employee_no, next_seq, and current_max_no. " "Read-only; admits global_admin / hr / auditor / management." " NOT the answer to 「我的工号是多少」 — this is the HR ALLOCATION ledger about everyone; the caller's own number is on `whoami`." ), ) async def next_employee_number() -> dict: async with tool_runtime("next_employee_number", {}) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, MANAGEMENT_ROLE, has_role, ) from app.domains.hr import employee_number_service as _svc if not has_role( rt.user.role_names, GLOBAL_ADMIN_ROLE, HR_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError("hr / auditor / management / global_admin role required") next_no, next_seq, max_no = await _svc.get_next(rt.db) return { "next_employee_no": next_no, "next_seq": next_seq, "current_max_no": max_no, } @mcp_server.tool( name="list_employee_numbers", description=( "List allocated 工号 (employee-number ledger) rows, newest number " "first. Optional filters: employee_no (substring), name (substring), " "source ('manual' | 'import' | 'seed'). Paginated via page / " "page_size (page_size capped at 200). Read-only; admits global_admin " "/ hr / auditor / management." " NOT the answer to 「我的工号是多少」 — this is the HR ALLOCATION ledger about everyone; the caller's own number is on `whoami`." ), ) async def list_employee_numbers( employee_no: str | None = None, name: str | None = None, source: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: async with tool_runtime( "list_employee_numbers", {"employee_no": employee_no, "name": name, "source": source, "page": page}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, MANAGEMENT_ROLE, has_role, ) from app.domains.hr import employee_number_service as _svc if not has_role( rt.user.role_names, GLOBAL_ADMIN_ROLE, HR_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError("hr / auditor / management / global_admin role required") page_size = max(1, min(page_size, 200)) rows, total = await _svc.list_numbers( rt.db, no=employee_no, name=name, source=source, page=max(1, page), page_size=page_size, ) return { "total": total, "page": page, "page_size": page_size, "items": [ { "employee_no": r["employee_no"], "name": r["name"], "hire_date": r["hire_date"].isoformat() if r["hire_date"] else None, "remark": r["remark"], "bu": r["bu"], "source": r["source"], "created_by_name": r["created_by_name"], "created_at": r["created_at"].isoformat() if r["created_at"] else None, } for r in rows ], } @mcp_server.tool( name="allocate_employee_number", description=( "Allocate the next 工号 (employee number) to a name. Mirrors the HTTP " "POST /hr/employee-numbers self-service flow. Inputs: name (required), " "hire_date (ISO date, optional), remark (optional), bu (一级部门 / " "top-level department, optional — must match a live BU from " "list_business_units). The number itself is assigned by the server " "(next 'P'+zero-padded-5). Writes an audit row. Requires the 'hr' or " "'global_admin' role." ), ) async def allocate_employee_number( name: str, hire_date: str | None = None, remark: str | None = None, bu: str | None = None, ) -> dict: async with tool_runtime( "allocate_employee_number", {"name": name, "hire_date": hire_date, "bu": bu}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import GLOBAL_ADMIN_ROLE, HR_ROLE, has_role from app.domains.hr import employee_number_service as _svc if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, HR_ROLE): raise ForbiddenError("hr or global_admin role required") clean_name = (name or "").strip() if not clean_name: raise ValidationError("name is required") parsed_hire = _parse_iso_date(hire_date) if hire_date else None clean_remark = (remark or "").strip() or None clean_bu, bu_ok = _svc.resolve_bu(bu, await _svc.known_bu_map(rt.db)) if not bu_ok: raise ValidationError(f"unknown BU: {bu!r}") # No in-body audit row: ``tool_runtime(mutates=True)`` already writes # the success audit for this tool call in its own session. The # allocated row's created_by/created_at carry the domain attribution. row = await _svc.allocate( rt.db, name=clean_name, hire_date=parsed_hire, remark=clean_remark, bu=clean_bu, created_by=rt.user.id, source="manual", ) return { "employee_no": row.employee_no, "seq": row.seq, "name": row.name, "hire_date": row.hire_date.isoformat() if row.hire_date else None, "remark": row.remark, "bu": row.bu, } @mcp_server.tool( name="set_next_employee_number", description=( "Manually set the next 工号 (employee number) the allocator will hand " "out (工号维护). Mirrors HTTP POST /hr/employee-numbers/next. Inputs: " "next_employee_no (e.g. P05000; padded or not) and reason (required). " "The target must be strictly greater than the current maximum " "allocated number — a smaller value would collide with an existing " "工号. Records the operator + reason as a maintenance entry and writes " "an audit row. Requires the 'hr' or 'global_admin' role." ), ) async def set_next_employee_number( next_employee_no: str, reason: str, ) -> dict: async with tool_runtime( "set_next_employee_number", {"next_employee_no": next_employee_no}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, ValidationError from app.core.rbac import GLOBAL_ADMIN_ROLE, HR_ROLE, has_role from app.domains.hr import employee_number_service as _svc if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, HR_ROLE): raise ForbiddenError("hr or global_admin role required") clean_reason = (reason or "").strip() if not clean_reason: raise ValidationError("reason is required") row = await _svc.set_next( rt.db, next_employee_no=next_employee_no, reason=clean_reason, created_by=rt.user.id, ) return { "next_employee_no": row.next_employee_no, "next_seq": row.next_seq, "previous_next_employee_no": row.previous_next_employee_no, "reason": row.reason, } @mcp_server.tool( name="list_employee_number_overrides", description=( "List 工号维护记录 — the manual next-number changes, newest first. " "Mirrors HTTP GET /hr/employee-numbers/next/history. Each row carries " "the new and previous next number, the reason, the operator, and the " "timestamp. Paginated via page / page_size (capped at 200). Read-only; " "admits global_admin / hr / auditor / management." " NOT the answer to 「我的工号是多少」 — this is the HR ALLOCATION ledger about everyone; the caller's own number is on `whoami`." ), ) async def list_employee_number_overrides( page: int = 1, page_size: int = 20, ) -> dict: async with tool_runtime( "list_employee_number_overrides", {"page": page}, ) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import ( AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, HR_ROLE, MANAGEMENT_ROLE, has_role, ) from app.domains.hr import employee_number_service as _svc if not has_role( rt.user.role_names, GLOBAL_ADMIN_ROLE, HR_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError("hr / auditor / management / global_admin role required") page_size = max(1, min(page_size, 200)) rows, total = await _svc.list_overrides( rt.db, page=max(1, page), page_size=page_size, ) return { "total": total, "page": page, "page_size": page_size, "items": [ { "next_employee_no": r["next_employee_no"], "previous_next_employee_no": r["previous_next_employee_no"], "reason": r["reason"], "created_by_name": r["created_by_name"], "created_at": r["created_at"].isoformat() if r["created_at"] else None, } for r in rows ], } # Module-level marker — every tool above is registered by import time. # Counting them here is a small smoke check used in tests, so a # regression that drops a registration shows up loudly. # # 2026-05-02: bumped from 64 → 90 (+26 new self-service tools): # seat self-cancel/undo (×2) # software-catalog owner-edit (×1) # inventory list/cycles/respond (×3) # DNS submit/list/cancel/recertify (×4) # SSO grant submit/list/cancel (×3) # domain registration submit/list/cancel (×3) # VPN submit/list/cancel (×3) # announcement submit/list/cancel (×3) # AI tool submit/list/cancel + approved registry (×4) # 2026-05-03: bumped 90 → 98 (+8 BU / cost-analytics / notification tools): # list_business_units (×1) # my_software_cost_summary + subordinates_software_cost_summary (×2) # ai_spending_overview / movers / forecast / by_bu (×4) # my_notification_summary (×1) # (list_my_notifications and mark_my_notifications_read already existed # pre-2026-05-03 — they were refactored to share the new InboxService # with the HTTP /notifications/inbox surface, no name change.) # 2026-05-03 (later same day, PR #158): bumped 98 → 105 (+7 cloud # scope/grant self-service tools — the constant was NOT bumped at the # time, caught 2026-05-04 by a routine count audit and corrected here): # submit_cloud_access_request (×1) # list_my_cloud_grants (×1) # renew_my_cloud_grant + revoke_my_cloud_grant (×2) # submit_cloud_scope_request + list_my_cloud_scope_requests (×2) # withdraw_my_cloud_scope_request (×1) # 2026-05-06: bumped 106 → 107 (+1 admin override for the cloud # grants-sync drift-sweep safety floor — without this escape valve a # single bulk admin cleanup in the cloud console deadlocks every # subsequent grants_sync run because the floor's match-rate stays # below 50% until the stale rows close, but the stale rows can't # close because the floor refuses): # force_cloud_grants_drift_sweep (×1) # 2026-05-06 — owner self-transfer (peer-to-peer cloud-scope handoff # without admin middleman; HTTP route is PATCH /cmdb/scopes/{id} with # owner_person_id from the current owner): # transfer_cloud_scope_owner (×1) # Pairs with the MCP-sync rule in CLAUDE.md / memory — every new # self-service flow MUST land with its MCP tool in the same PR, AND the # author MUST bump this hint in the same PR. Forgetting the bump is a # small lie about platform state that costs the next reader a real # debug round (the hint is what `_REGISTERED_TOOLS_COUNT_HINT` smoke # checks against in tests, and what PLATFORM.md / ROADMAP.md cite). # 2026-05-06 (PR for Identity Sync Center): bumped 108 → 112 (+4 # layered on top of the +2 from PR #186 transfer_cloud_scope_owner): # list_identity_sync_targets (×1) # trigger_identity_sync_target (×1) # sync_person_to_identity_target (×1) # get_identity_sync_state_for_person (×1) # 2026-05-07 (PR renaming Identity Sync Center → 身份源数据与认证 + # adding the SSO Apps catalog page; later same day a Round-2 audit # added four more admin tools so AI-assisted bulk admin workflows # have full MCP parity with the HTTP surface): # list_sso_apps (×1) # set_sso_app_owner (×1) # create_sso_app (×1) # delete_sso_app (×1) # import_sso_apps_csv (×1) # resend_sso_app_notification (×1) # 2026-05-07 (PR for cloud-side orphan principal cleanup — AWS Identity # Center "User not found" sweep + history tab + cloud-console pivot # button on the scope detail page): bumped 118 → 120 (+2): # scan_cloud_orphan_assignments (×1) # cleanup_cloud_orphan_assignments (×1) # 2026-05-08 (this pre-launch audit): bumped 120 → 119 (-1). # ``force_cloud_grants_drift_sweep`` was REMOVED — its name matched the # ``test_mcp_tools_surface._FORBIDDEN_TOOL_NAME_PATTERNS`` denylist # (``^force_*``, alongside ``^purge_`` / ``^wipe_`` / ``^impersonate`` # / etc.), which structurally refuses admin-override semantics from # every PAT-reachable surface so a stolen token cannot bypass the 50% # drift-sweep safety floor. The matching HTTP route # ``POST /cmdb/integrations/{id}/grants-sync/force-drift-sweep`` is # retained for admin web access — this is the single authorised # exception to the MCP-sync rule (CLAUDE.md), explicitly carved out # because the test surface treats the operation as launch-blocker # semantics. # 2026-05-08 (manual offboarding entry point — admin/HR can trigger # the full offboarding flow without waiting for HRIS sync, primarily # for testing the pipeline on un-onboarded HRIS tenants and for # emergency manual terminations): bumped 119 → 120 (+1): # mark_person_leaver_manual (×1) # 2026-05-08 (offboarding hardening pass — D-1 dry-run): bumped 120 → 121 # (+1). Read-only preview that mirrors mark_person_leaver_manual scan # without persisting or firing Google API calls; lets AI clients confirm # the offboarding scope (resource list + auto-picked successors) # before committing. # get_offboarding_preview (×1) # 2026-05-09 (Round-15 audit — scheduled-job failure ack flow): bumped # 121 → 122 (+1). Pairs with the matching HTTP route # ``POST /scheduled-jobs/runs/{id}/acknowledge``: AI clients can ack a # known protective failure (e.g. CMDB IAM-sync drop-tolerance floor) so the # default red queue stops surfacing the same incident on every cron # tick. Subsequent failures of a different signature still notify, and # acking does NOT rewrite ``status='failed'`` — the audit truth stands. # acknowledge_scheduled_job_run (×1) # 2026-05-09 (round-15 JML deep audit fix CR2-002 — MCP-sync rule for # the new admin-cancel transfer HTTP route): bumped 122 → 123 (+1). # Admin-override path for clearing orphaned peer-to-peer transfers # where the initiator has left the company. Audit row # ``transfer.cancelled_admin_override``. # admin_cancel_asset_transfer (×1) # 2026-05-09 (production-grade FinOps surface — migration 139 ships # ``cmdb_cost_facts`` line-item ground truth + allocation rules + # analytics service. MCP parity with the new ``/api/v1/cmdb/cost/*`` # HTTP routes per the MCP-sync rule): bumped 123 → 127 (+4). # cost_breakdown / cost_top_resources / cost_forecast / # cost_anomalies (×4) # 2026-05-10 (FinOps audit-round MCP-sync gap closure — the original # launch covered 4 of 9 cost_router endpoints; the audit round flagged # timeseries / allocation-rules-list / recompute / backfill as MCP- # unreachable, breaking the ``feedback_mcp_sync_rule`` invariant for AI # clients driving FinOps workflows): bumped 127 → 131 (+4). # cost_timeseries / cost_allocation_rules_list / # cost_recompute_allocation (mutates) / cost_backfill (mutates) (×4) # 2026-05-10 (BU-leader department cost view + apikey budget-check cron — # pairs with the matching HTTP route ``GET /api/v1/cmdb/cost/subordinates`` # that powers the ``/my/cloud/department-cost`` page; per the MCP-sync # rule every new self-service flow ships with its 1:1 MCP tool in the # same PR. The apikey.budget_check cron has no matching MCP tool — # it's a scheduled job, not a self-service HTTP flow): bumped 131 → 132 # (+1). # cost_subordinates_breakdown (×1) # 2026-05-10 (scope-owner attribution fallback + per-Key MTD spend # endpoint — pairs with the new ``GET /apikeys/assignments/{id}/spend`` # self-service HTTP route per the MCP-sync rule. The route returns # month-to-date spend + budget-threshold state for the assignment's # applicant, mirroring the data the apikey.budget_check cron alerts # on. AI clients use the tool to answer "how much did I spend on my # Azure OpenAI key this month?" without scraping the dashboard): # bumped 132 → 133 (+1). # get_api_key_spend (×1) # 2026-05-11 (per-assignment edit — budget + alert recipients): pairs # with the new ``PATCH /api/v1/apikeys/assignments/{id}`` self-service # HTTP route per the MCP-sync rule. Authz: applicant OR asset_admin. # Tri-state notify_recipients (None/[]/[ids]) matches the schema. # bumped 133 → 134 (+1). # update_api_key_assignment (×1) # 2026-05-12 (offboarding-buyback amount quote — IT policy depreciation # / tiered floor): pairs with the new ``GET /api/v1/approval/asset/ # buyback-quote`` self-service HTTP route per the MCP-sync rule. # Authz: applicant (asset owner/borrower) OR asset_admin. Returns the # read-only amount the buyback form now displays, plus a per-currency # breakdown so AI clients can quote in any of CNY/USD/JPY/SGD. # bumped 134 → 135 (+1). # get_buyback_quote (×1) # 2026-05-12 (held-by-leaver asset state — non-standard but real: the # hardware physically stays with the ex-employee. Offboarding's skip # action auto-flips to ``held_by_leaver`` so the asset is visibly # distinct from a healthy ``assigned`` row, locked out of claim / # borrow / transfer, and surfaced in admin lists. Pairs with the # matching HTTP routes ``POST /assets/{id}/mark-held-by-leaver`` + # ``POST /assets/{id}/recover-from-leaver``): bumped 135 → 137 (+2). # mark_asset_held_by_leaver (×1) # recover_asset_from_leaver (×1) # 2026-05-13 (sync skipped-unresolved feedback — per-scope count from # last sync so admins/AI can explain why cloud-manually-added people # don't appear in the Helix members list): bumped 137 → 138 (+1). # get_cloud_scope_last_sync_summary (×1) # 2026-05-14 (HR Tools — 年假计算器 self-service flow for HR + AI # clients, pairs with POST /hr-tools/annual-leave/calculate per the # MCP-sync rule. Caller must hold HR or global_admin; tool persists a # full computation-graph history row and writes an audit log entry): # bumped 138 → 139 (+1). # calculate_annual_leave_balance (×1) # 2026-05-14 (offboarding blocker revoke — cloud_access_grant gap fix; # the HTTP route /offboarding/blockers/{id}/revoke now also accepts # RES_CLOUD_ACCESS_GRANT, and the matching MCP wrapper lets AI clients # clear leaver residuals without going through the UI. Pairs with # the new cmdb.leaver_grant_sweep cron — the cron is the daily safety # net; this tool is the on-demand admin lever): bumped 139 → 140 (+1). # revoke_offboarding_blocker (×1) # 2026-05-15 (Software Seat Cancellations overview — admin-facing # unified view at /admin/software/cancellations that slices revoke # activity into scheduled / auto-done / awaiting-manual / failed. # Pairs with the matching HTTP filter # ``GET /assets/seat-allocations?has_pending_cancel=true`` per the # MCP-sync rule. Admin-only; AI clients can answer "whose seats are # scheduled to be cut next month?" without scraping the dashboard. # Same-PR follow-up after a round-1 sibling-coverage audit: added the # admin-side counterpart to list_my_provisioning_tasks so AI clients # can drive ALL four segments of the overview through MCP, not just # the scheduled segment. Both tools introduced same day): bumped # 140 → 142 (+2). # list_scheduled_software_cancellations (×1) # list_provisioning_tasks (×1) [admin counterpart to list_my_provisioning_tasks] # 2026-05-15 (per-scope month-anchored billing detail — pairs with the # new HTTP route GET /cmdb/scopes/{id}/billing-detail per the MCP-sync # rule. Powers the shared SubscriptionBillingDrawer rendered on both # the employee `/my/cloud/grants` page and the admin Subscriptions # tab. Authz: scope member/owner OR cloud_admin/it_admin/auditor/ # finance role. AI clients use it to answer "how much did # subscription X spend last month and on what?"): bumped 142 → 143 # (+1). # get_cloud_scope_billing_detail (×1) # 2026-05-15 (Cloud-scope owner governance — semiannual confirmation # cron + zero-spend monthly alert cron, plus the matching HTTP routes # ``GET /cmdb/scopes/{id}/owner-confirmations``, # ``POST .../semiannual-confirm``, and # ``POST .../zero-spend-respond``. AI clients (and the audit / # governance dashboards that talk MCP) can list a scope's governance # timeline and respond on the owner's behalf without screen-scraping # the detail page. Pairs with the rich-card Feishu push upgrade that # landed in the same PR). bumped 143 → 146 (+3). # list_cloud_scope_owner_confirmations (×1) # respond_cloud_scope_semiannual_confirm (×1) # respond_cloud_scope_zero_spend (×1) # 2026-05-16 (Software-billing surface gap — the per-period preview / # list / known-periods HTTP routes under /assets/software-billing/* # had no MCP twins, violating the MCP-sync rule. AI clients couldn't # answer "how much do we owe this month?" or "which periods are still # open?" without scraping the admin UI. Pairs with the new # test_billing_revoke_mid_month regression suite that pins the # revoke-mid-month-still-billed invariant): bumped 146 → 148 (+2). # software_billing_preview_period (×1) # list_software_billing_periods (×1) # 2026-05-18 (Round-30 follow-up #1 — admin-side network annotate / # domain-registration surface gap: the existing self-service trio # (submit / list_my / cancel_my) for domain_registration shipped # without the matching admin handover / revoke flows, and CDN PATCH # annotate had no MCP twin while server / lb / dns_record annotate # already did. AI clients driving leaver handovers or CDN owner / # cert governance had to fall back to HTTP scrapes. RBAC mirrors # HTTP exactly — handover / revoke are _require_global_admin # (matches Depends(require_admin())), annotate_cdn_domain checks # can_manage_cloud (matches the HTTP gate, which also lets # cloud_admin in). annotate_cdn_domain writes the same # `cmdb.cdn_domain.annotate` audit row the HTTP route writes so # auditors filtering on the resource action see MCP-driven # annotations too): bumped 148 → 151 (+3). # handover_domain_registration (×1) # revoke_domain_registration (×1) # annotate_cdn_domain (×1) # 2026-05-18 (per-AK spend detail — PR #361): the new # /apikeys/assignments/{id}/spend/detail HTTP route exposes the 6-month # series + per-meter model breakdown; MCP twin lets agents answer # "where did this Key's money go this month, by model?" with the same # bucket dispatch / filter recipe. Bumped 151 → 152 (+1). # get_api_key_spend_detail (×1) # 2026-05-19 (laptop_replacement supplement-uploads): the new # /asset/employee-requests/{id}/supplement-uploads HTTP route lets the # requester (or admin) attach files to an in-flight approval — Feishu # locks form fields on submitted instances, so we post the files as a # comment-with-attachment on the approval instance via the new # FeishuApprovalClient.add_instance_comment. Built for the buyback # flow where the requester now ships the form without # 转账成功截图 / 上传数据清理视频证明 and supplements them later. # Bumped 152 → 153 (+1). # supplement_request_uploads (×1) # 2026-05-18 (cloud scope-request + grant lifecycle timeline): the new # GET /cmdb/{scope-requests,grants}/{id}/timeline HTTP routes expose a # curated audit-backed event stream so owners, requesters, and admins # can see the full lifecycle of one scope-creation request or one # access grant on a single record. MCP twin lets agents answer "who # has applied for access to my subscription, what was approved / # rejected, when revoked" without scraping the audit_logs table. # Bumped 153 → 155 (+2). # get_cloud_scope_request_timeline (×1) # get_cloud_access_grant_timeline (×1) # 2026-05-19 (cloud-grant approval inbox): the new # /cmdb/lifecycle/my-pending-grant-approvals HTTP route returns every # pending cloud-access grant the caller is responsible for approving # (across all owned scopes for plain owners, org-wide for # global_admin). Mirrors the new MyTodosPage "云授权审批" tab and lets # an AI client answer "what cloud approvals are sitting on my plate?" # without per-scope round-trips. Bumped 155 → 156 (+1). # list_my_pending_cloud_grant_approvals (×1) # 2026-05-19 (office-held asset mode, migration 183/184/185): the new # HTTP routes /assets/{id}/recycle-to-office and # /assets/{id}/release-from-office let an asset admin route a device # into / out of the office-held lifecycle. The bulk variant # /assets/bulk-recycle-to-office exists too but doesn't get a top- # level MCP tool — LLM clients should iterate the single-asset tool. # Bumped 156 → 158 (+2). # recycle_asset_to_office, release_asset_from_office (×2) # 2026-05-19 (API-Key lifecycle closure PR1): self-service release # flow. Employees can ask their leader (via Feishu) to revoke an API # key they own; the leader's approval triggers the same cloud teardown # as admin revoke. revoke_api_key gains a reason_code parameter for # governance reports — old callers stay compatible (reason_code # defaults to admin_revoke server-side). Bumped 158 → 160 (+2): # release_my_api_key (×1) # cancel_my_api_key_release (×1) # 2026-05-19 (API-Key lifecycle closure PR2): self-service transfer # flow. Owner picks a recipient → AssetTransferRequest queued for # accept → on accept the cloud credential survives, ownership flips, # cost-allocation rule's assign_person_id swaps, and AWS IAM user's # Owner tag is rewritten. Bumped 160 → 162 (+2): # transfer_my_api_key (×1) # cancel_my_api_key_transfer (×1) # 2026-05-19 (API-Key lifecycle closure PR3): admin retry for the # best-effort AWS IAM tag rewrite that runs during transfer. The # transfer's local row + cost rule are already authoritative; this # tool fixes the cloud-side tag drift without a full autograny # rebind. Bumped 162 → 163 (+1): # retry_api_key_iam_retag (×1) # 2026-05-20 (API-Key release flow: Feishu-template → Helix-internal): # the self-service release flow no longer routes through a Feishu # approval template. Submitting a release now inserts an # api_key_release_requests row, pushes a Feishu PUSH notification (not # approval card) to the assignee's direct manager, and the manager # clicks Approve / Reject INSIDE Helix. Three new MCP tools surface # the approver-side flow to AI clients (the requester-side # release_my_api_key / cancel_my_api_key_release tools above retain # their names + signatures; only the underlying state machine # changed). Bumped 163 → 166 (+3): # list_my_api_key_release_approvals (×1) # approve_my_api_key_release (×1) # reject_my_api_key_release (×1) # 2026-05-20 (Object storage surface): S3 presigned URL helpers backing # the inventory `not_mine` photo requirement and any future upload # flow. Lets an AI client attach SN evidence the same way the browser # does. The respond_inventory_item tool above also gained an # evidence_photos param (signature change, not a new tool). # Bumped 166 → 168 (+2): # presign_upload_put (×1) # presign_upload_get (×1) # 2026-05-21 (4-eyes admin recovery on DNS requests): two new MCP tools # pair 1:1 with the new /cmdb/dns-record-requests/{id}/bypass-execute # and /retry HTTP endpoints. The bypass tool is audited + fan-outs a # notification to every other admin; the retry tool re-opens a Feishu # approval after the admin fixes the template's approver routing. # Bumped 168 → 170 (+2): # bypass_4_eyes_dns_request (×1) # retry_blocked_dns_request (×1) # 2026-05-21 (Approval flow stages + admin notes): two tools surfacing # the new "/approval/admin/flow-stages" + "/approval/admin/stage-notes" # endpoints to AI clients so an LLM agent can audit every Feishu # flow's chain of approvers and curate the per-stage hints employees # see on the create-request preview. Bumped 170 → 172 (+2): # list_approval_flow_stages (×1) # set_approval_stage_note (×1) # 2026-05-22 (Cost-fact tag harvest broadening + dashboard surfacing): # AWS CUR 2.0 ingest now harvests EVERY user-defined tag (not just the # narrow 5-tag allowlist); top_resources / scope-billing-detail surface # them on the dashboard so "who created this resource" is answerable # from existing tags without a CloudTrail lookup. One new MCP tool # (``cost_tag_keys``) discovers which tag keys exist in the cost # window; ``cost_top_resources`` (signature change, not a new tool) # gained ``tag_key`` + ``tag_value`` filter params. Bumped 172 → 173: # cost_tag_keys (×1) # 2026-05-22 (Resource-creator attribution via cloud audit logs): # Tag-based attribution covers the "owner sets a tag" case; for # untagged resources we now also resolve "who created this" by # querying CloudTrail (AWS) / Cloud Audit Logs (GCP) / # ActionTrail (Aliyun) / Activity Log (Azure). New MCP tool # ``cost_resolve_resource_creator`` exposes the same surface as # the HTTP twin GET /cmdb/cost/resources/{int}/{rid}/creator, # with refresh-the-cache opt-in. Bumped 173 → 174: # cost_resolve_resource_creator (×1) # 2026-05-25 (Idle-scope sweep dedup + owner self-service snooze): # The weekly ``cmdb.scope_idle_sweep`` cron now dedups per scope # (14-day window) and supports owner-initiated "ignore 30 days" # via the new ``POST /cmdb/scopes/{id}/snooze-idle`` HTTP route. # Same shape exposed to AI clients here. Bumped 174 → 175: # snooze_cloud_scope_idle_reminder (×1) # 2026-05-25 (API Key shared-access roster — migration 200): # An API Key's Owner (``ApiKeyAssignment.assigned_to``) can now # grant a small team read access to the same spend / detail / # reveal surfaces the Owner sees, via three new MCP tools that # mirror the new HTTP route trio ``GET / POST / DELETE # /apikeys/assignments/{id}/members``. Owner-only mutations # (transfer / release / revoke / extend / edit budget) stay # exclusively with the Owner. Bumped 175 → 178: # list_api_key_members (×1) # add_api_key_member (×1) # remove_api_key_member (×1) # 2026-05-26 (Resource-creator force-retry escape hatch): # When ``cmdb_resource_creators.attempt_count`` hits the cap (5), # the cron sweep excludes the row from its candidate pool — even # after the underlying cloud-side issue is fixed the row stays # stuck. New ``POST /cmdb/cost/resources/{int}/{rid}/creator/ # retry`` HTTP endpoint zeros the counter + clears cooldown + # fires a fresh resolve, and the MCP twin exposes the same # affordance to AI clients (mutates=True, same RBAC as the read # path). Bumped 178 → 179: # cost_retry_resource_creator (×1) # 2026-05-26 (Navan-sync optimisation pass — identity-sync # mapping preview surface): the same per-rule trace the admin # UI's Mapping Preview drawer renders is now an MCP read-only # tool so AI clients can diagnose "why is field X empty in # Navan for person Y" without crafting the call by hand. Bumped # 179 → 180: # preview_identity_sync_target (×1) # 2026-05-27 (Idle software-seat reclaim — software_seat.idle_reclaim): # new daily job warns + reclaims unused Atlassian (Jira/JSM/Confluence) # seats. The self-service "keep my seat" opt-out (HTTP POST # /assets/seat-allocations/{id}/keep) ships with its 1:1 MCP twin so an # AI client can keep a seat on the holder's behalf when they ask in # chat. Bumped 180 → 181: # keep_my_idle_seat (×1) # 2026-05-27 (Asset recycle bin + restore): soft-deleted assets used to # be unreachable (no list, no restore) and the intake 409 even pointed # admins at a non-existent "recycling bin". New HTTP routes # GET /assets/recycle-bin + POST /assets/{id}/restore get their 1:1 MCP # twins so an AI client can list and restore deleted assets. Bumped # 181 → 183: # list_recycle_bin_assets (×1) # restore_asset (×1) # 183 → 185: # seat_reconciliation_overview (×1) # software_seat_reconciliation_detail (×1) # cost_rollup_drift (×1) # 2026-05-28 (Offboarding default handover person — migration 211): # new HTTP PUT /offboarding/processes/{id}/default-handover lets IT # Admin set or clear a per-process default successor that prefills the # per-blocker 指定接手人 modal. 1:1 MCP twin so AI clients can drive # the same flow on the admin's behalf. Bumped 185 → 186: # set_offboarding_default_handover_person (×1) # 2026-05-28 (CMDB legacy-ID rescue + recycle bin — migration 212): # new admin escape hatch for soft-deleted CMDB rows across 5 resource # types (server / domain / dns_record / load_balancer / cdn_domain), # rolled out after the legacy-CMDB-migration → UCloud-sync collision # silently swept 116 UCloud servers. HTTP routes # GET /cmdb/recycle-bin/{resource_type} + # POST /cmdb/recycle-bin/{resource_type}/{id}/restore get their MCP # twins so AI clients can list and restore. Bumped 186 → 188: # list_cmdb_recycle_bin (×1) # restore_cmdb_resource (×1) # 2026-05-28 (PR #559 — server lifecycle 开机/关机/重启/删除): # Legacy CMDB 服务器列表 had open/close/restart/delete action buttons; # Helix shipped without them and the user reported "现在什么都没有了". # Added 4 HTTP routes POST /cmdb/servers/{id}/{power-on|power-off| # restart|destroy} with their 1:1 MCP twins. Provider impl covers # ucloud / aliyun / aws / alicloud_intl (the four that own 99% of # the prod fleet); the other 4 providers raise 501. Bumped 188 → 192: # server_power_on (×1) # server_power_off (×1) # server_restart (×1) # server_destroy (×1) # 2026-05-29 (migration 214 — cloud-server半年盘点): # Per-owner aggregated half-yearly inventory confirmation. Owner # receives one Feishu card listing every server they own that's past # its 180-day window; ``last_owner_confirmed_at`` on cmdb_servers # stamped on respond. HTTP routes GET /cmdb/server-confirmations/mine # + POST /cmdb/server-confirmations/{id}/respond with their MCP twins. # Bumped 192 → 194: # list_my_server_owner_confirmations (×1) # respond_server_owner_confirmation (×1) # 2026-05-29 (migration 218 — domain data_source + RBAC relax): # HTTP `PATCH /cmdb/domains/{id}` gained 3 new editable sync-owned # columns (registrar / expiry_date / auto_renew). The MCP twin # ``annotate_domain`` mirrors it 1:1 so AI clients can fix the very # stale-expiry problem migration 218 exists to surface (see # DECISIONS.md "CMDB domain provenance"). Bumped 194 → 195: # annotate_domain (×1) # 2026-05-30 (migration 222 — procurement domain PR 1): # Six tools mirror the procurement domain's primary read surfaces so # AI clients can answer "which contracts expire next quarter", "list # all suppliers in active status", "show me PO X" etc. without an # HTTP detour. Two write tools (create_supplier, create_procurement_ # order) cover the most common admin actions; the rest stay HTTP- # only this PR. Bumped 195 → 203: # list_procurement_categories (×1) # list_procurement_suppliers (×1) # create_procurement_supplier (×1, mutates) # list_procurement_contracts (×1) # get_procurement_contract (×1) # list_procurement_orders (×1) # get_procurement_order (×1) # create_procurement_order (×1, mutates) # 2026-05-30 (migration 224 — procurement domain PR 3): # Two read tools for the new budget pool: one lists budgets (optional # year / department filter), one gets balance + activity for a single # budget. Procurement reminders cron seeded same migration is # scheduler-driven, not MCP-exposed. Bumped 203 → 205: # list_procurement_budgets (×1) # get_procurement_budget (×1) # 2026-06-01 (PR-1 of the symmetric-onboarding series — promote the # onboarding domain from a read-only cockpit to a first-class state # machine, mirror the offboarding tool surface): bumped 205 → 213 (+8): # list_onboarding_processes (×1) # get_onboarding_process (×1) # set_onboarding_default_buddy (×1) # set_onboarding_hardware_allocation (×1) # advance_onboarding_now (×1) # cancel_onboarding (×1) # skip_onboarding_blocker (×1) # mark_onboarding_blocker_resolved (×1) # 2026-06-01 (PR-2 — provisioning bundles + cockpit inline-action # surface). Bundles group N ProvisionPolicy rows under named kits # ("SDE 标准包" / "销售标准包"); apply-to-person fans out grants for a # single onboarding process in one call. Bumped 213 → 217 (+4): # list_provisioning_bundles (×1) # get_provisioning_bundle (×1) # create_provisioning_bundle (×1) # apply_provisioning_bundle_to_person (×1) # 2026-06-01 (PR-3 — manager team-joiners surface). 3 new tools for the # manager-facing flow at /my/team-joiners. Bumped 217 → 220 (+3): # list_my_team_joiners (×1) # set_my_team_joiner_buddy (×1) # set_my_team_joiner_hardware_allocation (×1) # 2026-06-01 (PR-4 — new-hire self-service /me/onboarding). 1 new tool: # get_my_onboarding_status (×1) — caller reads OWN process + readiness # Bumped 220 → 221. # 2026-06-01 (PR-5 of symmetric-onboarding — access-review track): # Per-row reviewer assignment + per-row deadline for privileged-role # review cycles + self-scoped pending list. Bumped 221 → 223 (+2): # assign_privileged_review_holder (×1) # list_my_pending_privileged_reviews (×1) # 2026-06-01 (migration 235 — 动态广场 / feed domain): # Ten tools mirror the feed surface 1:1 so AI clients can read the # stream, post, react, send a blessing, and edit their own birthday # settings without a browser. Bumped 223 → 233: # feed_list_posts (×1) # feed_create_post (×1, mutates) # feed_react_post (×1, mutates) — like/unlike toggle # feed_add_comment (×1, mutates) # feed_send_blessing (×1, mutates) # feed_search_posts (×1) # feed_today_celebrations (×1) # feed_list_announcements (×1) # feed_get_my_preferences (×1) # feed_update_my_preferences (×1, mutates) # 2026-06-02 (leaver-blocked admin — force-bypass-grace): # One IT-admin tool to skip the T+0 24h grace window on a stuck # leaver process. Bumped 233 → 234: # leaver_blocked_force_bypass_grace (×1, mutates) # 2026-06-02 (self-service owner transfer for cmdb DNS / servers): # Two tools mirror the new HTTP endpoints # (POST /cmdb/dns-records/batch-owner-transfer + # POST /cmdb/servers/batch-owner-transfer). Bumped 234 → 236: # transfer_my_dns_records (×1, mutates) # transfer_my_servers (×1, mutates) # 2026-06-04 (migration 249 — 工号系统 / employee-number allocation): # Three tools mirror the new HTTP surface under /hr/employee-numbers so AI # clients can preview / list / allocate 工号 without a browser. Bumped # 236 → 239: # next_employee_number (×1) # list_employee_numbers (×1) # allocate_employee_number (×1, mutates) # 2026-06-04 (migration 250 — GRC spine / 安全合规 redesign): three # read tools mirror the new HTTP surface under /api/v1/compliance so AI # clients can answer "are we SOC2/ISO27001 ready and which clauses fail". # Bumped 239 → 242: # get_compliance_posture (×1) # list_compliance_controls (×1) # get_compliance_framework (×1) # 2026-06-04 (migration 251 — access certification engine, GRC PR2): one # read tool mirrors the new /api/v1/compliance/certifications surface. # Bumped 242 → 243: # list_access_certifications (×1) # 2026-06-04 (migration 252 — 工号维护 / next-number override + BU field): # Two tools mirror the new HTTP surface (POST /hr/employee-numbers/next + # GET /hr/employee-numbers/next/history). allocate_employee_number / # list_employee_numbers gained a bu field but stayed the same tools. Bumped # 243 → 245: # set_next_employee_number (×1, mutates) # list_employee_number_overrides (×1) # 2026-06-04 (migration 253 — fold privileged review into the access-cert # engine, GRC PR3): removed assign_privileged_review_holder (no assign # endpoint in the unified engine) and renamed list_my_pending_privileged_ # reviews → list_my_pending_access_reviews (repointed to # AccessCertificationItem). Net 245 → 244. # 2026-06-05 (mobile W0 PR1): +mobile_action_inbox (unified cross-domain # action inbox). Net 246 → 247. # 2026-06-05 (compliance MCP-sync fix): added resolve_compliance_incident # (×1, mutates) — the 1:1 MCP twin of POST /compliance-incidents/{id}/resolve, # which had read tools (list/get) but no close action. Net 247 → 248. # 2026-06-05 (access-cert MCP-sync): added the 3 cert write twins — # create_access_certification / decide_access_certification_item / # complete_access_certification (all mutates) — list was read-only before. # Net 248 → 251. # 2026-06-08 (api-key reports MCP-sync): added api_key_analytics (×1, read) # — the 1:1 MCP twin of GET /apikeys/analytics powering the admin Reports # tab (distribution + budget pool + actual MTD spend + lifecycle). Net # 251 → 252. # 2026-06-08 (identity-sync preview MCP-sync, #789): added # dry_run_identity_sync_target (×1, read) — the 1:1 MCP twin of the # HRIS→Feishu "同步前只读预览" dry-run endpoint. Net 252 → 253. # 2026-06-09 (reconciliation drift MCP-sync, #795): added cost_rollup_drift # (×1, read) — AI-spend rollup↔live drift recon. Net 253 → 254. # 2026-06-11 (identity-consumers PR1): +list_identity_consumers + # get_identity_consumer (×2, read) — governance twins of the new # GET /identity/consumers[/...] admin surface (outbound pull credentials). # Net 271 → 273. # 2026-06-12 (onboarding-parity PR1–5 + review fixes). 9 new tools, 273 → 282: # list_onboarding_ack_items / upsert_onboarding_ack_item / # delete_onboarding_ack_item / acknowledge_onboarding_item (入职须知 ×4) # list_hardware_prep_queue / list_hardware_prep_candidates / # reserve_asset_for_hire / release_asset_reservation (备机闭环 ×4) # onboarding_day1_readiness_analytics (Day-1 就绪率 ×1) # 2026-06-13 (recon-fix PR4). 3 new tools, 282 → 285: # transfer_apikey_assignment_owner / re_enable_apikey_cost_rules / # refresh_ai_spending_rollup (对账中心修复闭环 ×3) # 2026-06-13 (硬件资产财务全生命周期 PR1). 3 new tools, 285 → 288: # hardware_financial_stats / get_asset_financial_profile / # list_depreciation_policies (角色对称·财务补硬件 ×3, read-only) # 2026-06-13 (经理团队生命周期 PR2). 1 new tool, 288 → 289: # list_team_lifecycle (角色对称·经理看下属入离职 ×1, read-only) # 2026-06-13 (自助申请生命周期对称 PR3). 3 new tools, 289 → 292: # cancel_permission_request / handover_ai_tool_application / # revoke_ai_tool_application (角色对称·申请动词矩阵拉平 ×3, mutating) # 2026-06-13 (云同步可观测 PR4). 1 new tool, 292 → 293: # get_cloud_sync_status (角色对称·per-capability 同步诊断 ×1, read-only) # 2026-06-15 (我的申请催办). 1 new tool, 293 → 294: # urge_request (催办自己仍在途的申请,通知审批人,限流 1 次/24h ×1, mutating) # 2026-06-15 (我的已办). 1 new tool, 294 → 295: # list_my_todos_done (我处理过且终结的待办历史,5 域聚合 ×1, read-only) # 2026-06-15 (移除 onboarding buddy 概念). REMOVED 2 tools, 295 → 293: # set_onboarding_default_buddy (×1) — buddy 字段及其全部逻辑下线 # set_my_team_joiner_buddy (×1) — 同上(经理面) # 2026-06-15 (身份消费方自助申请 PR2). 1 new tool, 293 → 294: # request_identity_consumer (自助申请身份消费方→飞书审批→批准后自动开通 ×1, mutating) # 2026-06-15 (我的已办独立页 + 报表). 1 new tool, 294 → 295: # my_todos_done_stats (已办聚合报表:类型/状态分布 + 12 月活动趋势 ×1, read-only) # +6 finance reconciliation-centre read tools (2026-06-16): finance_spend_summary, # finance_spend_pivot, finance_spend_movers, finance_reconcile_internal, # finance_spend_completeness, finance_spend_digest. # +3 finance governance tools (2026-06-16, PR3): finance_savings_opportunities, # finance_contract_renewals, finance_contract_vs_actual. # +2 finance external-reconcile tools (2026-06-17, Phase 2 PR-B): finance_external_reconcile, finance_external_statements. # +2 finance cost-centre/governance tools (2026-06-17, Phase 2 PR-C): finance_cost_center_coverage, finance_expense_hygiene. def _serialize_support_ticket(t, *, messages=None) -> dict: out = { "id": str(t.id), "ticket_number": t.ticket_number, "title": t.title, "body": t.body, "category": t.category, "priority": t.priority, "status": t.status, "assignee_id": str(t.assignee_id) if t.assignee_id else None, "resolution": t.resolution, "created_at": t.created_at.isoformat() if t.created_at else None, "resolved_at": t.resolved_at.isoformat() if t.resolved_at else None, # 3-point CSAT (1=不满意/2=一般/3=满意); null until rated — kept in # step with the HTTP serializer so AI clients can see/prompt rating. "satisfaction_score": t.satisfaction_score, "satisfaction_at": ( t.satisfaction_at.isoformat() if t.satisfaction_at else None ), } if messages is not None: out["messages"] = [ { "author_id": str(m.author_id) if m.author_id else None, "kind": m.kind, "body": m.body, "created_at": m.created_at.isoformat() if m.created_at else None, } for m in messages ] return out @mcp_server.tool( name="create_support_ticket", description=( "Open a free-form IT help ticket (求助域) for the caller. Use when the " "request doesn't fit a structured template (laptop/seat/DNS/…) — e.g. " "'my laptop won't charge', 'I can't access X'. `category` ∈ " "{hardware, account_access, software, network, facility, other}; " "`priority` ∈ {low, normal, high, urgent}. IT is notified; track the " "ticket with list_my_support_tickets." ), ) async def create_support_ticket( title: str, body: str, category: str = "other", priority: str = "normal", ) -> dict: async with tool_runtime( "create_support_ticket", {"title": title, "category": category, "priority": priority}, mutates=True, ) as rt: from app.domains.support.service import SupportService from app.identity.person_models import IdentityPerson person = await rt.db.get(IdentityPerson, rt.user.id) svc = SupportService(rt.db) ticket = await svc.create( requester=person, title=title, body=body, category=category, priority=priority, source="ai", ) payload = _serialize_support_ticket(ticket) ticket_id = ticket.id # After the runtime commits: fire-and-forget AI auto-triage on its own # session — parity with the HTTP create path (MCP-created tickets were # previously never triaged). No-op when the copilot is disabled. from app.core.background import spawn from app.domains.support.copilot import run_ticket_triage spawn(run_ticket_triage(ticket_id), name="support.ticket_triage") return payload @mcp_server.tool( name="list_my_support_tickets", description=( "List the caller's IT help tickets (求助域). `status` optional: 'open' " "for still-active tickets, or a specific status. Returns ticket number, " "title, status, category, priority, assignee." ), ) async def list_my_support_tickets( status: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=100) async with tool_runtime( "list_my_support_tickets", {"status": status, "page": page, "page_size": page_size}, ) as rt: from app.domains.support.service import SupportService svc = SupportService(rt.db) rows, total = await svc.list_for_requester( person_ids=[rt.user.id], status=status, page=page, page_size=page_size, ) return { "items": [_serialize_support_ticket(t) for t in rows], "total": total, "page": page, "page_size": page_size, } @mcp_server.tool( name="list_my_assistant_submissions", description=( "What the assistant has submitted on the caller's behalf, with the " "LIVE state of each one read from the domain that owns it — not from " "the assistant's own record of having called something. Use this to " "answer 'how is that request I made going', including a follow-up in " "the same conversation: the answer must come from here, never from " "what was said earlier in the chat. `phase` is one of submitted, " "pending_approval, provisioning, effective, rejected, failed, " "cancelled, ended (it worked and has since been revoked or expired), " "gone (the row no longer exists), unknown. `detail.approval.node` " "names the approval step it is waiting on when there is one; " "`detail.by_phase` breaks down a batch (a cloud renewal covers several " "grants). Self-scoped: only the caller's own submissions." ), ) async def list_my_assistant_submissions(limit: int = 20) -> dict: async with tool_runtime( "list_my_assistant_submissions", {"limit": limit}, ) as rt: from app.core.coordinators.assistant_outcome_coordinator import ( list_submissions_for_person, ) items = await list_submissions_for_person( rt.db, person=rt.user, limit=limit, ) return { "items": [i.as_dict() for i in items], "total": len(items), } @mcp_server.tool( name="get_support_ticket", description=( "Get one of the caller's IT help tickets with its full message " "timeline. Only the requester's own tickets are returned." ), ) async def get_support_ticket(ticket_id: str) -> dict: async with tool_runtime("get_support_ticket", {"ticket_id": ticket_id}) as rt: from app.core.exceptions import AppException from app.domains.support.service import SupportService svc = SupportService(rt.db) ticket = await svc.get(_u(ticket_id)) if ticket is None or ticket.requester_id != rt.user.id: raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) messages = await svc.list_messages(ticket.id) return _serialize_support_ticket(ticket, messages=messages) @mcp_server.tool( name="reply_to_support_ticket", description=( "Add a reply to one of the caller's own IT help tickets (as the " "requester). Reopens the IT-side waiting state so an agent is pinged." ), ) async def reply_to_support_ticket(ticket_id: str, body: str) -> dict: async with tool_runtime( "reply_to_support_ticket", {"ticket_id": ticket_id}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.support.service import SupportService from app.identity.person_models import IdentityPerson svc = SupportService(rt.db) ticket = await svc.get(_u(ticket_id)) if ticket is None or ticket.requester_id != rt.user.id: raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) person = await rt.db.get(IdentityPerson, rt.user.id) await svc.add_reply(ticket=ticket, author=person, body=body, is_agent=False) return {"status": ticket.status} @mcp_server.tool( name="cancel_support_ticket", description=( "Withdraw one of the caller's own still-active IT help tickets. Only " "the requester can cancel, and only before it's resolved/closed." ), ) async def cancel_support_ticket(ticket_id: str) -> dict: async with tool_runtime( "cancel_support_ticket", {"ticket_id": ticket_id}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.support.service import SupportService from app.identity.person_models import IdentityPerson svc = SupportService(rt.db) ticket = await svc.get(_u(ticket_id)) if ticket is None or ticket.requester_id != rt.user.id: raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) person = await rt.db.get(IdentityPerson, rt.user.id) await svc.cancel(ticket=ticket, requester=person) return {"status": ticket.status} @mcp_server.tool( name="open_ticket_chat", description=( "Open a Feishu group chat for one IT help ticket, with the requester " "and whoever is handling it in the room. Idempotent — a ticket has at " "most one room, so calling twice returns the same chat. Callable by the " "requester, the ticket's assignee, or an IT admin. MCP twin of " "POST /support/tickets/{id}/chat." ), ) async def open_ticket_chat(ticket_id: str) -> dict: async with tool_runtime( "open_ticket_chat", {"ticket_id": ticket_id}, mutates=True, ) as rt: from app.core.coordinators.ticket_chat_coordinator import ( open_ticket_chat as _open, ) from app.core.coordinators.ticket_chat_coordinator import refusal_for from app.core.exceptions import AppException from app.domains.support.service import ( SupportService, can_act_on_ticket, role_names_for_person, ) from app.identity.person_models import IdentityPerson ticket = await SupportService(rt.db).get(_u(ticket_id)) if ticket is None: raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) roles = await role_names_for_person(rt.db, rt.user.id) # 求助人、这张工单的处理人、IT 管理员 —— 与 HTTP 端点同一条判据。 # 「谁能为这张工单拉群」在两个面上给出不同答案,是这类改动最容易留下的洞。 if ticket.requester_id != rt.user.id and not can_act_on_ticket( ticket=ticket, person_id=rt.user.id, role_names=roles, ): raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) actor = await rt.db.get(IdentityPerson, rt.user.id) outcome = await _open( rt.db, ticket=ticket, actor=actor, reason="requester" if ticket.requester_id == rt.user.id else "agent", ) if not outcome.ok: # 与 HTTP 端点读同一张表(coordinator 的 ``CHAT_REFUSALS``)—— # 这里原来自己写了一份两分支的 if,于是同一次拒绝在两个面上是两句 # 不同的话,而新增一个 reason 只会在其中一个面上被认出来。 code, message, status = refusal_for(outcome.reason) raise AppException(code=code, message=message, status_code=status) return { "chat_id": outcome.chat_id, "url": outcome.applink, "created": outcome.created, } @mcp_server.tool( name="disband_ticket_chat", description=( "Disband the Feishu group opened for one IT help ticket. IRREVERSIBLE " "— the room's chat history goes with it, so the ticket must already be " "closed or cancelled and the discussion should already be recorded on " "the ticket. Callable by the requester, the ticket's assignee, or an " "IT admin. MCP twin of POST /support/tickets/{id}/chat/disband." ), ) async def disband_ticket_chat(ticket_id: str) -> dict: async with tool_runtime( "disband_ticket_chat", {"ticket_id": ticket_id}, mutates=True, ) as rt: from app.assistant import ticket_chat as ticket_chat_mod from app.core.exceptions import AppException from app.core.locale import resolve_lang from app.domains.support.service import ( SupportService, can_act_on_ticket, role_names_for_person, ) from app.identity.person_models import IdentityPerson ticket = await SupportService(rt.db).get(_u(ticket_id)) if ticket is None: raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) roles = await role_names_for_person(rt.db, rt.user.id) if ticket.requester_id != rt.user.id and not can_act_on_ticket( ticket=ticket, person_id=rt.user.id, role_names=roles, ): raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) # 工单没结之前不解散:群里的讨论还有用,而记录会随房间一起消失。 # 这一条在授权之后判是有意的 —— 一个不相干的人不该从错误信息里读出 # 「这张工单是什么状态」。 if ticket.status not in ("closed", "cancelled"): raise AppException( code="INVALID_STATE", message="工单还没有结束,群里的讨论还有用", status_code=409, ) chat = await ticket_chat_mod.get_by_ticket(rt.db, ticket.id) if chat is None or chat.state != "open": return {"disbanded": False, "reason": "no_open_chat"} requester = await rt.db.get(IdentityPerson, ticket.requester_id) ok = await ticket_chat_mod.disband( rt.db, chat=chat, ticket=ticket, reason="manual", lang=resolve_lang(requester) if requester else "zh", ) if not ok: raise AppException( code="FEISHU_API_ERROR", message="暂时没能解散这个群,每日收尾任务会再试一次", status_code=502, ) return {"disbanded": True} @mcp_server.tool( name="rate_support_ticket", description=( "Rate one of the caller's own RESOLVED/CLOSED IT help tickets — " "3-point CSAT, one rating per ticket: 1=不满意 unsatisfied, " "2=一般 neutral, 3=满意 satisfied. Mirrors the ticket page's rating " "widget (MCP twin of POST /support/tickets/{id}/rate)." ), ) async def rate_support_ticket(ticket_id: str, score: int) -> dict: async with tool_runtime( "rate_support_ticket", {"ticket_id": ticket_id, "score": score}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.support.service import SupportService from app.identity.person_models import IdentityPerson svc = SupportService(rt.db) ticket = await svc.get(_u(ticket_id)) if ticket is None or ticket.requester_id != rt.user.id: raise AppException(code="NOT_FOUND", message="工单不存在", status_code=404) person = await rt.db.get(IdentityPerson, rt.user.id) await svc.rate_ticket( ticket=ticket, actor=person, score=int(score), via="mcp", ) return {"satisfaction_score": ticket.satisfaction_score} @mcp_server.tool( name="search_support_kb", description=( "Search the IT knowledge base (published help articles) by keyword. " "Returns title, category, and body. Use for self-service how-to / fix " "lookups before opening a ticket." ), ) async def search_support_kb(query: str, limit: int = 6) -> dict: async with tool_runtime( "search_support_kb", {"query": query, "limit": limit}, ) as rt: from app.domains.support.kb_service import SupportKbService arts = await SupportKbService(rt.db).search_published( (query or "")[:100], limit=max(1, min(limit, 20)), ) return { "items": [ { "title": a.title, "category": a.category, "body": a.body, # Source-doc link for Feishu-wiki mirrored articles (cite # it in answers); None for native articles. "url": a.external_url, } for a in arts ], } @mcp_server.tool( name="get_self_check_overview", description=( "周期性自我检查 overview: for every enabled check category (堡垒机日志" "审计 / 防病毒 / 全盘加密 …) its cadence + current-period status " "(not_opened/pending/completed/skipped), plus headline counters " "(category count, current-period done/pending, total overdue). " "Read-only; restricted to auditor / global_admin." ), ) async def get_self_check_overview() -> dict: async with tool_runtime("get_self_check_overview", {}) as rt: _require_security_read(rt.user.role_names) from app.domains.auditor import self_check_service as scs return await scs.get_overview(rt.db) @mcp_server.tool( name="list_self_check_records", description=( "List 周期性自我检查 records (the auditable台账). Each row: category, " "period (year + Q/H/M label), status, whether checked, the check " "item, outcome, checker, evidence attachment count, and whether it " "is overdue. Filters: category_id, status (pending/completed/" "skipped), period_year, overdue_only=true to see only the backlog. " "Read-only; restricted to auditor / global_admin." ), ) async def list_self_check_records( category_id: str | None = None, status: str | None = None, period_year: int | None = None, overdue_only: bool = False, page: int = 1, page_size: int = 50, ) -> dict: import uuid as _uuid page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_self_check_records", { "category_id": category_id, "status": status, "period_year": period_year, "overdue_only": overdue_only, "page": page, "page_size": page_size, }, ) as rt: _require_security_read(rt.user.role_names) from app.domains.auditor import self_check_service as scs return await scs.list_records( rt.db, category_id=_uuid.UUID(category_id) if category_id else None, status=status, period_year=period_year, overdue_only=overdue_only, page=page, page_size=page_size, ) @mcp_server.tool( name="open_self_check_records", description=( "Idempotently open the current-period record for every enabled " "周期性自我检查 category (the monthly cron does this automatically; " "this lets an admin force it). Returns how many were opened vs " "already-present. Mutates; restricted to auditor / global_admin." ), ) async def open_self_check_records() -> dict: async with tool_runtime( "open_self_check_records", {}, mutates=True, ) as rt: _require_security_manage(rt.user.role_names) from app.domains.auditor import self_check_service as scs result = await scs.open_due_records(rt.db) await rt.db.commit() return result @mcp_server.tool( name="list_assessment_cycles", description=( "List 信息安全自评估 (semiannual ISMS report) cycles: period (e.g. " "2026-H1), title, status (draft/finalized), overall conclusion, and " "dates. Read-only; restricted to auditor / global_admin." ), ) async def list_assessment_cycles() -> dict: async with tool_runtime("list_assessment_cycles", {}) as rt: _require_security_read(rt.user.role_names) from app.domains.auditor import assessment_service as asv return {"items": await asv.list_cycles(rt.db)} @mcp_server.tool( name="get_assessment_cycle", description=( "Full 信息安全自评估 report cycle: every section (domain + subsection) " "with its narrative, 评估结论 (compliant/partial/non_compliant/" "not_assessed), auto-filled data table + summary, and any crosswalked " "GRC control. Read-only; restricted to auditor / global_admin." ), ) async def get_assessment_cycle(cycle_id: str) -> dict: import uuid as _uuid async with tool_runtime( "get_assessment_cycle", {"cycle_id": cycle_id}, ) as rt: _require_security_read(rt.user.role_names) from app.domains.auditor import assessment_service as asv return await asv.get_cycle_detail(rt.db, _uuid.UUID(cycle_id)) @mcp_server.tool( name="list_security_measures", description=( "List the 安全自评估 measures tracker — the evergreen 整改/措施 worklist " "(评估项 / 主要内容 / 具体举措 / 落地负责人 / 当前状态 待开展·进行中·" "已完成). Read-only; restricted to auditor / global_admin." ), ) async def list_security_measures(include_archived: bool = False) -> dict: async with tool_runtime( "list_security_measures", {"include_archived": include_archived}, ) as rt: _require_security_read(rt.user.role_names) from app.domains.auditor import assessment_service as asv return { "items": await asv.list_measures( rt.db, include_archived=include_archived, ) } @mcp_server.tool( name="create_assessment_cycle", description=( "Open a new 信息安全自评估 report cycle for a half-year (period_key like " "'2026-H1'; omit for the most-recently-ended half). Instantiates all " "ISMS sections and auto-fills their data tables from live Helix data. " "Mutates; restricted to auditor / global_admin." ), ) async def create_assessment_cycle(period_key: str | None = None) -> dict: async with tool_runtime( "create_assessment_cycle", {"period_key": period_key}, mutates=True, ) as rt: _require_security_manage(rt.user.role_names) from app.domains.auditor import assessment_service as asv cycle = await asv.create_cycle( rt.db, period_key=period_key, created_by_person_id=rt.user.id, ) await rt.db.commit() return {"id": str(cycle.id), "period_key": cycle.period_key} @mcp_server.tool( name="draft_assessment_narratives", description=( "AI-draft every section's 评估描述 + 评估结论 for a 信息安全自评估 cycle " "from its auto-filled data (via 百炼/Qwen), then roll up the overall " "conclusion. Returns how many sections were drafted. Requires the " "platform LLM key (503 if unconfigured). Mutates; restricted to " "auditor / global_admin." ), ) async def draft_assessment_narratives(cycle_id: str) -> dict: import uuid as _uuid async with tool_runtime( "draft_assessment_narratives", {"cycle_id": cycle_id}, mutates=True, ) as rt: _require_security_manage(rt.user.role_names) from app.domains.auditor import assessment_ai result = await assessment_ai.draft_cycle(rt.db, _uuid.UUID(cycle_id)) await rt.db.commit() return result @mcp_server.tool( name="get_security_governance_overview", description=( "One-call 安全治理中心 snapshot stitching the 等保/认证/审计 pillars: " "periodic self-check health (category count / current-period done / " "overdue), the latest 信息安全自评估 cycle (period + status + overall " "conclusion), open remediation-measure count, and the GRC control " "roll-up by status. Read-only; restricted to auditor / global_admin." ), ) async def get_security_governance_overview() -> dict: async with tool_runtime("get_security_governance_overview", {}) as rt: _require_security_read(rt.user.role_names) from sqlalchemy import func, select from app.domains.auditor import ( assessment_service as asv, ) from app.domains.auditor import ( self_check_service as scs, ) from app.domains.auditor.models import AuditorAssessmentMeasure self_check = await scs.get_overview(rt.db) cycles = await asv.list_cycles(rt.db) open_measures = int((await rt.db.execute( select(func.count()).select_from(AuditorAssessmentMeasure).where( AuditorAssessmentMeasure.archived.is_(False), AuditorAssessmentMeasure.status != "done", ) )).scalar() or 0) return { "self_check": self_check["summary"], "latest_assessment": cycles[0] if cycles else None, "open_measures": open_measures, } # +1 API-Key apply-approval surface (2026-06-17): list_my_pending_api_key_apply_approvals # (「API Key 申请审批」待我审批,飞书裁决,read-only). # +2 notification governance (2026-06-17): get_my_notification_preferences (read) + # set_my_notification_preferences (mutates) — per-category×channel muting + quiet hours. # +5 support tickets (求助域, 2026-06-17): create_support_ticket / list_my_support_tickets / # get_support_ticket / reply_to_support_ticket / cancel_support_ticket (employee self-service). # +1 support KB (2026-06-17): search_support_kb (published help-article search, read). # +1 finance (2026-06-18): finance_budget_status (budget vs actual, read). # +1 finance (2026-06-18): finance_acknowledge_finding (governance finding triage, mutate). # +1 finance (2026-06-18): finance_my_budgets (owner-scoped budget vs actual, read). # +1 cli-dist (2026-06-18): cli_get_download_manifest (helix CLI download manifest, read). # +1 me-universe (2026-06-21): get_my_it_footprint (personal IT recap — tenure/devices/ # seats/tickets/milestones, self-scoped, read). # +1 me-universe (2026-06-21): get_my_device_gallery (every device ever held + service # tenure + rarity tier, self-scoped, read). # +1 me-universe (2026-06-21): get_my_cohort (same-day-hire colleagues + cohort # anniversary, self-scoped, read). # +1 me-universe (2026-06-21): get_my_welcome (Day-1 welcome snapshot — basics/ # manager/devices/onboarding/starters, self-scoped, read). # +1 dashboard (2026-06-21): get_helix_cockpit (full leadership ops cockpit — # people/assets/cost/cloud/ops/compliance, admin-gated, read). # +3 security-quiz (2026-06-21): get_todays_security_quiz / answer_security_quiz # (mutate) / get_security_quiz_leaderboard. # -1 dashboard (2026-06-22): removed get_helix_pulse (employee 广场 pulse removed — # "Helix 此刻" is now leadership-only). # +1 feishu-helpdesk (2026-06-22): get_feishu_helpdesk_analytics (服务台分析 KPI/ # trend/breakdowns, global_admin+management, read). # +1 feishu-helpdesk (2026-06-22): get_feishu_helpdesk_ai_insights (AI 月度运营 # 洞察 via 百炼/Qwen, global_admin+management, read). # +1 me-universe (2026-06-22): get_my_loadout (RPG character-sheet — equipped gear/ # level/power/attributes/inventory/rank, self-scoped, read). # +1 security-quiz (2026-06-22): get_security_team_battle (部门擂台 department # standings + last-week snapshot, read). # +1 gamification (2026-06-22): get_my_badges (成就徽章墙 — earned/locked + # newly_unlocked, self-scoped, lazily persists unlocks → mutates). # +1 me-universe (2026-06-22): get_my_wrapped (年度报告 year-in-review for a # given year — tenure/security/devices/tickets/badges + highlights, read). # +3 周期性自我检查 (2026-06-22): get_self_check_overview / list_self_check_records # (read, auditor+global_admin) + open_self_check_records (mutate — force the # monthly period opener). # +4 信息安全自评估 (2026-06-23): list_assessment_cycles / get_assessment_cycle / # list_security_measures (read, auditor+global_admin) + create_assessment_cycle # (mutate — open a half-year ISMS report cycle). # +1 信息安全自评估 AI (2026-06-23): draft_assessment_narratives (mutate — AI-draft # all section narratives + conclusions via 百炼). # +1 安全治理中心 (2026-06-23): get_security_governance_overview (read — self-check # health + latest assessment + open measures one-call snapshot). # +1 借用延期 (2026-06-23): extend_asset_borrow (mutate — push a borrowed asset's # expected_return_date forward; borrower-self OR asset-admin; notifies borrower # + lead + (on self-service) admins. 1:1 with POST /assets/{id}/borrow-extension). # +1 假期余额与记录 (2026-06-23): get_my_leave_balances (read — caller's SF EC # Time-Off mirror: every leave-account bucket + balance/entitled/used + full # posting ledger. 1:1 with GET /hr/leave/me). # +1 BU 财年成本 (2026-06-25): finance_bu_costs (read — per-BU fiscal-year cost # across cloud/software/ai/hardware vs budget + forecast; hardware_basis # depreciation|cash. 1:1 with GET /finance/bu-costs). # +1 全域申请列表 (2026-06-25): admin_request_list (read — platform-wide ALL-STATUS # request list across all 10 approval sources, newest-first; cross-domain # full-history sibling of admin_approval_overview. 1:1 with GET # /approval/admin/all-requests). # +4 实习生考勤月报自助 (2026-06-25, PR-B): list_my_attendance_reports (read) + # confirm_attendance_report / return_attendance_report / undo_attendance_confirmation # (mutate) — intern/leader confirm/return/undo, 1:1 with /api/v1/attendance/me/*. # +1 jamf-offboard (2026-06-26): offboard_jamf_device (mutate — confirm-gated # unbind/lock/wipe on ONE Jamf device, global_admin; wipe refused for # held_by_leaver/buyback; no auto-run). # +1 jamf-reconcile (2026-06-27): backfill_jamf_device_owners (mutate — write # Jamf assigned-user from asset system by serial, dry_run default, global_admin). # +4 软件一次性增值项 (2026-06-27): list_software_addon_items (read) + # request_software_addon / cancel_my_software_addon_charge (mutate) + # list_my_software_addon_charges (read) — one-time software add-on (AI # credits / vendor person-days) self-service, 1:1 with /assets/addon-*. # +3 finance-flux read tools (2026-06-27): get_finance_flux_summary / # get_finance_flux_statement / list_finance_flux_periods — month-end flux # +1 finance-flux AI tool (2026-06-27): get_finance_flux_ai_summary — 百炼 # executive flux narrative for a statement (read-only, deterministic fallback) # (variance) analysis over NetSuite's consolidated report figures. # +2 NetSuite GL inbound read tools (2026-06-28): finance_netsuite_gl_summary # (book-of-record GL actuals per month, sliced by type/subsidiary/dept) / # finance_netsuite_sync_status (GL mirror freshness + master counts) — # 1:1 with /finance/netsuite/gl/summary + /finance/netsuite/sync-status. # +2 GL reconciliation read tools (2026-06-28, finance PR2): finance_gl_reconcile # (Helix bottom-up vs NetSuite GL per cost_type + unclassified-account # exceptions) / finance_close_cockpit (month-close go/no-go checklist) — # 1:1 with /finance/netsuite/reconcile + /finance/close-cockpit. # +1 flux GL tie-out tool (2026-06-28): finance_flux_gl_tieout — consolidated # statement lines from GL (consolidatedexchangerate) vs the .xls report, # per-line diff + tie-rate; 1:1 with /finance-flux/gl-tieout. # +2 expense-intelligence tools (2026-06-28, finance PR3): finance_my_expenses # (employee self-service: own 汇联易 reimbursements) / finance_expense_analytics # (by category/dept/status + top spenders) — 1:1 with /finance/me/expenses + # /finance/governance/expense-analytics. # +1 commitment tool (2026-06-28, finance PR4): finance_open_commitments — # open-PO forward exposure (the committed leg of budget-vs-actual); 1:1 with # /finance/governance/commitments. # +2 self-service cost tools (2026-06-28, finance PR5): finance_my_attributed_cost # (employee's own cost to the company + 12mo trend) / finance_my_bu_costs # (BU-owner self-scoped BU cost vs budget) — 1:1 with /finance/me/attributed-cost # + /finance/me/bu-costs. # +2 leadership/vendor tools (2026-06-28, finance PR6): finance_leadership_dashboard # (CFO company-wide snapshot) / finance_vendor_360 (one vendor's cross-channel # spend) — 1:1 with /finance/leadership-dashboard + /finance/vendor-360. # +2 (2026-06-28): get_my_access_review, submit_access_review # (half-yearly access self-attestation self-service) # +1 (2026-06-28): get_access_self_attestation_summary (auditor governance) # +3 (2026-06-29, finance book-close PR4): finance_close_status (close progress, # finance-view) / finance_my_close_tasks (owner self-service, read) / # finance_close_set_task_status (owner-or-operator, mutating) — 1:1 with the # /me/close-tasks self-service flow + the close console status write. # +1 (2026-06-29, finance book-close PR5): finance_close_signals — Helix-native # close decision-support signals (GL/recon/FX/cloud/汇联易) for a period. # +1 (2026-06-29, book-close review): finance_close_set_task_comment — 1:1 with # the /me/close-tasks comment self-service flow (MCP-sync rule). # +1 (2026-06-29, book-close AI): finance_close_ai_assist — real Bailian # assist/perform help for a close task (1:1 with /tasks/{id}/ai-assist). # +1 休假健康洞察 (2026-06-29): get_leave_health_insights (read — leave-utilisation # roll-ups by BU/department/lead with company baseline + health bands; low # utilisation = team-health risk lens; global_admin/hr/auditor/management. # 1:1 with GET /hr/leave/insights/overview + /groups). # +2 证书到期治理 (2026-06-29, cert-expiry-governance PR2): list_my_expiring_resources # (read — my certs+domains expiring within N days) + acknowledge_expiry_renewal # (mutating — snooze a cert/domain's expiry alerts; 1:1 with the # /cmdb/expiry/{type}/{id}/acknowledge self-service flow). # +1 团队云资源·API Key (2026-06-29): list_team_api_keys (read — AI API keys held # by a manager's direct/transitive reports + per-key MTD spend/budget + # over-budget flags + team summary; manager-scope, read-only. 1:1 with # GET /me/team/api-keys; sibling of list_team_lifecycle). # +1 假期手动同步 (2026-06-29): refresh_my_leave_balances (mutating — re-pull # the caller's own SF EC Time-Off on demand; 1:1 with POST /hr/leave/me/sync). # +1 代提回购 (2026-06-30): submit_buyback_on_behalf (mutating — asset-admin # starts the offboarding-buyback approval for a leaver-held asset on the # leaver's behalf; admin = Feishu applicant, stored requester = leaver; # 1:1 with POST /approval/asset/requests/on-behalf). # +1 同步监控看板 (2026-07-01, identity-sync Authing-parity PR1): get_identity_sync_metrics # (read — time-windowed sync analytics: KPI strip + daily trend + per-target # breakdown + trigger mix + upstream→downstream volume + recent failures; # 1:1 with GET /identity-sync/metrics). # +1 跨厂商外部账号登记 (2026-07-01): classify_external_account (mutate — # set one cross-vendor classification + note for an external email; known # classes drop it from every panel's 外部未匹配 count; global_admin; # 1:1 with POST /assets/external-accounts/classify). # +1 通知再次发送 (2026-07-01): resend_notification (mutate — clone an outbox # row into a fresh dispatch, works on delivered rows; global_admin; # 1:1 with POST /notifications/outbox/{id}/resend). # +6 安全问卷·供应商安全评估 (2026-07-02): vendor_assessment_overview, # list_vendor_assessments, get_vendor_assessment, # list_vendor_questionnaire_templates (read — global_admin/auditor/management), # create_vendor_assessment, complete_vendor_assessment (mutate — # global_admin/auditor; 1:1 with the /auditor/vendor-assessments surface). # +6 安全问卷·乙方侧 (2026-07-02): search_answer_library, list_inbound_questionnaires, # get_inbound_questionnaire, list_evidence_documents (read — # global_admin/auditor/management), create_inbound_questionnaire, # ai_draft_inbound_answers (mutate — global_admin/auditor; 1:1 with the # /auditor/{answer-library,evidence-documents,inbound-questionnaires} surface). # +1 DNS 写锁重跑 (2026-07-06): resume_blocked_dns_request (mutate — # cloud_admin/global_admin; re-drives an approved DNS request that failed # only because DNS writes were locked; 1:1 with # POST /cmdb/dns-record-requests/{id}/resume). # +1 入职账号动作时间线 (2026-07-06): get_onboarding_account_timeline (read — # global_admin/auditor/management; per-hire audit_logs trail of what Helix # did to the account — create/link/fail with Google error text, welcome # email outcome, process transitions; 1:1 with # GET /onboarding/processes/{id}/account-timeline). # +1 Claude Code 插件分发 (2026-07-09): plugin_get_download_manifest (read — # any employee; plugin-dist/latest manifest metadata; 1:1 with # GET /api/v1/plugin/manifest, download stays in the web UI / helix CLI). # +3 云审批决策 MCP 补齐 (2026-07-10, 移动端能力整改 PR3): approve_cloud_grant, # reject_cloud_grant (mutate — scope owner OR cloud_admin, mirrors # _require_scope_owner_or_admin + prod-write-lock; 1:1 with # POST /cmdb/grants/{id}/approve|reject — grant decisions are NOT # step-up-gated so a PAT may act), reject_cloud_scope_request (mutate — # cloud_admin; 1:1 with POST /cmdb/scope-requests/{id}/reject). NO # approve_cloud_scope_request tool by design: scope approval triggers AWS # CreateAccount + is require_browser_session-gated, which rejects PAT/MCP. # +3 邮件组自助加入 (2026-07-13): request_join_email_group (mutate — any # employee; 1:1 with POST /email-groups/{id}/join-requests), # list_pending_group_join_requests (read — owner / HR / IT-admin queue; 1:1 # with GET /email-groups/join-requests/pending), decide_group_join_request # (mutate — owner / HR / IT-admin; 1:1 with POST # /email-groups/join-requests/{id}/decide). Join requests use in-Helix owner # approval (owners are arbitrary employees, not fixed Feishu approvers). # +3 API-key AK/SK 轮换自助 (2026-07-16): rotate_api_key (mutate — owner/admin; # 1:1 with POST /apikeys/assignments/{id}/rotate), complete_api_key_rotation # (mutate — owner/admin; 1:1 with .../rotate/complete), # cancel_api_key_rotation (mutate — owner/admin; 1:1 with .../rotate/cancel). # +1 放松一下游戏厅 (2026-07-18): get_my_arcade_overview (read w/ lazy credit # reconcile — self-scoped wallet/plays/games; 1:1 with GET /arcade/overview). # Round play (start/submit) is real-time interactive web/H5 only by design — # documented in the tool description, not an MCP surface. # +1 放松一下排行榜 (2026-07-19): get_arcade_leaderboard (read — 科举 knowledge # board / per-game / per-category best-score board + caller rank; 1:1 with # GET /arcade/leaderboard). # +1 放松一下参与看板 (2026-07-19): get_arcade_participation (read, admin/ # management — adoption metrics/by-game/by-department/economy; 1:1 with GET # /arcade/admin/participation). # +2 放松一下经济参数 (2026-07-19): get_arcade_economy (read, admin) + # set_arcade_economy (mutate, admin) — tunable points/streak/cap/entry-cost; # 1:1 with GET/PUT /arcade/admin/economy. # +2 放松一下对局日志 (2026-07-19): get_my_arcade_history (read, self — my # finished rounds; 1:1 with GET /arcade/sessions) + list_arcade_sessions # (read, admin/management — cross-person round audit; 1:1 with GET # /arcade/admin/sessions). # +2 云资源效率洞察 (2026-07-20): get_cloud_efficiency_summary (read, # cloud-read scope; 1:1 with GET /cmdb/efficiency/summary) + # list_cloud_efficiency_instances (read, same scope; 1:1 with GET # /cmdb/efficiency/instances). # +1 云订阅内嵌优化建议 · 业主参与 (2026-07-20): # triage_cloud_efficiency_recommendation (write, mutates; owner/admin; # 1:1 with POST /cmdb/efficiency/instances/{server_id}/triage). # +1 成本优化成果 · 管理层 (2026-07-20): get_cloud_savings_outcomes (read, # cloud-read scope; 1:1 with GET /cmdb/efficiency/savings-outcomes). # +1 单人即时同步 HR (2026-07-22): hr_refresh_person_from_hris (mutate — # global_admin/HR; pull one person from SuccessFactors → apply to Helix → # push to Feishu + background fan-out; blocks genuine hire/leave # transitions; 1:1 with POST /hr/people/{person_id}/hris-refresh). # +5 动态邮件组管理 · HR (2026-07-22): list_dynamic_email_groups (read; 1:1 GET # /email-groups/dynamic), get_dynamic_email_group (read w/ field_catalog; 1:1 # GET /email-groups/dynamic/{id}), reconcile_dynamic_email_group (read diff; # 1:1 GET /email-groups/dynamic/{id}/reconcile), preview_dynamic_email_group_rule # (read; 1:1 POST /email-groups/dynamic[/{id}]/preview), # upsert_dynamic_email_group_rule (mutate; 1:1 PUT # /email-groups/dynamic/{id}/rule). All gated by # can_manage_dynamic_email_groups (hr + global_admin). # +2 离职云权限清扫 (2026-07-24, aws-empty-name-records): scan_leaver_cloud_assignments # (read — preview an about-to-leave person's still-held AWS Identity Center # account assignments = the delete plan the offboarding sweep runs) + # sweep_leaver_cloud_assignments (mutate — delete the non-prod untracked ones # before SCIM deprovision turns them into blank-name orphans). Manual twin of # the automatic run_tick / 立即推进 leaver sweep; both gated by can_manage_cloud. # +4 net 云权限续期改审批制 (2026-07-29, cn68 事故 + 续期治理): # REMOVED renew_my_cloud_grant (-1) — it was a second self-service renewal # entry point, so leaving it would have meant the owner-approval gate only # covered HTTP while an AI client could still bypass it entirely. # ADDED (+5): request_cloud_grant_renewal (mutate; 1:1 POST # /scopes/{id}/renewal-requests), decide_cloud_grant_renewal (mutate; 1:1 # POST /renewal-requests/{id}/decide), list_cloud_grant_renewal_requests # (read; 1:1 GET /scopes/{id}/renewal-requests, owner/cloud-admin gated), # list_my_cloud_grant_renewals (read; 1:1 GET /me/renewal-requests), # cancel_my_cloud_grant_renewal (mutate; 1:1 POST # /renewal-requests/{id}/cancel). Authority lives in the service so the # HTTP and MCP surfaces cannot drift on self-approval / second-signature. # +1 续期审批队列的跨订阅视图 (2026-07-29, 同 PR 收口审查第六轮): # list_my_pending_cloud_renewal_approvals (read; 1:1 GET # /my-pending-renewal-approvals). 补的是 MCP-sync 缺口 —— grant 审批早有 # list_my_pending_cloud_grant_approvals,续期审批只有单订阅版,AI 客户端 # 问「我还有哪些待审」时拿不到跨订阅答案。 # +1 云订阅发现路径 (2026-07-30, MCP 描述指向不存在的工具): # list_cloud_scopes (read; 1:1 GET /cmdb/scopes, both the membership view # and the ``requestable=true`` picker). Two tool descriptions # (submit_cloud_access_request / list_my_cloud_grants) pointed at a # never-registered ``list_my_cloud_subscriptions`` — an AI client got # tool-not-found and had NO way to discover a ``scope_id``, so the whole # cloud-access self-service flow was unreachable over MCP. MCP-sync gap: # the requestable picker HTTP route had no MCP twin. # +1 姓名 → person UUID 解析器 (2026-07-30, 承接上一条同日 PR 刻意留下的决策): # search_directory_people (read; 1:1 GET /auth/directory/people/lite)。 # 30+ 个工具收 ``*_person_id``,但没有任何已注册工具能把人名变成 UUID —— # handover_domain_registration / set_offboarding_default_handover_person # 的描述甚至指向过从未注册的 list_persons / search_persons。上一条 PR 把 # 目录搜索是否开、按哪个闸开留成独立决策,这里的答案是 **不按 HR 闸开**: # 受阻的一大半是员工自助(transfer_my_api_key / transfer_my_dns_records / # transfer_my_servers / handover_ai_tool_application,都是持有人可调), # require_hr_read 会把它们继续锁死。因此做成 /lite 那个**本就全员可调**的 # 自助 picker 的忠实孪生:q 必填 / 无分页 / limit≤30 / 仅 # active+leaver_pending+present / 显式补上 600 次每小时目录限流(HTTP 有, # 少了它 PAT 就是比浏览器更弱的枚举面);字段刻意窄于 /auth/users # (无 employee_no / roles / manager / last_login_at)。 # +1 跨月软件花费汇总 (2026-07-30): # software_cost_range_summary (read; 1:1 GET # /assets/software-billing/range-summary)。此前 MCP 面只能一个月一个月问 # (software_billing_preview_period),财务真正的问法「这几个软件这半年分别 # 花了多少」得由客户端循环 6 次再自己加,而跨币种相加正是这条数据线最忌讳的 # 事。工具直接返回 by_currency + live/excluded 月份口径标注。 # +1 供应商解析器 (2026-07-30,与上一条同主题的第 2 个 PR): # finance_spend_vendors (read; 1:1 GET /finance/spend/vendors)。同 PR 给 # finance_spend_pivot 加了 start/end 区间与 vendor 列表过滤 —— 而 vendor 是 # **精确等值**匹配,半个名字静默匹配不到任何行,所以必须同时给出「这段时间到底 # 有哪些 vendor」的解析器,否则区间过滤对 AI 客户端等于不可用(同 # search_directory_people 的教训:收 X 的工具必须有产出 X 的工具)。 # +1 调岗事件管理列表 (2026-08-02, issue #2274): # list_role_change_events (read; 1:1 GET /api/v1/role-changes)。HTTP 与 # MCP 共用固定快照 + 签名 keyset cursor 的 builder,避免并发 HRIS 写入时 # OFFSET 翻页重复/漏行;权限忠实镜像 require_admin_read。 # +1 动态邮件组成员列表 (2026-08-04, HR 导出成员需求): # list_dynamic_email_group_members (read; JSON behind GET /api/v1/ # email-groups/dynamic/{id}/members/export.xlsx)。返回动态组当前的 # Google 实际成员 + Helix 人员富化(姓名/工号/职位/地点/国家/在职状态), # 供 HR 导出 Excel 与 AI 客户端读取成员名单。 # +1 API Key 使用说明提示 (2026-08-04, 员工"怎么用这把 key"体验): # api_key_usage_hints (read; JSON behind GET /api/v1/apikeys/ # assignments/{id}/usage-hints)。返回 vendor_kind / 存量纳管 adopted / # contact_it,Azure OpenAI 还实时列出 IT 已部署的模型 deployment 名, # 供 reveal "如何使用" 面板与 AI 客户端指导员工正确调用(不再让员工自建模型)。 # +1 开通缺口对账 (2026-08-06, Zoom 坐席事故的可观测性补齐): # provisioning_gap_overview (read; JSON behind GET /admin/reconciliation/ # provisioning-gap)。四类信号:审批在 Helix 仍 pending 而厂商侧已终态 / # 坐席已 active 但开通任务从未完成 / 逾期未执行的厂商调用(尊重 # not_before 推迟闸)/ 已失败的厂商调用。谓词刻意收窄(厂商同步导入的 # 坐席、按最后工作日推迟的离职回收均不计入),故非零即可操作。 # +2 庆祝页回谢闭环 (2026-08-09, 用户反馈「广场收到了祝福以后也没有快速 # 回复的功能」): 祝福墙此前是纯展示,当事人对收到的祝福没有任何回应入口。 # feed_ack_blessing (逐条致谢; 1:1 POST /feed/celebrations/{id}/ # blessings/{bid}/ack,当事人限定 + 幂等重放不报 409) # feed_thank_blessings (一键回谢全部; 1:1 POST /feed/celebrations/{id}/ # thanks,全部标记已致谢 + 发一条 @ 所有送祝福者的动态,走既有 mention # 通知链路,每场庆祝只允许一次,服务端按已存在的回谢帖判定) # +1 成就徽章全面升级 (2026-08-09): 徽章 13 → 39 枚、单枚进度、稀有度、 # 全站解锁率;新增管理侧聚合读: # get_achievement_stats (1:1 GET /gamification/admin/stats; # global_admin / management,聚合口径,无点名排行) # +1 Data access service-account grant (2026-08-10): # submit_data_access_service_account_grant (1:1 with the DAX SA HTTP flow). # +1 Data access personal grant (2026-08-19): 483 -> 484 # submit_data_access_personal_grant (1:1 with the authenticated-user DAX flow). # +1 My data-access grants (2026-08-19): 484 -> 485 # list_my_data_access_grants (1:1 requester-scoped read model for the employee UI). # +1 Data-access table owner delegation (2026-08-19): 487 -> 488 # reassign_data_access_table_owner (1:1 with the DAX table-owner HTTP mutation). # +1 Data-access table lineage (2026-08-20): 488 -> 489 # get_data_access_table_lineage (1:1 with the DAX schema-browser read path). # +3 Multi-country public-holiday calendar (2026-08-12): the holiday table # HR maintains is now readable by AI clients. # get_public_holidays (1:1 GET /hr/holidays/public — caller's own country # by default, country="all" for every maintained one; makeup workdays # included as their own entries; no_data coverage never collapses to []), # get_my_next_holiday (1:1 GET /hr/holidays/public/upcoming — caller's own # local date, in-progress holidays reported not skipped), # generate_holiday_notice (1:1 POST /hr/holidays/notice; global_admin/hr # only, returns text for HR to copy — never sends anything). # +2 离职者云 IAM 权限残留治理 (2026-08-12): 455 → 457 # list_leaver_cloud_iam_residue (1:1 GET /cmdb/leaver-iam-residue; # can_read_cloud, 审计侧也要读这张表) # dismiss_leaver_cloud_iam_residue (1:1 POST /cmdb/leaver-iam-residue/ # {id}/dismiss; can_manage_cloud, 幂等) # 清理动作刻意不新增 MCP 工具 —— 不可撤销的删除只走管理端 HTTP # (POST /cmdb/iam-users/{id}/cleanup),全系统保留一条删除链路 / # 一个审计动作 / 一道 prod 写锁。 # +1 云 IAM 账号分类 (2026-08-12): 457 → 458 # classify_cloud_iam_user (1:1 POST /cmdb/iam-users/{id}/classify; # can_manage_cloud,纯 Helix 侧声明,不碰云端) # +2 离职数据交接自助 (2026-08-13): 458 → 460 # list_my_handovers (1:1 GET /offboarding/my-handovers; 任意登录员工, # 只返回自己名下且「还能改变结果」的交接) # reassign_my_handover (1:1 PUT /offboarding/my-handovers/{id}/reassign; # 授权 = 当前交接人本人或 global_admin —— 这条路由不是 admin-gated, # 所有权校验本身就是闸门) # +3 AI 大脑只读数据面 (2026-08-16): 460 → 463 # intelligence_get_overview / intelligence_get_domain / # intelligence_get_history (1:1 HTTP twins; release gate + identical RBAC; # reads deterministic snapshots only, model_calls=0) # +3 云订阅共享 AI 分析 (2026-08-16): 463 → 466 # cloud_scope_get_ai_analysis / cloud_scope_get_ai_analysis_history / # cloud_scope_generate_ai_analysis (1:1 HTTP twins; shared cache + exact # subscription authority; generate is an explicit cost-producing mutation) @mcp_server.tool( name="assistant_list_conversations", description=( "List Ask Helix assistant conversations for audit: who talked to the " "assistant, when, in what kind of room, and how many turns. Filter by " "person_id, outcome (answered / unavailable / failed), tool name, " "chat_kind (dm / handoff / group) or a time window. " "1:1 twin of GET /api/v1/assistant/admin/conversations; global_admin " "and auditor only." ), ) async def assistant_list_conversations( person_id: str | None = None, outcome: str | None = None, tool: str | None = None, chat_kind: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: async with tool_runtime( "assistant_list_conversations", { "person_id": person_id, "outcome": outcome, "tool": tool, "chat_kind": chat_kind, "page": page, "page_size": page_size, }, ) as rt: import uuid as _uuid from app.assistant import admin_service from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError("Assistant conversation audit is admin-only.") rows, total = await admin_service.list_conversations( rt.db, filters=admin_service.ConversationFilters( person_id=_uuid.UUID(person_id) if person_id else None, outcome=outcome, tool=tool, chat_kind=chat_kind, ), page=page, page_size=page_size, ) return {"conversations": rows, "total": total, "page": page} @mcp_server.tool( name="assistant_ticket_chat_overview", description=( "Operational overview of Helix's IT ticket group chats: how often " "handoff opened a room, why the rest degraded, per-category " "classification counts and confidence, how many rooms are open / idle " "/ awaiting cleanup, and — the one that matters for recoverability — " "how many open rooms still have no written record on their ticket " "(a room's chat history dies with it). Read-only; 1:1 twin of GET " "/api/v1/assistant/admin/room-console; global_admin and auditor only." ), ) async def assistant_ticket_chat_overview(window_days: int = 30) -> dict: async with tool_runtime( "assistant_ticket_chat_overview", {"window_days": window_days}, ) as rt: from app.assistant import room_console from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError("Ticket-chat operations data is admin-only.") window = max(1, min(int(window_days or 30), 365)) return { "overview": await room_console.overview(rt.db, window_days=window), "unassigned_routes": await room_console.unassigned_routes(rt.db), } @mcp_server.tool( name="assistant_get_conversation", description=( "Read one Ask Helix transcript in order, with each answer's tool calls, " "provider, latency and trace id. This is the evidence behind what the " "assistant told an employee. 1:1 twin of GET " "/api/v1/assistant/admin/conversations/{id}; global_admin and auditor only." ), ) async def assistant_get_conversation(conversation_id: str) -> dict: async with tool_runtime( "assistant_get_conversation", {"conversation_id": conversation_id}, ) as rt: import uuid as _uuid from app.assistant import admin_service from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError("Assistant conversation audit is admin-only.") found = await admin_service.get_conversation( rt.db, _uuid.UUID(conversation_id), ) if found is None: from app.core.exceptions import NotFoundError raise NotFoundError("No such conversation.") return found @mcp_server.tool( name="assistant_usage_stats", description=( "Ask Helix operating counters for a window: conversations, distinct " "people, outcome mix, average latency, top tools, confirmed proposals " "and REFUSED tool calls (attempts that reached past the read-only " "boundary). 1:1 twin of GET /api/v1/assistant/admin/stats." ), ) async def assistant_usage_stats(days: int = 30) -> dict: async with tool_runtime("assistant_usage_stats", {"days": days}) as rt: from app.assistant import admin_service from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError("Assistant conversation audit is admin-only.") return await admin_service.usage_stats(rt.db, days=days) @mcp_server.tool( name="assistant_unhelpful_answers", description=( "The knowledge-gap queue: answers employees marked 「没用」, each paired " "with the question they had asked and the tools that ran. Deflection " "counts tickets not filed, which cannot tell a satisfied employee from " "one who gave up — this is the list that can. 1:1 twin of " "GET /api/v1/assistant/admin/unhelpful." ), ) async def assistant_unhelpful_answers(days: int = 30, limit: int = 50) -> dict: async with tool_runtime( "assistant_unhelpful_answers", {"days": days, "limit": limit}, ) as rt: from app.assistant import admin_service from app.core.exceptions import ForbiddenError from app.core.rbac import AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, has_role if not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, AUDITOR_ROLE): raise ForbiddenError("Assistant conversation audit is admin-only.") return { "items": await admin_service.unhelpful_answers( rt.db, days=days, limit=limit, ), } # +6 存量漂移纠正 (2026-08-17): 466 → 472 # 合并到 main 时未 bump 的存量工具。实测 origin/main 的 # `grep -c "@mcp_server.tool"` = 472 而 hint 仍为 466, # `test_registered_count_hint_matches_actual` 因此在 main 上已是红的。 # 本 PR 改到同一个文件,顺手纠正而不是再叠一层谎。 # +3 Ask Helix 对话审计只读面 (2026-08-17): 472 → 475 # assistant_list_conversations / assistant_get_conversation / # assistant_usage_stats (1:1 HTTP twins; global_admin + auditor only — # deliberately NOT require_admin_read, transcripts carry employees' own # words so the leadership read tier is not admitted) # ── Evidence queries (auditor forensics) ───────────────────────────── @mcp_server.tool( name="auditor_list_evidence_queries", description=( "List the deterministic audit-evidence questions Helix can answer — " "access rosters over a period, point-in-time privileged-role holders, " "approval-chain lookups, leaver revocation proof, object lifecycle and " "access-review completion. Each entry carries its key, version, " "trilingual title and declared parameters. There is no free-form query " "path: a question is either in this catalogue or cannot be asked. " "Exact twin of GET /api/v1/auditor/evidence-queries." ), ) async def auditor_list_evidence_queries() -> dict: async with tool_runtime("auditor_list_evidence_queries", {}) as rt: from app.domains.auditor.evidence_query.service import list_catalog _require_security_read(rt.role_names) return {"items": list_catalog()} @mcp_server.tool( name="auditor_run_evidence_query", description=( "Run one catalogued evidence query and FREEZE the result into an " "immutable, hash-stamped bundle. Facts come from deterministic SQL " "over Helix's own ledgers; every returned row cites the record it came " "from (source table + primary key, or an audit-log id). Re-running the " "same question produces a NEW bundle — a differing content_hash means " "the underlying data moved between runs. Writes a bundle plus an " "auditor.evidence_query.run audit row, so this is a mutation. Exact " "twin of POST /api/v1/auditor/evidence-queries/{query_key}/run." ), ) async def auditor_run_evidence_query( query_key: str, params: dict | None = None, ) -> dict: async with tool_runtime( "auditor_run_evidence_query", {"query_key": query_key}, # Freezing a bundle and writing the audit row are real writes; # annotating this as read-only would let a client replay it as a # cache fill and quietly accumulate dated evidence artefacts. mutates=True, ) as rt: # Imported rather than re-implemented: two copies of a # redaction rule drift, and the drift is silent on the # permissive side. from app.domains.auditor.evidence_query.router import _redacted_payload from app.domains.auditor.evidence_query.service import ( list_bundle_rows, run_evidence_query, ) _require_security_manage(rt.role_names) bundle = await run_evidence_query( rt.db, query_key=query_key, params=params or {}, actor_user_id=rt.person_id, ) rows, total = await list_bundle_rows(rt.db, bundle.id, page_size=200) return { "bundle_id": str(bundle.id), "query_key": bundle.query_key, "query_version": bundle.query_version, "params": bundle.params, "row_count": bundle.row_count, "content_hash": bundle.content_hash, "data_gaps": bundle.data_gaps or [], "rows_returned": len(rows), "rows_total": total, "rows": [ { "seq": r.seq, "subject_type": r.subject_type, "subject_id": r.subject_id, "subject_label": r.subject_label, "source_table": r.source_table, "source_pk": r.source_pk, "audit_log_id": ( str(r.audit_log_id) if r.audit_log_id else None ), "occurred_at": ( r.occurred_at.isoformat() if r.occurred_at else None ), # Same cost-visibility gate the HTTP twin applies. # ``object_lifecycle`` and ``approval_chain`` embed # raw ``audit_logs.detail`` in the payload, which # can carry invoice-basis amounts — and an # ``auditor`` sits outside the invoice circle. The # HTTP path redacts those; returning them verbatim # here would make the MCP twin the way around a # platform red line. "payload": _redacted_payload(r.payload, rt.user), } for r in rows ], } # 2026-08-17: +1 rate_support_ticket (服务台收编 — MCP twin of the CSAT # rating flow, MCP-sync rule). # 2026-08-17: +2 auditor_list_evidence_queries / auditor_run_evidence_query # (审计证据查询 —— 1:1 HTTP twins; catalogue-only questions, no free-form # query path; run freezes an immutable bundle + writes an audit row, # hence mutates=True). 476 → 478. # 2026-08-17: +1 list_leave_expiry_reminder_candidates (年假到期提醒 — the # READ half of the new HR send flow. The send half is deliberately NOT # exposed over MCP: a mass employee-facing push is browser-session-gated # (``require_browser_session``) so a stolen PAT cannot fire it, and an MCP # twin would be exactly that hole.) 478 → 479. # +1 回答闭环 (2026-08-18): 479 → 480 # assistant_unhelpful_answers (1:1 HTTP twin; global_admin + auditor only, # same narrow gate as the other transcript reads — a bad answer is quoted # verbatim alongside the employee's question). # +3 SSO 治理闭环 (2026-08-18): 480 → 483 # list_sso_grants / revoke_sso_grant — the admin read + revoke surface the # SSO domain never had on ANY client (the HTTP endpoints existed since # migration 101 but nothing called them, so "who holds access to X" and # "take this access away" were DB-only operations). # set_sso_app_idp_config — 1:1 twin of the new POST # /sso/apps/{id}/idp-config: records what Google generated and hands it # back to the requester, closing the return leg of the grant flow. # All three are IT-admin gated: they read or decide on OTHER people's # access, which is authz-wise nothing like the ``*_my_*`` self-service # twins next door. # +2 目录/组织读面 (2026-08-19): 485 → 487 # list_directory_people / list_departments — 1:1 twins of GET # /directory/people and GET /auth/departments/tree, both admin-tier gated # (``can_view_directory``). The whole org-structure read surface was HTTP-only: # MCP could resolve ONE colleague by name (search_directory_people) but could # not answer 「这个部门有多少人」「有哪些部门」 at all, so the assistant answered # department questions from the model's own memory or not at all. # +1 unified approval detail (2026-08-20): admin_request_detail, 488 → 489. # +1 DAX table lineage (2026-08-20): 489 → 490 # get_data_access_table_lineage — exact HTTP twin for the versioned, # read-only upstream relation graph; no BigQuery query or provider write. # +17 unified cloud resource directory (2026-08-24): 490 → 507. Inventory, # governance, findings, rules, rollups and recipient-owned reporting use the # same endpoint bodies and permission checks as HTTP. CSV remains an HTTP # download rather than embedding a 50k-row file in JSON-RPC. # +5 漏洞赏金 · 业务确认自助面 (2026-08-24): 507 → 512 # list_my_bounty_confirmations / get_my_bounty_confirmation / # confirm_bounty_report / update_bounty_remediation / # comment_on_bounty_report — exact twins of ``/api/v1/me/bounty/*``, the # surface a named business owner uses to say whether an externally # reported vulnerability is real and when it will be fixed. Self-scope: # ``self_service.load_my_report`` 403s a report assigned to anybody else, # and the payload omits the researcher's identity and the bounty amount # (security-team-only). The admin console surface is deliberately NOT # mirrored: it decides other people's rewards and reads external # submitters' contact details. # +2 asset location administrators (2026-08-24): 512 → 514 # list_asset_location_admins / get_asset_location_coverage — read twins of # GET /assets/locations?with_admins=true and GET /assets/locations/coverage, # both gated on asset_admin like their HTTP counterparts. Migration 535 made # a location's administrator list the asset_admin grant set, so "who covers # this region" and "where is nobody covering" became answerable questions # that MCP could not answer at all. The WRITE side stays HTTP-only on # purpose: PUT /assets/locations/{id}/admins grants a role, and a role grant # is not something a stolen PAT should be able to reach. # +4 内部系统台账 (2026-08-25): 514 → 518 # list_my_systems — self-scope twin of GET /api/v1/me/systems: which # systems am I on the hook for (the question a leaver needs answered # before their last day). No role required; scope IS the authorisation. # list_registered_systems / get_registered_system / find_system_owner — # read twins of the admin surface, gated on ``can_view_system_registry`` # exactly like HTTP. These make 「谁负责这个系统」「哪些系统没有负责人」 # answerable at all; before the registry the answer lived in people's # heads. The WRITE side stays HTTP-only on purpose: naming a system's # owner decides who an offboarding blocker will chase, which is not # something a stolen PAT should be able to reach (same reasoning as the # asset-location administrator grant above). # +1 Data-access dataset owner self-transfer (2026-08-25): 518 -> 519 # transfer_my_data_access_dataset_owner (1:1 with the DAX dataset-owner PUT; # current-owner-only, active recipient, inherited table owners follow atomically). # +3 审批流程台账 (2026-08-26): 519 → 522 # find_approval_flow_owner — the all-staff read: which approval flow to # use and who owns it, from the published list, no role required. This # is the question 「报销找哪个流程、这个审批谁负责」 that previously had # no answer anywhere outside the Feishu approval admin console. # list_approval_flows — the governance view (ownerless / unconfirmed / # departed-owner), gated on ``can_view_approval_registry`` exactly like # HTTP. list_my_approval_flows — self-scope twin of GET /api/v1/me/ # approval-flows. The WRITE side stays HTTP-only: naming a flow's owner # decides who the half-year confirmation chases, same reasoning as the # system registry above. # +3 审批流程台账 · 半年度确认 (2026-08-26): 522 → 525 # get_my_approval_flow_confirmation — 「我这轮还有几条没确认」, self-scope. # confirm_my_approval_flow / transfer_my_approval_flow — the two answers # the half-yearly card asks for, as self-service writes. They are the # caller's own accountability record, which is why they are writable # here at all: naming somebody ELSE's owner stays HTTP + global_admin. # Transfer requires a successor by design — 「不是我」 without saying who # leaves the register no better off, and the platform will not guess. # +1 新建流程申请 (2026-08-26): 525 → 526 # request_new_approval_flow — the employee's front door for a flow the # company does not have yet. A self-service write about their own ask; # deciding it and building it stay HTTP + global_admin. # +2 MDM 设备台账 (2026-08-26): 526 → 528 # list_mdm_devices — the Jamf-enrolled fleet with its asset links and # resolved holders, and list_mdm_cleanup_queue — the devices that left # company ownership but are still in the tenant. Both gated on the same # four roles as require_asset_read on HTTP (spelled out rather than # reusing can_read_all_assets, which also admits finance — a gate that # claims parity has to have it). The one write (confirming a device was # removed) stays HTTP-only: it closes a todo on the strength of an # attestation, which should cost a login rather than a token, and the # vendor sync confirms it independently anyway. # +3 安全 FAQ · 员工可读的答案库切片 (2026-08-27): 528 → 531 # search_security_faq / get_security_faq_entry / # list_security_faq_categories — read twins of ``/api/v1/me/security-faq``, # all-staff like the HTTP surface (scope IS the authorisation) and # narrowed to the same three fields. The point of mirroring them is that # an AI client asked 「公司允许用 ChatGPT 处理代码吗」 should answer from # the security team's published position rather than from priors. The # ask endpoint is deliberately NOT mirrored: its whole job is to hand a # zero-hit query to a model, and an MCP caller already IS one — it can # rank ``search_security_faq`` output itself, for free. The engagement # writes (view / helpful) are UI telemetry, not agent actions, and the # gap ledger is the security team's console, gated on can_view_security. # +1 流程用途说明 (2026-08-27): 531 → 532 # describe_my_approval_flow — the owner's own words for what their flow # is for, the same self-scope write as PATCH /api/v1/me/approval-flows/ # {id}/description. Writable from here for the same reason confirm is: # it is the caller's own row. All 49 flows shipped with this empty, so # the directory named a person to ask and never said what for. # +1 安全 FAQ 的提问口 (2026-08-28): 532 → 533 # ask_security_faq — the twin of ``POST /api/v1/me/security-faq/ask``, # which the +3 note above deliberately did NOT mirror. That call was # right for the ask path as it then was (hand a zero-hit query to a # model to pick ids — an MCP caller already IS a model and could rank # ``search_security_faq`` output itself). The ask path is no longer # that: it now runs LEXICAL + SEMANTIC (embedding) retrieval fused by # RRF, and the semantic channel is exactly the thing an MCP caller # cannot reproduce from the keyword tool's output — it has no access to # the corpus vectors. So the reason it was skipped stopped being true, # and the FAQ's own failure mode (questionnaire-voice entries that # share no words with the question an employee actually types) is # precisely what an AI client hits hardest. Read-only and gap-free on # purpose; see the tool body for both. # +3 GitHub 组织 (2026-08-29): 533 → 536 # list_github_orgs / list_my_github_orgs / lookup_github_org_member — # the MCP twins of the GitHub organization registry + membership ledger. # ``list_my_github_orgs`` is the self-scoped one and deliberately reports # ``github_handle_on_file`` separately: the handle is sparse (collected at # onboarding, never backfilled), so "we have no handle for you" and "you # are in no organization" are different answers and must not collapse. # ``lookup_github_org_member`` exists for the reverse direction a GitHub # member list actually hands you — a string of handles — which no # email-keyed tool can answer. # +3 转岗复核包裹 (2026-08-29, migration 568): 536 → 539 # list_my_role_change_reviews / get_role_change_review / # decide_role_change_review_category — twins of the three # /api/v1/role-changes/reviews endpoints a REVIEWER uses. Worth # mirroring because the reviewer is a MANAGER, not IT: the packet's # whole premise is that only the person who just picked this colleague # up can judge whether they still need a given entitlement, and a # manager working through an AI client is exactly the caller the # category-level bulk decision was shaped for ("keep all the DNS # records, revoke the prod cloud roles"). # # The bulk-decide tool is ``mutates=True`` and gated per-packet on # ``reviewer_person_id`` (or global_admin) — never on a role — which # mirrors the HTTP gate exactly. It records a decision and performs # NO revoke or transfer (issue #623). The remaining fourth endpoint, # the per-ITEM decision, is deliberately not mirrored: 238 individual # tool calls is not a surface anybody wants an AI client to use, and # the category tool plus ``get_role_change_review(resource_type=…)`` # already covers reading one category and answering it. # # NOT proposable by the Feishu assistant: ``OPT_OUT`` excludes # "deciding on somebody else's behalf" (``decide_*``), and a mover # review is a decision about a colleague's access. The MCP surface is # for a manager driving an AI client with their own credentials; the # assistant's one-line-trigger surface is not the place for it. # +2 GitHub 组织自助 (2026-08-29): 539 → 541 # request_github_org_access / list_github_org_options —— 自助申请入口的 # 1:1 MCP 孪生(MCP-sync 规则)。``list_github_org_options`` 与 HTTP 侧 # 一样把「有哪些组织」和「我在不在里面」放进同一次调用:两次调用之间的 # 时间差正好够一个 agent 建议用户去申请一个他已经在里面的组织。 # ``github_id_on_file`` 单独报,理由同 list_my_github_orgs。 # +1 GitHub 组织治理缺口 (2026-08-29): 541 → 542 # list_github_org_drift —— 把「哪些成员资格 Helix 认不出是谁」这个数 # 连同它的上下文(上次对账时间、对账错误、凭证配没配、是否豁免离职 # 回收)一次给出。这个数是这套东西的治理缺口本身:那些成员资格在 # GitHub 上存在,而离职自动回收够不着它们。分成多次工具调用去拼, # 等于让模型每次都重新发现「原来还有这么一批人」。 # +1 助手提交的实时状态 (2026-08-30): 543 → 544 # list_my_assistant_submissions —— 「我刚才那个申请怎么样了」的唯一 # 合法来源。助手过去只能复述自己说过的话(`executed` 只证明调用返回 # 了),会话里追问一次就会把「已提交」重复成「已完成」。这个工具从 # 领域实时读,并在读时重新执行对象级 RBAC —— 持有提案不等于有权读 # 那一行,资产易主之后旧链接必须读不到。 # 2026-08-30: get_my_it_inventory # +2 离职账号终态 (2026-08-30): 544 → 546 # list_leaver_account_assurance / close_leaver_accounts —— 「离职的人 # 还能不能登录」和「现在就把它关掉」。和已有的离职工具是两个不同的 # 问题:那些回答「Helix 撤了什么」,这两个回答「厂商此刻怎么说」。 # 2026-08-13 之后 16 天里,前者一直是干净的,后者一直是活着的。 # +5 GitHub ID 自助修改 (2026-08-30): 546 → 551 # get_my_github_id / change_my_github_id / cancel_my_github_id_change / # list_github_id_changes_to_confirm / decide_github_id_change —— 与 # ``/api/v1/me/github-id`` 的 5 个端点 1:1(MCP-sync 规则)。补的是 # ``identity_persons.github_id`` 此前**没有任何更新入口**这个洞:它只在 # 入职表单采集一次,而 github_org 域的自动加入、对账、离职回收三处都靠 # 它认人。审批人是数据(申请人的 HR Ops / HRBP),不是角色。 # +5 GitHub 组织治理待办 (2026-08-31): 551 → 556 # list_github_action_items / remove_github_org_member / # suggest_github_member_links / link_github_member_to_person / # confirm_github_member_external —— 与新的 `/api/v1/github/action-items` # 等端点 1:1(MCP-sync 规则)。补的是「说得出问题但处理不了问题」这个洞: # 上线第一天 leavers_still_in_orgs 就是 1、pingcap-inc 只剩 3 个坐席, # 而它们只活在一张 KPI 卡上。 # `confirm_github_member_external` 是让「认不出是谁」那个数**能归零**的 # 那一个:实测 217 个认不出的 handle 里 0 个能靠字面对上,其中相当一部分 # 本来就不是员工。 # +1 GitHub 组织总览 (2026-09-01): 556 → 557 # get_github_org_report —— 与 `/api/v1/github/report` 1:1。与治理待办 # 相反的取数方向:待办看缺口,报表看构成与趋势。描述里刻意点名 # 「先看 by_source 而不是成员总数」——成员总数几乎不动,而模型若照着它 # 回答「我们管了 1016 个成员」,就把「其中 1016 个都是我们上线时发现的 # 存量」这个真相盖掉了。 # +1 机器账号候选 (2026-09-01): 557 → 558 # list_github_bot_candidates —— 与 `GET /api/v1/github/bot-policy` 1:1。 # 描述里刻意写明「GitHub 自己的 type 字段在这里用不了」:七个 bot 账号 # 实测全报 type=User,所以只能靠命名约定,而命名约定会误伤真人 # (robot-liu 命中 *robot*)。模型必须把它们当候选而不是结论。 # +2 handle 标签体系 (2026-09-01): 558 → 560 # label_github_handle / list_github_labels —— 与 `/github/handle-labels` # `/github/labels` `/github/label-suggestions` 对应。描述里刻意强调两点: # ① 标签挂 handle 不挂成员资格(生产上一个 handle 常在 5 个组织里); # ② 建议会误伤真人(robot-liu 命中 *robot*),模型不得自行批量落库。 # +3 合作方通讯录 (2026-09-01): 560 → 563 # lookup_partner_employee / list_partner_directory_pending_links / # review_partner_directory_link —— 与 `/api/v1/partner-directory/*` 1:1 # (MCP-sync 规则)。补的是「这个账号到底是谁的」在 AI 侧无从回答这个洞: # 合作方员工在他们租户里是 @pingcap.cn,在我们这儿是 @pingcap.com,生产 # 里 google_orphan_accounts 的 @pingcap.cn 是 0 行 —— 模型按完整邮箱串 # 查一定查空,然后自信地答「不是合作方的人」。lookup 工具把跨域邮箱名 # 这条规则内置进去,并在返回里点名 match_method,让「仅姓名对上」没法 # 被当成结论。 # +2 (2026-09-01, PR「标签规则可启用」): # list_github_label_rules (×1) —— 规则 + 「现在会命中多少个 handle」 # apply_github_label_rules (×1) —— 把已启用的规则落一次 # 刻意**没有** enable_github_label_rule:把启发式变成自动写手是人的决定, # 在界面上那个命中数字旁边点,不该有第二个入口。 # +4 邮件组管理 (2026-09-03): 565 → 569 # list_email_groups_estate / get_email_group_estate_overview / # list_email_group_operation_log / list_email_group_operation_log_actions # —— 与 `/api/v1/email-groups/manage/*` 1:1(MCP-sync 规则)。之前 MCP 只 # 看得见 40 个动态组,720 个静态组和「这个组上发生过什么」在 AI 侧完全不 # 存在。描述里刻意写死三条容易被模型自信答错的事实:① Google 把 owner 算 # 进 directMembersCount,所以「0 成员」和「无 owner」是同一个事实、不是两 # 条独立发现;② `direct_members_count = null` 是「从没同步过」不是 0; # ③ kind='unknown' 不能当静态组说。日志工具还点名 actor_type='system' 的 # 行不能归因到某个人。 # +5 静态邮件组写面 (2026-09-03): 569 → 574 # add_email_group_members / remove_email_group_members / # grant_email_group_owner / remove_email_group_owner / # update_email_group_display —— 与 `/api/v1/email-groups/manage/*` 的写 # 端点 1:1(MCP-sync 规则)。生产 720 个静态组此前在 Helix 里只有「删除」 # 一个管理动作,加人/换负责人/改名全在 Google 控制台手工做 —— 也就是全部 # 不留痕。描述里写死三件模型一定会答错的事:① 按地址逐条隔离,部分成功 # 不许汇报成成功;② 移除 owner 走另一条带「不许留下无 owner 的组」闸的 # 路径,普通移除会明确拒绝 owner;③ grant_email_group_owner 是唯一能救 # 「无 owner 的组」的路径(transfer 需要有一个 owner 可转,救不了)。 # 刻意**没有** 建组 / 删组的新工具:建组 MCP 已有 request_email_group, # 删组已有 delete_email_group,再加一个入口只会让「哪个才是对的」变模糊。 # +4 助手评测台 (2026-09-03): 574 → 578 # list_assistant_eval_suites / get_assistant_eval_run / # list_assistant_eval_failures / run_assistant_eval —— 与 # `/api/v1/assistant/eval/*` 对应(MCP-sync 规则)。补的是「飞书助手 # 到底答得好不好」在 AI 侧只能靠轶事回答这个洞:此前唯一的信号是员工 # 按的那个 👎,而它只覆盖「糟到有人愿意点一下」的那一小撮。 # 描述里刻意强调三点:① 通过率是同族模型判同族模型,绝对值偏松、 # 看变化才可靠;② cases_flaky 往往比通过率更有用,而 repeats=1 根本 # 看不见它;③ run_assistant_eval 花的是和员工同一个模型后端的钱, # 不要为了「试试能不能跑」就发起一次。 # 刻意**没有** promote_eval_failures / edit_eval_case:把一条失败变成 # 长期门禁、或者改动门禁本身,是人在界面上对着原始回答做的决定。 # +2 空邮件组清理工作台 (2026-09-03): 578 → 580 # list_email_group_cleanup_candidates / cleanup_email_groups —— 与 # `/api/v1/email-groups/manage/cleanup-candidates` `/cleanup` 1:1。 # 两个描述里花最多字数说的都不是「怎么用」而是「不许怎么答」: # ① 「0 成员」不等于「已废弃」—— careers@ / jobs@ / 各种机器人告警收件 # 地址在生产上就是 0 成员,删掉就是丢邮件,而且 Google 没有 undelete; # ② `indeterminate` 是「这项没探到」不是「探过了很干净」,行的 risk 因此 # 留在 review; # ③ 响应里的 known_blind_spots(云 IAM group 绑定 / 邮件流量)必须在总结 # 里重复,不许把这份清单当成安全放行; # ④ 逐组 outcome 要逐条汇报,refused / failed 不许并进「整体成功」。 # 写工具还明说:不要拿自己拼的清单调它,要先读候选、把信号摆给人看、由人 # 选 id。 # +4 知识循环 (2026-09-03): 580 → 584 # list_knowledge_proposals / get_knowledge_proposal / read_kb_index / # run_knowledge_lint —— 与 `/api/v1/knowledge/*` 对应(MCP-sync 规则)。 # 补的是「知识库现在缺什么、哪里不对」在 AI 侧完全无从回答这个洞:此前 # 只有 search_support_kb(回答一个问题),没有任何东西能看见语料的**形状**。 # 描述里刻意写死三件模型会答错的事:① kind 不可互换(add_aliases 是词表、 # merge 会下架一条、contradiction 刻意不选边);② context.target 是**当下** # 重读的,不是提案写下时的快照;③ read_only=true 的镜像条目根本落不了地。 # 刻意**没有** decide_knowledge_proposal:接受一条提案改变的是**每个员工被 # 告知的内容**,而做这个决定需要把目标条目摆在旁边逐字看 —— 那是一块屏幕, # 不是一次工具调用。同 enable_github_label_rule 的先例。 # +2 静态组别名写面 (2026-09-03, 复审补): 580 → 582 # add_email_group_alias / remove_email_group_alias —— 与 # `POST`/`DELETE /api/v1/email-groups/manage/{id}/aliases` 1:1。 # 复审抓到的 MCP-sync 缺口:组别名的「删除」本身是这次新增的平台能力, # 而 MCP 侧一个入口都没有(既有的 create_email_alias 走的是 owner 门的 # 自助路径,且没有删除对应物)。描述里写死两件事:① 移除别名等于让那个 # 地址停止收信;② `already_absent` 是「什么都没删」不是「删掉了」—— # Google 的 non-editable 别名删不掉但仍然在投递,所以本地镜像刻意不动。 # 合并 main 时两边各自 +N,所以这里是 580 + 4(知识循环)+ 2(组别名)。 # +2 安全宣贯素材库 (2026-09-03): 586 → 588 # list_security_awareness_materials / read_security_awareness_material —— # 与 `GET /api/v1/security-awareness/materials`(+ `/{id}`)1:1。两个都 # **不写曝光台账**:HTTP 详情路由记曝光是因为有个人真的打开了页面,而 # agent 代读一遍没有向任何人展示任何东西,记进去等于把这个域存在的理由 # (可信的覆盖率)自己稀释掉。描述里写死了这一点,免得模型据此告诉员工 # 「你已经学过了」。 # 刻意**没有** create/update 素材的写工具:素材是漫画和长文,编辑它需要 # 看见排版和图片,那是一块屏幕不是一次工具调用(同 decide_knowledge_proposal # 的先例)。 # +2 安全宣贯活动 · 员工自己那一侧 (2026-09-03): 588 → 590 # list_my_security_awareness / acknowledge_security_awareness —— 与 # `GET /api/v1/security-awareness/my/campaigns` 和 # `POST .../{id}/acknowledge` 1:1。**确认**这个写面是给的:按下「我已阅读 # 并理解」是本人的一个决定,agent 按他明确的指示代按就是同一个决定。 # 不给的是任何能让 agent 自行判定「这个人已被触达」的东西 —— 曝光台账 # 对 MCP 保持只读(见上一段)。描述里写死了「不要拿它清单子」:那个时间 # 戳是别人后面要依赖的证据,替他按而他没读,等于把证据变成假的。 # 刻意**没有** 建活动 / 发送 / 催办的写工具:发送是一个 500 人的不可撤销 # 动作,需要受众预览摆在按钮旁边,那是一块屏幕。 # +1 安全宣贯覆盖率 (2026-09-03): 590 → 591 # get_security_awareness_coverage —— 与 `GET /admin/security-awareness/coverage` # 1:1,并把 GRC 控制项自己的判定一起回,这样 agent 回答「我们合规吗」读的是 # 合规页上那个状态,而不是自己发明一个阈值。描述里写死三件模型会算错的事: # ① 运营档不计入(它不要求任何人做任何事);② roll-up 按**人**加权不按活动 # 平均(3 人的活动不该和 480 人的活动等权);③ `targeted` 是当时真的发给了 # 谁,不是今天的在职人数,不要拿别的分母重算百分比。 # +0 安全宣贯素材小测 (2026-09-03): 591 保持不变 —— **刻意不给写工具**。 # `POST /security-awareness/materials/{id}/quiz` 是一个自助写面,按 MCP-sync # 规矩本该配一个 1:1 工具;这里明确不配,理由和上面两个只读素材工具同源但更 # 强:小测是「你理解了吗」的证据,而一个替员工答题的工具产出的是**假证据** # —— 通过还会顺带把必修活动标成已确认。员工那一侧要的两个能力(看还欠什么 / # 本人确认)已经由 list_my_security_awareness + acknowledge_security_awareness # 覆盖,后者的描述里已经写死「不要拿它清单子」。同 decide_knowledge_proposal # 与本域素材写工具的先例。 # 2026-09-05 (PR 访问对象闭环 #1 · 迁移 603): +sso_removal_summary, # +open_sso_grant_removal_tasks, +verify_sso_grant_removal, +list_idle_api_keys, # +keep_my_idle_api_key, +update_api_key_idle_alert_config → 600 (594 after #3438/#3439 + 6). # +2 财务固定资产台账对账 (2026-09-05): list_asset_cost_reconcile_runs / # get_asset_cost_reconcile_run — 600 → 602. **刻意不给 upload / apply / # skip / discard 四个写工具。** upload 本身无法镜像(MCP 传不了文件); # 而 apply 是「把一份 LLM 没有上传、也看不到原件的台账里的金额写到资产 # 行上」—— 那正是必须一个人看过预览再点头的动作,给它一个工具调用等于 # 把「两本账不一致要人来判」这条设计从流程里拿掉。两个只读工具让 AI # 客户端能读出并汇报三组分歧(尤其 unmatched:财务在折旧一台台账里 # 没有的设备),这是这个面最有价值、也最适合自动化的那一半。同 # answer_security_quiz / decide_knowledge_proposal 的先例。 # 2026-09-05 (PR 访问对象闭环 #2 · 迁移 606): DAX 授权生命周期 +admin_list_data_access_grants, # +get_data_access_grant_lifecycle_summary, +get_data_access_grant_lifecycle_settings, # +update_data_access_grant_lifecycle_settings, +extend_data_access_grant, # +revoke_data_access_grant, +renew_my_data_access_grant, +release_my_data_access_grant, # +list_my_data_access_owner_confirmations, +respond_data_access_owner_confirmation; # 权限目录站内审批 +list_my_permission_approvals, +approve_permission_request, # +reject_permission_request → 615 (602 after #3442 + 13). # +1 离职未归还设备对账 (2026-09-05): list_leaver_held_assets — 615 → 616. # 只读。回收动作本身已经有 recover_asset_from_leaver(既有工具),这里补的 # 是「哪些、多久、谁该追」这一半。 @mcp_server.tool( name="seat_usage_signal_coverage", description=( "Answer 'can Helix even tell whether these paid seats are being " "used?' per software vendor. Returns one entry per provider: " "either supported=true with a row count, the newest snapshot " "time, a per-state breakdown (active / never / unknown) and " "whether that snapshot has gone stale, or supported=false with " "the concrete reason that vendor cannot answer (e.g. Slack " "exposes no last-activity field outside Enterprise Grid; the " "GitHub App is not scoped for the audit log). Use this BEFORE " "quoting any idle-seat number, because 'no idle seats' and 'we " "cannot see this vendor' look identical in a seat report and " "mean opposite things. States are reported AFTER the freshness " "gate, so a snapshot nobody refreshed reads as unknown rather " "than as its stale claim. Read-only." ), ) async def seat_usage_signal_coverage() -> dict: async with tool_runtime("seat_usage_signal_coverage", {}) as rt: from datetime import UTC from datetime import datetime as _dt from sqlalchemy import func as _func from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_all_assets from app.domains.asset.models import SeatUsageSignal from app.integrations.usage_signals import ( NO_USAGE_SIGNAL, STALE_AFTER_HOURS, resolve_signal, usage_collectors, ) if not can_read_all_assets(rt.user.role_names): raise ForbiddenError("Not allowed") now = _dt.now(UTC) rows = (await rt.db.execute( select( SeatUsageSignal.provider, SeatUsageSignal.state, _func.count(SeatUsageSignal.id), _func.max(SeatUsageSignal.collected_at), ).group_by(SeatUsageSignal.provider, SeatUsageSignal.state) )).all() per: dict[str, dict] = {} for provider, state, count, newest in rows: e = per.setdefault(provider, { "provider": provider, "supported": True, "reason": None, "rows": 0, "by_state": {}, "collected_at": None, }) e["rows"] += int(count or 0) resolved = resolve_signal(state, newest, now=now) e["by_state"][resolved] = e["by_state"].get(resolved, 0) + int(count or 0) if newest and (e["collected_at"] is None or newest > e["collected_at"]): e["collected_at"] = newest for provider in usage_collectors(): per.setdefault(provider, { "provider": provider, "supported": True, "reason": None, "rows": 0, "by_state": {}, "collected_at": None, }) for provider, reason in NO_USAGE_SIGNAL.items(): per[provider] = { "provider": provider, "supported": False, "reason": reason, "rows": 0, "by_state": {}, "collected_at": None, } out = sorted( per.values(), key=lambda e: (not e["supported"], e["provider"]), ) for e in out: collected = e["collected_at"] if collected is not None: aware = collected if collected.tzinfo else collected.replace(tzinfo=UTC) e["stale"] = ( (now - aware).total_seconds() > STALE_AFTER_HOURS * 3600 ) e["collected_at"] = aware.isoformat() else: e["stale"] = False # Annotated rather than returned straight from ``serialize`` — # that helper is typed Any, and letting it flow out untouched is # what the mypy ratchet counts as a new no-any-return. payload: dict = serialize({ "providers": out, "stale_after_hours": STALE_AFTER_HOURS, }) return payload # 2026-09-05 (PR 访问对象闭环 #3 · 迁移 609): 616 保持不变 —— 本 PR **+0**,刻意不加工具。 # 这一轮加的是一条 cron、T+1 安全网改成注册表驱动、以及三种新的离职 blocker 类型; # 读面已经被现有工具覆盖(list_leaver_account_assurance 现在天然多返回每个身份同步 # 目标的 isync: 系统,list_offboarding_processes / revoke_offboarding_blocker # 对新类型不需要任何改动 —— 它们按 blocker id 工作,不枚举类型)。唯一新的写动作是 # 「账号终态」页上按人回收的那个按钮,它已有 HTTP 端点但历来就没有 MCP 孪生;在这 # 一轮补一个不属于本 PR 的范围,而给它一个工具意味着一次 LLM 调用就能在厂商侧停掉 # 一个人的账号 —— 那正是应该有人看着页面上的证据再点的动作。 @mcp_server.tool( name="list_my_assigned_access_reviews", description=( "List access-review items you must decide ON SOMEONE ELSE'S " "BEHALF — typically because a direct report never answered their " "half-yearly access self-attestation, so the call fell to you. " "Returns each item with the holder, what the access is, the " "campaign deadline, and why it reached you. Self-scoped: only " "items where you are the named reviewer. Read-only — deciding " "goes through the review page, because keeping or removing a " "colleague's access from one sentence, without the item's " "context in front of you, is the thing the page exists to stop." ), ) async def list_my_assigned_access_reviews() -> dict: async with tool_runtime("list_my_assigned_access_reviews", {}) as rt: from app.domains.auditor import access_certification_service as acs data = await acs.assigned_reviews_for( rt.db, reviewer_person_id=rt.user.id, ) payload: dict = serialize(data) return payload # 2026-09-05 (PR 访问对象闭环 #4 · 迁移 612): 多厂商使用信号 +1 # seat_usage_signal_coverage — 616 → 617。只读,而且是这一轮**最该**有 MCP 孪生的 # 那一个:「没有闲置坐席」和「这个厂商我们根本看不见」在报表里长得一模一样、意思 # 相反,AI 客户端在引用任何闲置数字之前必须能问到后者。回收动作本身**不给工具** —— # 撤一个人的坐席应该有人看着证据再点,同 PR3 不给「按人回收账号」工具的先例。 # 2026-09-05 (+1): list_my_cloud_scope_grant_reviews — the cloud-scope # owner's half of the mover review, mirroring the new # GET /api/v1/role-changes/my-scope-grants self-service flow (MCP-sync # rule). Read-only on purpose: keep / revoke is a human action, and # revoke tears down a live cloud IAM binding. # # 2026-09-06 (+1): scheduled_job_chains — the read half of the new # GET /scheduled-jobs/chains admin surface (MCP-sync rule). Read-only for # the same reason as its two siblings: it exists to explain what runs # after what, and editing a chain edge is a reviewed admin action with # its own audit trail and cycle validation. # 620 -> 621. # 2026-09-05 (+2): scheduled_job_capacity / scheduled_job_hygiene — the # read halves of the new GET /scheduled-jobs/capacity and # GET /scheduled-jobs/hygiene admin surfaces (MCP-sync rule). Both are # read-only by design: they exist to explain why a cron is late or inert, # and the remedies (re-cadence, disable, re-enable an integration) are # already-existing human actions with their own audit trail. # 618 → 620. # 617 → 618 on merge: the two lines above landed in parallel branches and # BOTH read "616 → 617". Neither number was right once both tools were in # the tree, so this was recounted from `len(@mcp_server.tool)` rather than # re-derived by arithmetic — which is exactly the drift the hint exists # to catch, arriving through a merge instead of through a forgotten bump. @mcp_server.tool( name="platform_llm_usage", description=( "IT admin / finance / auditor: how much of the company's AI " "budget HELIX ITSELF is spending, and on which feature. " "Returns calls and token counts for a window (default 30 days, " "max 180), cut three ways — by purpose (e.g. support.copilot, " "automation_exception.triage), by model, and by day — plus the " "top failing error codes. " "TOKENS ONLY, NEVER MONEY: this platform has no per-model price " "table, so do not multiply these by a price you assume. The " "vendor invoices exposed by ai_spending_* are the money truth. " "Note two things when quoting the numbers: rows whose vendor " "did not report usage are counted in " "``calls_without_token_report`` and are NOT in the token sums, " "so a purpose with many such calls looks cheaper than it is; " "and the ``unattributed`` purpose is a real bucket of calls " "from sites that have not declared themselves, not an error." ), ) async def platform_llm_usage(days: int | None = None) -> dict: async with tool_runtime("platform_llm_usage", {"days": days}) as rt: _require_ai_spending_view(rt) from app.domains.bailian.ledger_read import overview payload: dict = serialize(await overview(rt.db, days=days)) return payload # 2026-09-06 (自动化异常闭环 PR1 · 迁移 618): +3 # list_automation_exceptions / get_automation_exception / # automation_exception_overview。全部只读。这三个是本轮**最该**有 MCP 孪生 # 的:一个 AI 客户端在回答「某某为什么没拿到账号」「我们的自动化健康吗」 # 之前,必须能问到「哪些没走通、还有几条没恢复、还有几个人在等」,否则它 # 只能引用各域自己的失败计数 —— 而那些计数是幸存者偏差(重试成功的那次不 # 留行)。 # 四个人工状态迁移(确认 / 定因 / 不修关闭 / 指派)**不给工具**:每一个都是 # 人对一个仍在发生的问题下判断,尤其「不修关闭」会让一个真实故障从台账上 # 消失。同既有先例(不给「按人回收账号」「撤某人坐席」工具)。 # 本行在 rebase 时**重新数过**(`len(@mcp_server.tool)`),没有按 620+3 算 —— # 合并期间 main 从 620 走到了 621,算术会写下一个刚好差一的谎。 # 2026-09-06 (自动化异常闭环 PR2 · 迁移 619): +1 # platform_llm_usage —— 平台自己烧了多少 token、烧在哪个功能上。只读。 # 这是本轮唯一该有 MCP 孪生的读:一个 AI 客户端被问到「我们的 AI 预算花在哪」时, # 此前只能引用厂商账单(按 API key 分),而账单答不出「哪个功能」。 # 分诊的四个人工动作仍然不给工具,与 PR1 同一条理由。 # 624 → 625,实测 `len(@mcp_server.tool)`,不是算术。 # 2026-09-06 (PR 访问对象闭环 #5 · 迁移 620): 自证建议与后果 +1 # list_my_assigned_access_reviews — 625 -> 626。**本行在 rebase 时重新数过**, # 不是按算术推的:main 在这轮里又动过,按「上次的数 +1」会正好写下一个差一的 # 谎。只读。这一轮的写动作是 # 「替下属决定留还是撤」,**刻意不给工具**:本文件里 approve/reject 一类 # 「替别人的申请做决定」早就写明不上聊天面 —— 一句话就决定同事的权限、 # 而条目的上下文不在决定者眼前,正是复核页要堵的洞。读面给工具是对的: # 「有几个下属没回答、分别是什么」值得能被问到。 # 2026-09-08(+1 → 629):`assistant_ticket_chat_overview` —— 工单群运营台的 # 只读孪生(global_admin + auditor)。只读,所以与 curated 卡片无关。 # 2026-09-08(+1 → 628):`disband_ticket_chat` —— 解散一张已结工单的飞书群。 # **不可逆**(聊天记录随房间消失),所以它进了 `derived_actions.OPT_OUT`: # 不可逆动作不上聊天卡片面,这条规矩本文件早就写着。工具本身仍然可用 # (MCP 客户端、运营台),只是不会被派生成一张可以在群里递给人的确认卡。 # 2026-09-08(+1 → 627):`open_ticket_chat` —— 把一张工单变成一个飞书群 # (求助人 + 现在负责它的人)。写工具,所以它自动出现在助手的卡片面上; # 按 curated-card 规矩配了三语标题与字段标签,而不是排除 —— 「为我这张 # 工单拉个群」正是员工替自己做的事。 # 2026-09-09(±0,仍是 629):`set_my_language` 当天加进来又**当天删掉** —— # 记在这里是因为下一个人很可能会想再加一次。改语言的 HTTP 孪生 # (`PATCH /auth/me/profile`)**明确拒绝 PAT**(ADR:被偷走的 PAT 不该能把 # 人的语言悄悄换成他读不懂的那种,让他错过通知),而 MCP 的鉴权只有 PAT / # MCP-OAuth,从来不是浏览器会话 —— 加这个工具等于给那条被拒绝的路开一个后门。 # 按 `reveal_my_contact` 的先例改成「非 jwt 一律拒绝」也不行:MCP 永远不是 # jwt,那就是一个在任何可达上下文里都不会成功的工具,而且它派生出来的卡片 # 会在员工点完确认之后才拒绝。改语言的两条路是飞书帮助卡(飞书验签的操作者 # 身份,等价于本人在自己的客户端上点)和网页个人设置页。 _REGISTERED_TOOLS_COUNT_HINT = 629 # ══════════════════════════════════════════════════════════════════════ # DAX grant lifecycle (migration 606) — expiry / renewal / owner confirmation # ══════════════════════════════════════════════════════════════════════ def _dax_binding_payload(binding, *, settings) -> dict: from datetime import UTC as _UTC from datetime import datetime as _dt from app.domains.data_access.lifecycle import lifecycle_fields payload: dict = serialize({ "binding_id": binding.id, "resource_id": binding.resource_id, "principal_type": binding.principal_type_snapshot, "principal_email": binding.principal_email_snapshot, "binding_status": binding.status, "reconcile_status": binding.reconcile_status, "revoke_reason": binding.revoke_reason, "revoked_at": binding.revoked_at, **lifecycle_fields(binding, now=_dt.now(_UTC), settings=settings), }) return payload @mcp_server.tool( name="admin_list_data_access_grants", description=( "IT / auditor / management: inventory of BigQuery dataset grants with their " "lifecycle position — personal grants' expiry (default 180 days, renewable a " "bounded number of times), service-account grants' semiannual technical-owner " "confirmation. lifecycle filter: ok | expiring | expired | confirmation_due | " "confirmation_overdue | lifecycle_revoked. Mirrors GET /data-access/admin/grants." ), ) async def admin_list_data_access_grants( principal_type: str | None = None, lifecycle: str | None = None, status: str | None = None, search: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "admin_list_data_access_grants", {"principal_type": principal_type, "lifecycle": lifecycle, "status": status, "search": search, "page": page, "page_size": page_size}, ) as rt: _require_admin_read(rt.user.role_names) from datetime import UTC as _UTC from datetime import datetime as _dt from app.domains.data_access.lifecycle import ( LIFECYCLE_STATES, DataAccessLifecycleService, load_lifecycle_settings, ) if lifecycle is not None and lifecycle not in (*LIFECYCLE_STATES, "lifecycle_revoked"): raise ValueError("lifecycle must be one of ok, expiring, expired, confirmation_due, confirmation_overdue, lifecycle_revoked") if status is not None and status not in ("active", "revoked_pending", "revoked"): raise ValueError("status must be active, revoked_pending or revoked") settings = await load_lifecycle_settings(rt.db) items, total = await DataAccessLifecycleService(rt.db).list_admin_grants( now=_dt.now(_UTC), settings=settings, principal_type=principal_type, lifecycle=lifecycle, status=status, search=_bound_free_text(search, field="search", max_len=255), page=page, page_size=page_size, ) return {"items": items, "total": total, "page": page, "page_size": page_size} @mcp_server.tool( name="get_data_access_grant_lifecycle_summary", description=( "IT / auditor / management: KPI strip of the DAX grant lifecycle — expiring, " "expired-but-not-revoked, service-account confirmations due / overdue, 30-day " "renewals and lifecycle revokes, plus the effective policy. Mirrors GET " "/data-access/admin/grants/summary." ), ) async def get_data_access_grant_lifecycle_summary() -> dict: async with tool_runtime("get_data_access_grant_lifecycle_summary", {}) as rt: _require_admin_read(rt.user.role_names) from datetime import UTC as _UTC from datetime import datetime as _dt from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) settings = await load_lifecycle_settings(rt.db) summary: dict = serialize(await DataAccessLifecycleService(rt.db).summary( now=_dt.now(_UTC), settings=settings, )) return summary @mcp_server.tool( name="get_data_access_grant_lifecycle_settings", description=( "Read the DAX grant lifecycle policy: personal TTL, reminder ladder, renewal " "length / cap, service-account confirmation interval / grace, and the four " "switches (employee push, auto-revoke on expiry, owner push, auto-revoke on " "confirmation lapse). Mirrors GET /data-access/admin/grants/lifecycle-settings." ), ) async def get_data_access_grant_lifecycle_settings() -> dict: async with tool_runtime("get_data_access_grant_lifecycle_settings", {}) as rt: _require_admin_read(rt.user.role_names) from app.domains.data_access.lifecycle import load_lifecycle_settings return (await load_lifecycle_settings(rt.db)).to_setting() @mcp_server.tool( name="update_data_access_grant_lifecycle_settings", description=( "Global admin: change the DAX grant lifecycle policy. Pass only the fields to " "change. auto_revoke_on_expiry cannot be enabled while employee_push_enabled is " "off (a grant must not expire without the holder having been reminded); the " "same rule binds auto_revoke_on_confirmation_lapse to owner_confirmation_push_" "enabled. Mirrors PUT /data-access/admin/grants/lifecycle-settings." ), ) async def update_data_access_grant_lifecycle_settings( personal_ttl_days: int | None = None, reminder_ladder_days: list[int] | None = None, renewal_extends_days: int | None = None, self_renewals_before_reapproval: int | None = None, employee_push_enabled: bool | None = None, auto_revoke_on_expiry: bool | None = None, sa_confirmation_interval_days: int | None = None, sa_confirmation_grace_days: int | None = None, owner_confirmation_push_enabled: bool | None = None, auto_revoke_on_confirmation_lapse: bool | None = None, ) -> dict: changes = { k: v for k, v in { "personal_ttl_days": personal_ttl_days, "reminder_ladder_days": reminder_ladder_days, "renewal_extends_days": renewal_extends_days, "self_renewals_before_reapproval": self_renewals_before_reapproval, "employee_push_enabled": employee_push_enabled, "auto_revoke_on_expiry": auto_revoke_on_expiry, "sa_confirmation_interval_days": sa_confirmation_interval_days, "sa_confirmation_grace_days": sa_confirmation_grace_days, "owner_confirmation_push_enabled": owner_confirmation_push_enabled, "auto_revoke_on_confirmation_lapse": auto_revoke_on_confirmation_lapse, }.items() if v is not None } async with tool_runtime( "update_data_access_grant_lifecycle_settings", changes, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.audit.service import AuditService from app.domains.data_access.lifecycle import ( LifecycleSettings, load_lifecycle_settings, save_lifecycle_settings, ) previous = await load_lifecycle_settings(rt.db) merged = LifecycleSettings.from_setting({**previous.to_setting(), **changes}) await save_lifecycle_settings(rt.db, merged) await AuditService(rt.db).log( trace_id=_trace_str(), actor_type="user", actor_user_id=rt.user.id, action="data_access.lifecycle_settings.updated", status="success", resource_type="platform_setting", resource_id="data_access.grant_lifecycle", detail={"before": previous.to_setting(), "after": merged.to_setting(), "via": "mcp"}, ) return merged.to_setting() @mcp_server.tool( name="extend_data_access_grant", description=( "Global admin: extend a PERSONAL BigQuery dataset grant by N days (1-730) with a " "reason. Does not consume one of the holder's self-renewals. Mirrors POST " "/data-access/admin/grants/{binding_id}/extend." ), ) async def extend_data_access_grant(binding_id: str, days: int, reason: str) -> dict: async with tool_runtime( "extend_data_access_grant", {"binding_id": binding_id, "days": days, "reason": reason}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) reason = _bound_free_text(reason, field="reason", max_len=500) or reason settings = await load_lifecycle_settings(rt.db) binding = await DataAccessLifecycleService(rt.db).extend( _u(binding_id), actor=rt.user, days=int(days), reason=reason, trace_id=_trace_str(), ) return _dax_binding_payload(binding, settings=settings) @mcp_server.tool( name="revoke_data_access_grant", description=( "Global admin: revoke a BigQuery dataset grant (personal, group or service " "account). Helix flips the binding to revoked_pending now; the apply-absent " "reconciler performs the BigQuery IAM write on its next tick. Mirrors POST " "/data-access/admin/grants/{binding_id}/revoke." ), ) async def revoke_data_access_grant(binding_id: str, reason: str | None = None) -> dict: async with tool_runtime( "revoke_data_access_grant", {"binding_id": binding_id, "reason": reason}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) settings = await load_lifecycle_settings(rt.db) binding = await DataAccessLifecycleService(rt.db).revoke_as_admin( _u(binding_id), actor=rt.user, reason_note=_bound_free_text(reason, field="reason", max_len=500), trace_id=_trace_str(), ) return _dax_binding_payload(binding, settings=settings) @mcp_server.tool( name="renew_my_data_access_grant", description=( "Renew one of the caller's PERSONAL BigQuery dataset grants before (or after) it " "expires. Requires a one-line reason. Bounded: after the policy's self-renewal " "cap the tool returns 409 DATA_ACCESS_RENEWAL_LIMIT_REACHED and the holder must " "submit a new request (submit_data_access_personal_grant). Mirrors POST " "/data-access/me/grants/{binding_id}/renew." ), ) async def renew_my_data_access_grant(binding_id: str, reason: str) -> dict: async with tool_runtime( "renew_my_data_access_grant", {"binding_id": binding_id, "reason": reason}, mutates=True, ) as rt: from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) reason = _bound_free_text(reason, field="reason", max_len=500) or reason settings = await load_lifecycle_settings(rt.db) binding = await DataAccessLifecycleService(rt.db).renew( _u(binding_id), actor=rt.user, reason=reason, settings=settings, trace_id=_trace_str(), ) return _dax_binding_payload(binding, settings=settings) @mcp_server.tool( name="release_my_data_access_grant", description=( "Give up a BigQuery dataset grant the caller holds (personal), technically owns " "(service account) or owns as a Google Group owner / dataset owner. Same revoke " "path as the admin console — the reconciler removes the IAM member on its next " "tick. Mirrors POST /data-access/me/grants/{binding_id}/release." ), ) async def release_my_data_access_grant(binding_id: str, reason: str | None = None) -> dict: async with tool_runtime( "release_my_data_access_grant", {"binding_id": binding_id, "reason": reason}, mutates=True, ) as rt: from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) settings = await load_lifecycle_settings(rt.db) binding = await DataAccessLifecycleService(rt.db).release( _u(binding_id), actor=rt.user, reason_note=_bound_free_text(reason, field="reason", max_len=500), trace_id=_trace_str(), ) return _dax_binding_payload(binding, settings=settings) @mcp_server.tool( name="list_my_data_access_owner_confirmations", description=( "Service-account BigQuery dataset grants whose technical owner is the caller and " "whose semiannual confirmation is due within 14 days or overdue. Answer each with " "respond_data_access_owner_confirmation. Mirrors GET /data-access/me/owner-confirmations." ), ) async def list_my_data_access_owner_confirmations() -> dict: async with tool_runtime("list_my_data_access_owner_confirmations", {}) as rt: from datetime import UTC as _UTC from datetime import datetime as _dt from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) now = _dt.now(_UTC) settings = await load_lifecycle_settings(rt.db) rows = await DataAccessLifecycleService(rt.db).list_owner_confirmations_for(rt.user.id, now=now) return {"items": [ { **_dax_binding_payload(b, settings=settings), "locator": ".".join(filter(None, (r.project_id, r.dataset_id, r.table_id))), "display_name": r.display_name, "reminder_count": int(b.owner_confirmation_reminder_count or 0), "grace_days": settings.sa_confirmation_grace_days, } for b, r in rows ]} @mcp_server.tool( name="respond_data_access_owner_confirmation", description=( "Technical owner answers a service-account dataset grant's semiannual " "confirmation. decision='confirm' keeps the grant and schedules the next " "confirmation; decision='revoke' gives it up (reconciler removes the IAM member). " "Mirrors POST /data-access/me/owner-confirmations/{binding_id}/respond." ), ) async def respond_data_access_owner_confirmation( binding_id: str, decision: str, note: str | None = None, ) -> dict: async with tool_runtime( "respond_data_access_owner_confirmation", {"binding_id": binding_id, "decision": decision, "note": note}, mutates=True, ) as rt: from app.domains.data_access.lifecycle import ( DataAccessLifecycleService, load_lifecycle_settings, ) if decision not in ("confirm", "revoke"): raise ValueError("decision must be 'confirm' or 'revoke'") settings = await load_lifecycle_settings(rt.db) binding = await DataAccessLifecycleService(rt.db).respond_owner_confirmation( _u(binding_id), actor=rt.user, decision=decision, note=_bound_free_text(note, field="note", max_len=500), settings=settings, trace_id=_trace_str(), ) return _dax_binding_payload(binding, settings=settings) # ══════════════════════════════════════════════════════════════════════ # Permission catalog — in-app approval (migration 606) # ══════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_my_permission_approvals", description=( "Software permission requests waiting for the caller's decision — the caller is " "the software's owner or IT contact (scope='mine'), or a global admin listing " "every pending request (scope='all'). Decide with approve_permission_request / " "reject_permission_request. Approvals happen in Helix, not Feishu. Mirrors GET " "/permissions/approvals." ), ) async def list_my_permission_approvals( scope: str = "mine", page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_my_permission_approvals", {"scope": scope, "page": page, "page_size": page_size}, ) as rt: if scope not in ("mine", "all"): raise ValueError("scope must be 'mine' or 'all'") if scope == "all": _require_admin_read(rt.user.role_names) from app.domains.permission.service import PermissionService items, total = await PermissionService(rt.db).list_pending_approvals( approver_id=rt.user.id, include_all=(scope == "all"), page=page, page_size=page_size, ) return {"items": serialize(items), "total": total, "page": page, "page_size": page_size} @mcp_server.tool( name="approve_permission_request", description=( "Approve a pending software permission request (software owner / IT contact / " "global admin). valid_days (optional, 1-1095) gives the grant a validity after " "which the daily sweep revokes it; omit for the policy default. The row moves to " "'pending' and the vendor-side grant runs from a provisioning task; it turns " "'active' when that lands. Mirrors POST /permissions/approvals/{id}/approve." ), ) async def approve_permission_request( user_permission_id: str, note: str | None = None, valid_days: int | None = None, ) -> dict: async with tool_runtime( "approve_permission_request", {"user_permission_id": user_permission_id, "note": note, "valid_days": valid_days}, mutates=True, ) as rt: from app.domains.permission.service import PermissionService if valid_days is not None and not 1 <= int(valid_days) <= 1095: raise ValueError("valid_days must be between 1 and 1095") svc = PermissionService(rt.db) row = await svc.approve_request( _u(user_permission_id), actor_user_id=rt.user.id, note=_bound_free_text(note, field="note", max_len=2000), valid_days=int(valid_days) if valid_days is not None else None, trace_id=_trace_str(), ) items, _ = await svc.list_user_permissions( user_id=row.user_id, permission_id=row.permission_id, viewer_id=rt.user.id, ) match = next((i for i in items if i.id == row.id), None) return serialize(match) if match is not None else {"id": str(row.id), "status": row.status} @mcp_server.tool( name="reject_permission_request", description=( "Reject a pending software permission request (software owner / IT contact / " "global admin) with an optional note; the requester is notified. Mirrors POST " "/permissions/approvals/{id}/reject." ), ) async def reject_permission_request(user_permission_id: str, note: str | None = None) -> dict: async with tool_runtime( "reject_permission_request", {"user_permission_id": user_permission_id, "note": note}, mutates=True, ) as rt: from app.domains.permission.service import PermissionService svc = PermissionService(rt.db) row = await svc.reject_request( _u(user_permission_id), actor_user_id=rt.user.id, note=_bound_free_text(note, field="note", max_len=2000), trace_id=_trace_str(), ) items, _ = await svc.list_user_permissions( user_id=row.user_id, permission_id=row.permission_id, viewer_id=rt.user.id, ) match = next((i for i in items if i.id == row.id), None) return serialize(match) if match is not None else {"id": str(row.id), "status": row.status} # ────────────────────────────────────────────────────────────────────── # Procurement domain (PR 1) — read-heavy with two writes. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_procurement_categories", description=( "List procurement category master-data rows. Each row exposes " "``code`` (software/hardware/cloud/service or custom), display " "names, default owner / cost-centre / reminder days, and the " "receipt_semantics that govern how PO completion behaves for " "this category (hardware_provision / software_activation / " "service_acceptance). Mirrors HTTP GET /api/v1/procurement/" "categories." ), ) async def list_procurement_categories(include_inactive: bool = False) -> dict: async with tool_runtime( "list_procurement_categories", {"include_inactive": include_inactive}, ) as rt: _require_software_read(rt.user.role_names) from app.domains.procurement import service as _proc_service rows = await _proc_service.list_categories( rt.db, include_inactive=include_inactive, ) return { "data": [ { "id": str(r.id), "code": r.code, "name_zh": r.name_zh, "name_en": r.name_en, "receipt_semantics": r.receipt_semantics, "default_reminder_days": r.default_reminder_days, "default_cost_center": r.default_cost_center, "is_active": bool(r.is_active), "sort_order": r.sort_order, } for r in rows ], } @mcp_server.tool( name="list_procurement_suppliers", description=( "List procurement suppliers (v2 master data — full vendor " "record including tax_id, banking, multi-contact JSON, payment " "terms, status). Optional filters: ``keyword`` matches name/" "legal_name/supplier_code; ``status`` is one of evaluating/" "active/suspended/blacklisted. Pagination via page+page_size. " "Mirrors HTTP GET /api/v1/procurement/suppliers." ), ) async def list_procurement_suppliers( keyword: str | None = None, status: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_procurement_suppliers", {"keyword": keyword, "status": status, "page": page, "page_size": page_size}, ) as rt: _require_software_read(rt.user.role_names) from app.core.masking import mask_tail from app.core.rbac import ( FINANCE_ROLE, GLOBAL_ADMIN_ROLE, has_role, ) from app.domains.procurement import service as _proc_service # Mirror the HTTP route's masked-default: vendor tax_id is plaintext # only for finance / global_admin (the AP/banking owners). The # _require_software_read audience is broader (asset_admin / auditor # / management), so without this the MCP surface re-leaks the field # the HTTP twin masks. See procurement/router.py # _can_read_supplier_plaintext / reveal_supplier_route. plaintext = has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE, FINANCE_ROLE) rows, total = await _proc_service.list_suppliers( rt.db, keyword=keyword, status=status, limit=page_size, offset=(page - 1) * page_size, ) return { "data": [ { "id": str(r.id), "supplier_code": r.supplier_code, "name": r.name, "legal_name": r.legal_name, "country": r.country, "status": r.status, "default_currency": r.default_currency, "payment_terms": r.payment_terms, "tax_id": r.tax_id if plaintext else mask_tail(r.tax_id), "sensitive_masked": not plaintext, "contacts": r.contacts or [], "is_legacy_imported": r.legacy_supplier_id is not None, } for r in rows ], "pagination": { "page": page, "page_size": page_size, "total": int(total), }, } @mcp_server.tool( name="create_procurement_supplier", description=( "Create a new procurement supplier. Required: ``name``. " "Optional: legal_name, country, tax_id, status (default " "'active'), default_currency, payment_terms, bank_name, " "bank_account, swift_code, contacts (list of {name, role, " "email, phone, is_primary}). ``supplier_code`` auto-generates " "if omitted (format ``SUP-YYYY-NNNNN``). Mirrors HTTP POST " "/api/v1/procurement/suppliers. Supply a stable " "``idempotency_key`` and reuse it after an uncertain response." ), ) async def create_procurement_supplier( name: str, legal_name: str | None = None, country: str | None = None, tax_id: str | None = None, status: str = "active", default_currency: str | None = None, payment_terms: str | None = None, bank_name: str | None = None, bank_account: str | None = None, swift_code: str | None = None, contacts: list[dict] | None = None, notes: str | None = None, idempotency_key: str | None = None, ) -> dict: async with tool_runtime( "create_procurement_supplier", {"name": name, "status": status, "idempotency_key": idempotency_key}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.procurement import service as _proc_service from app.domains.procurement.schemas import SupplierContact, SupplierCreate try: payload = SupplierCreate( name=name, legal_name=legal_name, country=country, tax_id=tax_id, status=status, default_currency=default_currency, payment_terms=payment_terms, bank_name=bank_name, bank_account=bank_account, swift_code=swift_code, contacts=[SupplierContact(**c) for c in (contacts or [])], notes=notes, ) obj, _replayed = await _proc_service.create_supplier_idempotent( rt.db, payload, actor_id=_u(str(rt.user.id)), actor=rt.user, idempotency_key=idempotency_key, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(obj.id), "supplier_code": obj.supplier_code, "name": obj.name, "status": obj.status, } } @mcp_server.tool( name="list_procurement_contracts", description=( "List procurement contracts. Optional filters: ``supplier_id``, " "``status`` (draft/signed/active/expired/terminated), " "``category_id``, ``expiring_within_days`` (returns rows whose " "expiry_date <= today + N days; useful for 'show me what's " "renewing in Q3'). Mirrors HTTP GET /api/v1/procurement/contracts." ), ) async def list_procurement_contracts( keyword: str | None = None, supplier_id: str | None = None, status: str | None = None, category_id: str | None = None, expiring_within_days: int | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_procurement_contracts", { "keyword": keyword, "supplier_id": supplier_id, "status": status, "category_id": category_id, "expiring_within_days": expiring_within_days, "page": page, "page_size": page_size, }, ) as rt: _require_software_read(rt.user.role_names) from app.domains.procurement import service as _proc_service sup_uuid = _u(supplier_id) if supplier_id else None cat_uuid = _u(category_id) if category_id else None rows, total = await _proc_service.list_contracts( rt.db, keyword=keyword, supplier_id=sup_uuid, status=status, category_id=cat_uuid, expiring_within_days=expiring_within_days, limit=page_size, offset=(page - 1) * page_size, ) return { "data": [ { "id": str(r.id), "contract_number": r.contract_number, "title": r.title, "supplier_id": str(r.supplier_id) if r.supplier_id else None, "status": r.status, "contract_type": r.contract_type, "total_amount": str(r.total_amount) if r.total_amount is not None else None, "currency": r.currency, "effective_date": r.effective_date.isoformat() if r.effective_date else None, "expiry_date": r.expiry_date.isoformat() if r.expiry_date else None, "auto_renew": bool(r.auto_renew), "external_contract_no": r.external_contract_no, } for r in rows ], "pagination": { "page": page, "page_size": page_size, "total": int(total), }, } @mcp_server.tool( name="get_procurement_contract", description=( "Fetch a single procurement contract with its attachment + " "version-history metadata. Returns the same shape as HTTP GET " "/api/v1/procurement/contracts/{id}." ), ) async def get_procurement_contract(contract_id: str) -> dict: async with tool_runtime( "get_procurement_contract", {"contract_id": contract_id}, ) as rt: _require_software_read(rt.user.role_names) from app.domains.procurement import service as _proc_service cid = _u(contract_id) obj = await _proc_service.get_contract(rt.db, cid) atts = await _proc_service.list_contract_attachments(rt.db, cid) vers = await _proc_service.list_contract_versions(rt.db, cid) return { "data": { "id": str(obj.id), "contract_number": obj.contract_number, "title": obj.title, "supplier_id": str(obj.supplier_id) if obj.supplier_id else None, "legal_entity_id": str(obj.legal_entity_id) if obj.legal_entity_id else None, "category_id": str(obj.category_id) if obj.category_id else None, "contract_type": obj.contract_type, "total_amount": str(obj.total_amount) if obj.total_amount is not None else None, "currency": obj.currency, "signed_date": obj.signed_date.isoformat() if obj.signed_date else None, "effective_date": obj.effective_date.isoformat() if obj.effective_date else None, "expiry_date": obj.expiry_date.isoformat() if obj.expiry_date else None, "auto_renew": bool(obj.auto_renew), "renew_notice_days": obj.renew_notice_days, "status": obj.status, "business_owner_id": str(obj.business_owner_id) if obj.business_owner_id else None, "external_contract_no": obj.external_contract_no, "attachment_count": len(atts), "version_count": len(vers), }, } @mcp_server.tool( name="list_procurement_orders", description=( "List procurement orders (POs). Optional filters: keyword " "(matches order_number / external_po_number / notes), " "supplier_id, contract_id, category_id, status (draft/submitted" "/approved/rejected/received/completed/cancelled), order_type " "(purchase/lease/subscription/service), applicant_id, " "start_date/end_date (created_at window). Mirrors HTTP GET " "/api/v1/procurement/orders." ), ) async def list_procurement_orders( keyword: str | None = None, supplier_id: str | None = None, contract_id: str | None = None, category_id: str | None = None, status: str | None = None, order_type: str | None = None, applicant_id: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) def _to_uuid(s: str | None): return _u(s) if s else None async with tool_runtime( "list_procurement_orders", { "keyword": keyword, "supplier_id": supplier_id, "contract_id": contract_id, "category_id": category_id, "status": status, "order_type": order_type, "applicant_id": applicant_id, "page": page, "page_size": page_size, }, ) as rt: _require_software_read(rt.user.role_names) from app.domains.procurement import service as _proc_service rows, total = await _proc_service.list_orders( rt.db, keyword=keyword, supplier_id=_to_uuid(supplier_id), contract_id=_to_uuid(contract_id), category_id=_to_uuid(category_id), status=status, order_type=order_type, applicant_id=_to_uuid(applicant_id), limit=page_size, offset=(page - 1) * page_size, ) return { "data": [ { "id": str(r.id), "order_number": r.order_number, "order_type": r.order_type, "status": r.status, "supplier_id": str(r.supplier_id) if r.supplier_id else None, "contract_id": str(r.contract_id) if r.contract_id else None, "category_id": str(r.category_id) if r.category_id else None, "total_amount": str(r.total_amount) if r.total_amount is not None else None, "currency": r.currency, "total_cny": str(r.total_cny) if r.total_cny is not None else None, "receipt_status": r.receipt_status, "payment_status": r.payment_status, "external_po_number": r.external_po_number, "created_at": r.created_at.isoformat(), } for r in rows ], "pagination": { "page": page, "page_size": page_size, "total": int(total), }, } @mcp_server.tool( name="get_procurement_order", description=( "Fetch a single procurement order with its line items. " "Returns the order header (status, totals, four-role owners, " "receipt/payment status, budget linkage, external links) plus " "an ``items`` array. Mirrors HTTP GET /api/v1/procurement/" "orders/{id}." ), ) async def get_procurement_order(order_id: str) -> dict: async with tool_runtime( "get_procurement_order", {"order_id": order_id}, ) as rt: _require_software_read(rt.user.role_names) from app.domains.procurement import service as _proc_service oid = _u(order_id) obj = await _proc_service.get_order(rt.db, oid) items = await _proc_service.list_order_items(rt.db, oid) return { "data": { "id": str(obj.id), "order_number": obj.order_number, "order_type": obj.order_type, "status": obj.status, "supplier_id": str(obj.supplier_id) if obj.supplier_id else None, "contract_id": str(obj.contract_id) if obj.contract_id else None, "category_id": str(obj.category_id) if obj.category_id else None, "legal_entity_id": str(obj.legal_entity_id) if obj.legal_entity_id else None, "department": obj.department, "cost_center": obj.cost_center, "currency": obj.currency, "total_amount": str(obj.total_amount) if obj.total_amount is not None else None, "total_cny": str(obj.total_cny) if obj.total_cny is not None else None, "applicant_id": str(obj.applicant_id), "business_owner_id": str(obj.business_owner_id) if obj.business_owner_id else None, "procurement_owner_id": str(obj.procurement_owner_id) if obj.procurement_owner_id else None, "receipt_status": obj.receipt_status, "payment_status": obj.payment_status, "external_po_number": obj.external_po_number, "items": [ { "id": str(i.id), "line_no": i.line_no, "description": i.description, "quantity": str(i.quantity) if i.quantity is not None else None, "unit_price": str(i.unit_price) if i.unit_price is not None else None, "line_total": str(i.line_total) if i.line_total is not None else None, "currency": i.currency, "receipt_status": i.receipt_status, } for i in items ], }, } @mcp_server.tool( name="create_procurement_order", description=( "Create a draft procurement order. Required: ``order_type`` " "(purchase/lease/subscription/service), ``items`` (list of " "{description, quantity, unit_price, currency}). Optional: " "category_id, supplier_id, contract_id, legal_entity_id, " "software_catalog_id, department, cost_center, currency " "(default CNY), total_amount (omit to auto-sum item line_totals " "when all items share the order currency; pass explicitly on " "mixed-currency orders so the budget pool tracks the spend), " "business_owner_id, it_contact_id, finance_contact_id, " "procurement_owner_id, notes, external_po_number. Applicant " "defaults to the caller. Mirrors HTTP POST /api/v1/procurement/" "orders. Supply a stable ``idempotency_key`` and reuse it after " "an uncertain response." ), ) async def create_procurement_order( order_type: str, items: list[dict], category_id: str | None = None, supplier_id: str | None = None, contract_id: str | None = None, legal_entity_id: str | None = None, software_catalog_id: str | None = None, department: str | None = None, cost_center: str | None = None, currency: str = "CNY", total_amount: str | None = None, business_owner_id: str | None = None, procurement_owner_id: str | None = None, notes: str | None = None, external_po_number: str | None = None, idempotency_key: str | None = None, ) -> dict: import uuid as _uuid from decimal import Decimal def _to_uuid(s: str | None): return _u(s) if s else None async with tool_runtime( "create_procurement_order", { "order_type": order_type, "currency": currency, "items_count": len(items or []), "idempotency_key": idempotency_key, }, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.procurement import service as _proc_service from app.domains.procurement.schemas import OrderCreate, OrderItemCreate try: line_payloads = [] for idx, raw in enumerate(items or [], start=1): line_payloads.append(OrderItemCreate( line_no=int(raw.get("line_no") or idx), description=raw["description"], quantity=Decimal(str(raw.get("quantity") or 1)), unit=raw.get("unit"), unit_price=( Decimal(str(raw["unit_price"])) if raw.get("unit_price") is not None else None ), line_total=( Decimal(str(raw["line_total"])) if raw.get("line_total") is not None else None ), currency=raw.get("currency") or currency, )) payload = OrderCreate( order_type=order_type, category_id=_to_uuid(category_id), supplier_id=_to_uuid(supplier_id), contract_id=_to_uuid(contract_id), legal_entity_id=_to_uuid(legal_entity_id), software_catalog_id=_to_uuid(software_catalog_id), department=department, cost_center=cost_center, currency=currency, total_amount=Decimal(total_amount) if total_amount is not None else None, business_owner_id=_to_uuid(business_owner_id), procurement_owner_id=_to_uuid(procurement_owner_id), notes=notes, external_po_number=external_po_number, items=line_payloads, ) actor_id = _uuid.UUID(str(rt.user.id)) obj, _replayed = await _proc_service.create_order_idempotent( rt.db, payload, applicant_id=actor_id, actor_id=actor_id, actor=rt.user, idempotency_key=idempotency_key, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(obj.id), "order_number": obj.order_number, "status": obj.status, "currency": obj.currency, "total_amount": str(obj.total_amount) if obj.total_amount is not None else None, }, } @mcp_server.tool( name="list_procurement_budgets", description=( "List procurement budget pools. Optional filters: year, " "department, owner_person_id. Each row carries: total_amount, " "currency, owner, plus computed locked / remaining / pct_used " "and the 80%/95% threshold flags. Mirrors HTTP GET /api/v1/" "procurement/budgets." ), ) async def list_procurement_budgets( year: int | None = None, department: str | None = None, owner_person_id: str | None = None, include_inactive: bool = False, page: int = 1, page_size: int = 50, ) -> dict: page, page_size = _clamp_pagination(page, page_size, max_size=200) async with tool_runtime( "list_procurement_budgets", {"year": year, "department": department, "owner_person_id": owner_person_id, "page": page, "page_size": page_size}, ) as rt: # HTTP twin GET /procurement/budgets gates on the finance-view tier, # NOT require_software_read: the bulk list (every department's budget, # owner identity, balances) is an enumeration vector that must exclude # region-scoped asset_admin. Budget OWNERS read their own via # finance_my_budgets / the /me surface. Mirror the HTTP gate. from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_finance if not can_view_finance(rt.user.role_names): raise ForbiddenError() from app.domains.procurement import service as _proc_service from app.domains.procurement.budget_service import compute_budget_balance owner_uuid = _u(owner_person_id) if owner_person_id else None rows, total = await _proc_service.list_budgets( rt.db, year=year, department=department, owner_person_id=owner_uuid, include_inactive=include_inactive, limit=page_size, offset=(page - 1) * page_size, ) out = [] for r in rows: bal = await compute_budget_balance(rt.db, r.id) out.append({ "id": str(r.id), "year": r.year, "department": r.department, "cost_center": r.cost_center, "currency": r.currency, "total_amount": str(r.total_amount), "owner_person_id": str(r.owner_person_id) if r.owner_person_id else None, "locked": bal["locked"], "remaining": bal["remaining"], "pct_used": bal["pct_used"], "is_over_80": bal["is_over_80"], "is_over_95": bal["is_over_95"], "is_over_total": bal["is_over_total"], }) return { "data": out, "pagination": {"page": page, "page_size": page_size, "total": int(total)}, } @mcp_server.tool( name="get_procurement_budget", description=( "Fetch a single procurement budget with its full balance + " "activity log (every lock / release / actualize event with " "amount, PO ref, actor, timestamp). Mirrors HTTP GET /api/v1/" "procurement/budgets/{id}." ), ) async def get_procurement_budget(budget_id: str) -> dict: async with tool_runtime( "get_procurement_budget", {"budget_id": budget_id}, ) as rt: # HTTP twin GET /procurement/budgets/{id} gates on # require_software_read but then restricts the detail (owner # identity / cost-centre / per-actor consumption) to the # finance-view tier (global_admin / finance / auditor / management) # OR the budget's own owner; everyone else gets 404 so id # enumeration can't be used as an existence oracle. Mirror it. from app.core.exceptions import NotFoundError from app.core.rbac import can_view_finance from app.domains.procurement import service as _proc_service from app.domains.procurement.budget_service import compute_budget_balance _require_software_read(rt.user.role_names) bid = _u(budget_id) obj = await _proc_service.get_budget(rt.db, bid) if not can_view_finance(rt.user.role_names) and str( obj.owner_person_id or "" ) != str(rt.user.id): raise NotFoundError("procurement_budget", str(bid)) cons = await _proc_service.list_budget_consumptions(rt.db, bid) bal = await compute_budget_balance(rt.db, bid) return { "data": { "id": str(obj.id), "year": obj.year, "department": obj.department, "cost_center": obj.cost_center, "category_id": str(obj.category_id) if obj.category_id else None, "currency": obj.currency, "total_amount": str(obj.total_amount), "owner_person_id": str(obj.owner_person_id) if obj.owner_person_id else None, "is_active": bool(obj.is_active), "balance": bal, "consumptions": [ { "id": str(c.id), "event": c.event, "amount": str(c.amount), "currency": c.currency, "po_id": str(c.po_id) if c.po_id else None, "notes": c.notes, "occurred_at": c.occurred_at.isoformat(), } for c in cons ], }, } # ────────────────────────────────────────────────────────────────────── # Onboarding domain (PR-1 of the symmetric-onboarding series, 2026-06-01). # Seven admin tools cover the state-machine surface: list / get / set # hardware allocation / advance now / cancel / skip blocker / mark # blocker resolved. (The buddy concept was removed 2026-06-15.) # Self-service employee tools # (``get_my_onboarding_status`` etc.) are deferred to PR-4. Pairs with # the MCP-sync rule: every admin HTTP route at /api/v1/onboarding/* has # a 1:1 MCP tool. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_onboarding_processes", description=( "List onboarding processes — 「本月/这周入职了哪些人」「谁快入职了」: " "pass ``hire_date_from`` + ``hire_date_to`` for the window. " "Optional filters: ``status`` " "(scanning / preparing / ready / live / completed / cancelled); " "``open_only=true`` to scope to the four non-terminal statuses; " "``hire_date_from`` / ``hire_date_to`` (ISO date strings) for a " "date window. Pagination via ``limit`` (capped at 200) + " "``offset``. Mirrors HTTP GET /api/v1/onboarding/processes." ), ) async def list_onboarding_processes( status: list[str] | None = None, open_only: bool = False, hire_date_from: str | None = None, hire_date_to: str | None = None, limit: int = 50, offset: int = 0, ) -> dict: async with tool_runtime( "list_onboarding_processes", { "status": status, "open_only": open_only, "limit": limit, "offset": offset, }, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.onboarding.models import PROC_OPEN_STATUSES from app.domains.onboarding.service import OnboardingService # Clamp caller-controlled pagination — limit=1000000 would dump # the table and a negative offset is a SQL error (500). limit = max(1, min(int(limit), 200)) offset = max(0, int(offset)) status_filter = ( list(PROC_OPEN_STATUSES) if open_only else (status or None) ) hd_from = ( _parse_iso_date(hire_date_from, field="hire_date_from") if hire_date_from else None ) hd_to = ( _parse_iso_date(hire_date_to, field="hire_date_to") if hire_date_to else None ) rows, total = await OnboardingService(rt.db).list_processes( status_in=status_filter, hire_date_from=hd_from, hire_date_to=hd_to, limit=int(limit), offset=int(offset), ) return { "data": [ { "id": str(r.id), "person_id": str(r.person_id), "hire_date": r.hire_date.isoformat(), "status": r.status, "sla_breach_level": r.sla_breach_level, "backfilled": bool(r.backfilled), } for r in rows ], "meta": {"total": total, "limit": int(limit), "offset": int(offset)}, } @mcp_server.tool( name="get_onboarding_process", description=( "Fetch a single onboarding process with its full blocker list " "(each blocker carries resource_type, severity, resolution, " "triage_bucket, error_count). Mirrors HTTP GET /api/v1/" "onboarding/processes/{id}." ), ) async def get_onboarding_process(process_id: str) -> dict: async with tool_runtime( "get_onboarding_process", {"process_id": process_id} ) as rt: _require_admin_read(rt.user.role_names) from app.domains.onboarding.models import compute_triage_bucket from app.domains.onboarding.service import OnboardingService pid = _u(process_id) proc, blockers = await OnboardingService(rt.db).get_process(pid) return { "data": { "process": { "id": str(proc.id), "person_id": str(proc.person_id), "hire_date": proc.hire_date.isoformat(), "status": proc.status, "sla_breach_level": proc.sla_breach_level, "hardware_allocation": proc.hardware_allocation, "backfilled": bool(proc.backfilled), }, "blockers": [ { "id": str(b.id), "resource_type": b.resource_type, "label": b.label, "severity": b.severity, "resolution": b.resolution, "error_count": int(b.error_count), "triage_bucket": compute_triage_bucket( resolution=b.resolution, severity=b.severity, error_count=int(b.error_count), ), } for b in blockers ], } } @mcp_server.tool( name="get_onboarding_account_timeline", description=( "Chronological account-action timeline for one hire, read from " "audit_logs — answers 'what did Helix actually DO to this person's " "account and did it succeed?'. Surfaces google_workspace.* events " "(account_created / account_linked / account_create_failed with " "the Google error text in detail.reason / reconcile_lookup_failed), " "the welcome/password email outcome (in the create row's detail), " "and onboarding process transitions. Use this to diagnose a hire " "who reads NOT-ready on the cockpit (e.g. no Google account minted) " "— the failure reason is here, not in the green/red chips. Pass " "person_id; optionally process_id to also fold in that process's " "transitions. Read-only (global_admin / auditor / management). " "1:1 with GET /api/v1/onboarding/processes/{id}/account-timeline." ), ) async def get_onboarding_account_timeline( person_id: str, process_id: str | None = None, ) -> dict: async with tool_runtime( "get_onboarding_account_timeline", {"person_id": person_id, "process_id": process_id}, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.onboarding.service import OnboardingService events = await OnboardingService(rt.db).get_account_action_timeline( _u(person_id), process_id=_u(process_id) if process_id else None, ) return {"data": {"person_id": person_id, "events": events}} @mcp_server.tool( name="set_onboarding_hardware_allocation", description=( "Set the per-asset-slot hardware allocation map for an " "onboarding process. ``allocation`` is a JSON object keyed by " "slot label (e.g. 'laptop'), with values " "``{source: office_stock|new_purchase|inherit_from_leaver, " "office_id?, asset_id?, leaver_person_id?, leaver_asset_id?, " "note?}``. Pass ``null`` to clear. Mirrors HTTP PUT /api/v1/" "onboarding/processes/{id}/hardware-allocation." ), ) async def set_onboarding_hardware_allocation( process_id: str, allocation: dict | None = None, ) -> dict: async with tool_runtime( "set_onboarding_hardware_allocation", { "process_id": process_id, "slot_count": len(allocation) if allocation else 0, }, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.service import OnboardingService pid = _u(process_id) try: proc = await OnboardingService(rt.db).set_hardware_allocation( pid, allocation, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(proc.id), "status": proc.status, "hardware_allocation": proc.hardware_allocation, } } @mcp_server.tool( name="advance_onboarding_now", description=( "Force-flip a ``ready`` onboarding process to ``live`` ahead of " "the daily T+0 advance cron — used for emergency Day-1 push " "when ``hire_date`` was corrected to today an hour before the " "cron tick. Refuses if status is not ``ready``. Mirrors HTTP " "POST /api/v1/onboarding/processes/{id}/advance-now." ), ) async def advance_onboarding_now(process_id: str) -> dict: async with tool_runtime( "advance_onboarding_now", {"process_id": process_id}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.service import OnboardingService pid = _u(process_id) try: proc = await OnboardingService(rt.db).advance_now( pid, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(proc.id), "status": proc.status, "live_at": ( proc.live_at.isoformat() if proc.live_at else None ), } } @mcp_server.tool( name="cancel_onboarding", description=( "Cancel an in-flight onboarding process — typically because " "HRIS rescinded the offer. Idempotent on already-terminal " "processes. Mirrors HTTP POST /api/v1/onboarding/processes/" "{id}/cancel." ), ) async def cancel_onboarding( process_id: str, reason: str | None = None ) -> dict: async with tool_runtime( "cancel_onboarding", {"process_id": process_id, "reason": (reason or "")[:120]}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.service import OnboardingService pid = _u(process_id) try: proc = await OnboardingService(rt.db).cancel_process( pid, trace_id=_trace_str(), actor_user_id=rt.user.id, reason=reason, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(proc.id), "status": proc.status, "cancelled_at": ( proc.cancelled_at.isoformat() if proc.cancelled_at else None ), } } @mcp_server.tool( name="skip_onboarding_blocker", description=( "Mark a pending onboarding blocker as ``skipped`` with a free-" "text reason (1–512 chars, required). Used when a dimension " "legitimately does not apply (e.g. contractor without VPN " "access). Idempotent on already-non-pending blockers. Mirrors " "HTTP POST /api/v1/onboarding/blockers/{id}/skip." ), ) async def skip_onboarding_blocker(blocker_id: str, reason: str) -> dict: async with tool_runtime( "skip_onboarding_blocker", {"blocker_id": blocker_id, "reason": (reason or "")[:120]}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.service import OnboardingService bid = _u(blocker_id) try: blocker = await OnboardingService(rt.db).skip_blocker( bid, reason=reason, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(blocker.id), "resource_type": blocker.resource_type, "resolution": blocker.resolution, } } @mcp_server.tool( name="mark_onboarding_blocker_resolved", description=( "Mark a pending onboarding blocker as ``provisioned`` with an " "optional admin note (≤512 chars). Used when IT has manually " "completed a dimension that the scanner can't auto-detect. " "Idempotent on already-non-pending blockers. Mirrors HTTP POST " "/api/v1/onboarding/blockers/{id}/mark-resolved." ), ) async def mark_onboarding_blocker_resolved( blocker_id: str, note: str | None = None ) -> dict: async with tool_runtime( "mark_onboarding_blocker_resolved", {"blocker_id": blocker_id, "has_note": bool(note)}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.service import OnboardingService bid = _u(blocker_id) try: blocker = await OnboardingService(rt.db).mark_blocker_resolved( bid, trace_id=_trace_str(), actor_user_id=rt.user.id, note=note, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(blocker.id), "resource_type": blocker.resource_type, "resolution": blocker.resolution, } } # ────────────────────────────────────────────────────────────────────── # 入职须知 (onboarding acknowledgement items, 2026-06-11). # Admin CRUD on the checklist + the employee self-service confirm that # satisfies the ``training_ack`` readiness dimension. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_onboarding_ack_items", description=( "List the 入职须知 (onboarding acknowledgement) checklist items " "new hires must confirm. Admin-read gated. Pass " "``include_inactive=true`` to also return deactivated items. " "Mirrors HTTP GET /api/v1/onboarding/ack-items. (Employees see " "their own checklist + confirm state inside " "get_my_onboarding_status instead.)" ), ) async def list_onboarding_ack_items(include_inactive: bool = False) -> dict: async with tool_runtime( "list_onboarding_ack_items", {"include_inactive": include_inactive}, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.onboarding.ack_service import ( OnboardingAckService, serialize_ack_item, ) items = await OnboardingAckService(rt.db).list_items( include_inactive=bool(include_inactive), ) return {"data": [serialize_ack_item(i) for i in items]} @mcp_server.tool( name="upsert_onboarding_ack_item", description=( "Create or update a 入职须知 checklist item. Omit ``item_id`` to " "create (title required); pass it to update — only the supplied " "fields change. ``doc_url`` deep-links the policy document; " "``required=false`` items render in the employee checklist but " "don't gate Day-1 readiness; ``is_active=false`` retires an " "item. global_admin only. Mirrors HTTP POST/PUT " "/api/v1/onboarding/ack-items." ), ) async def upsert_onboarding_ack_item( item_id: str | None = None, title: str | None = None, description: str | None = None, doc_url: str | None = None, required: bool | None = None, is_active: bool | None = None, sort_order: int | None = None, ) -> dict: async with tool_runtime( "upsert_onboarding_ack_item", {"item_id": item_id, "title": (title or "")[:120]}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException, ValidationError from app.domains.onboarding.ack_service import ( OnboardingAckService, serialize_ack_item, ) # Mirror the HTTP Pydantic bounds — without them an over-length # value hits the DB column limit as an opaque 500 (TiDB 1406), # the exact failure shape of the idempotency-scope incident. if title is not None and len(title) > 255: raise ValidationError("title must be ≤255 characters") if doc_url is not None and len(doc_url) > 512: raise ValidationError("doc_url must be ≤512 characters") if description is not None and len(description) > 4000: raise ValidationError("description must be ≤4000 characters") svc = OnboardingAckService(rt.db) try: if item_id: updates: dict = {} if title is not None: updates["title"] = title if description is not None: updates["description"] = description if doc_url is not None: updates["doc_url"] = doc_url if required is not None: updates["required"] = bool(required) if is_active is not None: updates["is_active"] = bool(is_active) if sort_order is not None: updates["sort_order"] = max(0, min(10000, int(sort_order))) item = await svc.update_item( _u(item_id), updates=updates, trace_id=_trace_str(), actor_user_id=rt.user.id, ) else: if not (title or "").strip(): raise AppException( code="ACK_ITEM_TITLE_REQUIRED", message="title is required when creating an item", status_code=422, ) item = await svc.create_item( title=title or "", description=description, doc_url=doc_url, required=bool(required) if required is not None else True, sort_order=( max(0, min(10000, int(sort_order))) if sort_order is not None else 0 ), trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: await _safe_rollback(rt.db) raise return {"data": serialize_ack_item(item)} @mcp_server.tool( name="delete_onboarding_ack_item", description=( "Hard-delete a 入职须知 checklist item that has NO employee " "acknowledgements yet. Refuses (409) once any hire has " "confirmed it — those rows are compliance evidence; deactivate " "via upsert_onboarding_ack_item(is_active=false) instead. " "global_admin only. Mirrors HTTP DELETE " "/api/v1/onboarding/ack-items/{id}." ), ) async def delete_onboarding_ack_item(item_id: str) -> dict: async with tool_runtime( "delete_onboarding_ack_item", {"item_id": item_id}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.ack_service import OnboardingAckService try: await OnboardingAckService(rt.db).delete_item( _u(item_id), trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: await _safe_rollback(rt.db) raise return {"data": {"deleted": True}} @mcp_server.tool( name="acknowledge_onboarding_item", description=( "Confirm a 入职须知 checklist item as the calling user (self-" "scoped, no admin role needed). Idempotent — re-confirming " "returns the original confirmation timestamp. Resolves the " "caller's ``training_ack`` Day-1 readiness blocker immediately. " "Mirrors HTTP POST /api/v1/me/onboarding/acknowledgements/{id}." ), ) async def acknowledge_onboarding_item(item_id: str) -> dict: async with tool_runtime( "acknowledge_onboarding_item", {"item_id": item_id}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.onboarding.ack_service import OnboardingAckService try: ack = await OnboardingAckService(rt.db).acknowledge( rt.user.id, _u(item_id), source="mcp", trace_id=_trace_str(), ) except AppException: await _safe_rollback(rt.db) raise return { "data": { "item_id": str(ack.item_id), "acknowledged_at": ( ack.acknowledged_at.isoformat() if ack.acknowledged_at else None ), } } @mcp_server.tool( name="onboarding_day1_readiness_analytics", description=( "Per-month Day-1 readiness rate — the share of hires whose " "every BLOCKER-severity onboarding dimension was in place by " "their start date (hard_ready_at <= hire_date, Asia/Shanghai; " "advisory dims never gate the metric) — plus the current " "count of past-due hires still carrying BLOCKER-severity gaps. " "Backfilled / cancelled processes excluded. ``months`` 1–24, " "default 6. Admin-read gated. Mirrors HTTP GET " "/api/v1/onboarding/analytics/day1-readiness." ), ) async def onboarding_day1_readiness_analytics(months: int = 6) -> dict: async with tool_runtime( "onboarding_day1_readiness_analytics", {"months": months}, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.onboarding.analytics_service import ( OnboardingAnalyticsService, ) payload = await OnboardingAnalyticsService(rt.db).day1_readiness( months=max(1, min(24, int(months))), ) return {"data": payload} @mcp_server.tool( name="list_onboarding_hardware_gaps", description=( "The Day-1 hardware worklist: hires whose start date has PASSED " "and whose computer is still not on the Helix asset ledger. " "Includes ``completed`` onboarding processes — the cockpit only " "shows open ones, so a completed-with-gap hire appears on no " "other board. Each row carries days_overdue, the hire's work " "location, ``hardware_state`` (``missing`` = no device at all; " "``reserved`` = a device is earmarked but the day-1 claim never " "landed), and ``mdm_hint`` — the Jamf/Intune serial + model " "matching that person, which is the evidence for WHICH machine " "to register. A null mdm_hint means no fleet record, not " "'no device'. Read-only; close a row with " "``register_onboarding_hire_device`` (the machine exists) or " "``skip_onboarding_blocker`` (this hire needs no company " "computer). Mirrors HTTP GET " "/api/v1/onboarding/hardware-gaps." ), ) async def list_onboarding_hardware_gaps(lookback_days: int = 400) -> dict: async with tool_runtime( "list_onboarding_hardware_gaps", {"lookback_days": lookback_days}, ) as rt: # Must match the HTTP twin's ``require_lifecycle_read()`` = # global_admin + hr + auditor + management. _require_hr_read(rt.user.role_names) from app.domains.onboarding.hardware_gap_service import ( OnboardingHardwareGapService, ) payload = await OnboardingHardwareGapService(rt.db).list_gaps( lookback_days=max(1, min(1200, int(lookback_days))), ) return {"data": payload} @mcp_server.tool( name="register_onboarding_hire_device", description=( "Close one Day-1 hardware gap: create the missing asset row, " "claim it to the hire, and let the onboarding scanner mark the " "hardware dimension satisfied — one call, one transaction. Use " "for a machine that physically exists but was never entered " "(overseas self-purchase / direct ship). ``category`` must be a " "computer (laptop / desktop / computer): a monitor would create " "a ledger row and leave the blocker pending. Refuses with 409 " "when the hire already holds a computer (nothing to register) " "or has departed (use the leaver recovery flow). Prefer the " "serial from ``list_onboarding_hardware_gaps``'s mdm_hint over " "guessing. If the hire needs no company device, use " "``skip_onboarding_blocker`` instead — do NOT invent an asset " "row to clear the queue. Mirrors HTTP POST " "/api/v1/onboarding/hardware-gaps/{process_id}/register-device." ), ) async def register_onboarding_hire_device( process_id: str, serial_number: str | None = None, category: str = "laptop", brand: str | None = None, model: str | None = None, asset_tag: str | None = None, purchase_date: str | None = None, purchase_cost: str | None = None, currency: str | None = None, purchase_entity: str | None = None, invoice_number: str | None = None, location: str | None = None, claim_date: str | None = None, notes: str | None = None, ) -> dict: async with tool_runtime( "register_onboarding_hire_device", { "process_id": process_id, "serial_number": serial_number, "category": category, }, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.onboarding.hardware_gap_service import ( OnboardingHardwareGapService, ) from app.domains.onboarding.router import RegisterHireDeviceBody try: # Validate through the HTTP twin's own body model so the two # surfaces cannot drift on field limits or the # computer-category rule. body = RegisterHireDeviceBody( category=category, asset_tag=asset_tag, brand=brand, model=model, serial_number=serial_number, purchase_date=( _parse_iso_date(purchase_date, field="purchase_date") if purchase_date else None ), purchase_cost=( _money_or_none(purchase_cost, field="purchase_cost") ), currency=currency, purchase_entity=purchase_entity, invoice_number=invoice_number, location=location, claim_date=( _parse_iso_date(claim_date, field="claim_date") if claim_date else None ), notes=notes, ) result = await OnboardingHardwareGapService(rt.db).register_device( _u(process_id), payload=body, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: await _safe_rollback(rt.db) raise return {"data": result} # ────────────────────────────────────────────────────────────────────── # 备机闭环 (hardware prep for incoming hires, 2026-06-12). # Asset-admin worklist + device reservation, mirroring # /api/v1/assets/hardware-prep*. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_hardware_prep_queue", description=( "待备机 worklist for incoming hires: every open onboarding " "process inside the window (default 14 days, past-due hires " "always included) that still needs hardware prep or carries a " "device reservation. Rows include the manager's declared " "hardware_allocation, the reserved device (if any) and SLA " "level; KPIs include in-stock computer count and stale " "reservations to release. Asset/global admin only. Mirrors " "HTTP GET /api/v1/assets/hardware-prep." ), ) async def list_hardware_prep_queue(window_days: int = 14) -> dict: async with tool_runtime( "list_hardware_prep_queue", {"window_days": window_days}, ) as rt: _require_asset_admin(rt) from app.domains.asset.hardware_prep import HardwarePrepService payload = await HardwarePrepService(rt.db).build_queue( window_days=max(1, min(60, int(window_days))), ) return {"data": payload} @mcp_server.tool( name="list_long_idle_computers", description=( "长期闲置电脑 — the list behind the 长期闲置电脑盈余率 health " "metric, with the SAME maths, so the two never disagree: " "computer-category assets (laptop/desktop/computer only — " "monitors, printers and office network gear have no circulation " "and are excluded) sitting in stock, not earmarked for an " "incoming hire, whose LAST REAL CUSTODY EVENT (claim / return / " "office transfer, falling back to intake) is over 90 days old. " "⚠️ Answering a stocktake does NOT reset that clock. Near-term " "Day-1 hardware demand is SUBTRACTED: rows with " "``is_surplus=false`` are long-idle but held back for hires who " "need a machine, so recommending their disposal is wrong. " "``never_moved_in_helix=true`` means Helix has no custody event " "at all (the 2026-04 legacy import), so the idle clock starts at " "import day, not when the device actually stopped being used — " "conservative, not exact. ``never_inventoried=true`` means no " "stocktake has ever covered it, i.e. nobody has confirmed the " "device physically exists. Region-scoped like every asset " "surface. Read-only: act through the existing reserve / dispose " "/ stocktake flows. Mirrors HTTP GET " "/api/v1/assets/idle-computers." ), ) async def list_long_idle_computers( location: list[str] | None = None, purchase_year: list[int] | None = None, never_inventoried: bool | None = None, min_idle_days: int | None = None, ) -> dict: async with tool_runtime( "list_long_idle_computers", { "location": location, "purchase_year": purchase_year, "never_inventoried": never_inventoried, "min_idle_days": min_idle_days, }, ) as rt: _require_asset_admin(rt) from app.domains.asset.idle_computers import ( IdleComputerFilters, IdleComputerService, ) payload = await IdleComputerService(rt.db).build( actor_person_id=rt.user.id, role_names=list(rt.user.role_names or []), filters=IdleComputerFilters( location=tuple(location or ()), purchase_year=tuple(int(y) for y in (purchase_year or ())), never_inventoried=never_inventoried, min_idle_days=( max(0, min(3650, int(min_idle_days))) if min_idle_days is not None else None ), ), ) return {"data": payload} @mcp_server.tool( name="list_leaver_held_assets", description=( "离职未归还设备 — devices still out with departed employees, with " "a clock and an owner on each row. ``days_held`` counts from the " "leaver's HRIS termination date where available, NOT from when " "IT got round to marking the asset: 'how long has the company " "been without this laptop' is the question worth asking. Each " "row reports ``clock_source`` (``termination_date`` = " "authoritative; ``marked_at`` / ``row_updated_at`` = fallbacks) " "— a bucket built on a fallback deserves less confidence, so do " "not present those days_held figures as precise. Ageing buckets " "are under_30 / d30_59 / d60_89 / d90_plus: a device two weeks " "out is normal offboarding latency, one 90 days out is lost or " "quietly written off. ``responsible_admin_ids`` is the office " "admin(s) for the device's location, derived not stored; " "``summary.unassignable`` counts rows whose location does not " "resolve, i.e. nobody is accountable for them. Region-scoped. " "Read-only. Mirrors HTTP GET /api/v1/assets/leaver-held." ), ) async def list_leaver_held_assets(bucket: str | None = None) -> dict: async with tool_runtime( "list_leaver_held_assets", {"bucket": bucket}, ) as rt: _require_asset_admin(rt) from app.domains.asset.leaver_recovery import ( LeaverAssetRecoveryService, ) payload = await LeaverAssetRecoveryService(rt.db).build( actor_person_id=rt.user.id, role_names=list(rt.user.role_names or []), bucket=bucket, ) return {"data": payload} @mcp_server.tool( name="list_asset_cost_reconcile_runs", description=( "财务固定资产台账对账 runs, newest first: each upload of the " "finance fixed-asset register with its match counts " "(matched / conflict / unmatched) and status " "(previewing = uploaded but nothing written yet; applied; " "discarded). Use this to answer 'has anyone reconciled the " "register lately, and what did it disagree about'. Read-only; " "drill into one with ``get_asset_cost_reconcile_run``. " "Global-admin only, matching the HTTP surface. Mirrors HTTP GET " "/api/v1/assets/cost-reconcile." ), ) async def list_asset_cost_reconcile_runs(limit: int = 50) -> dict: async with tool_runtime( "list_asset_cost_reconcile_runs", {"limit": limit}, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.asset.cost_reconcile import ( AssetCostReconcileService, ) runs = await AssetCostReconcileService(rt.db).list_runs( limit=max(1, min(200, int(limit))), ) return {"data": {"items": runs}} @mcp_server.tool( name="get_asset_cost_reconcile_run", description=( "One 财务固定资产台账对账 run in detail — the per-line match " "verdicts. ``match_status`` values and what each MEANS: " "``matched`` = the register line maps to one asset that has no " "amount, or whose amount is an estimate the register supersedes; " "``conflict`` = the ledger already RECORDS a different amount, " "i.e. two books disagree and a human should look (never " "overwritten without an explicit flag); ``unmatched`` = the " "register line matches NO Helix asset, meaning finance is " "depreciating a device the IT ledger does not have — this is the " "inverse of the completeness gap and is invisible from every " "other surface; ``ambiguous`` = the serial matches more than one " "asset (never applied — picking one would write a money value " "onto an arbitrary row); ``invalid`` = the register line itself " "is unusable (no serial, unparseable amount, or an amount with " "no currency). ``breakdown`` always covers the whole run even " "when ``match_status`` filters the rows. Read-only. Global-admin " "only. Mirrors HTTP GET " "/api/v1/assets/cost-reconcile/{run_id}." ), ) async def get_asset_cost_reconcile_run( run_id: str, match_status: str | None = None, limit: int = 200, offset: int = 0, ) -> dict: async with tool_runtime( "get_asset_cost_reconcile_run", { "run_id": run_id, "match_status": match_status, "limit": limit, "offset": offset, }, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.asset.cost_reconcile import ( AssetCostReconcileService, ) payload = await AssetCostReconcileService(rt.db).get_run( _u(run_id), match_status=match_status, limit=max(1, min(2000, int(limit))), offset=max(0, int(offset)), ) return {"data": payload} @mcp_server.tool( name="list_hardware_prep_candidates", description=( "Reservable devices for one incoming hire's onboarding process " "— in-stock computers (idle / available / held_by_office / " "held_by_leaver) not earmarked for anyone else, with optional " "keyword filter (tag / name / model / brand / serial). Use " "before reserve_asset_for_hire. ``limit`` 1–200, default 50. " "Asset/global admin only. Mirrors HTTP GET " "/api/v1/assets/hardware-prep/{process_id}/candidates." ), ) async def list_hardware_prep_candidates( process_id: str, q: str | None = None, limit: int = 50, ) -> dict: async with tool_runtime( "list_hardware_prep_candidates", {"process_id": process_id, "q": (q or "")[:64], "limit": limit}, ) as rt: _require_asset_admin(rt) from app.domains.asset.hardware_prep import HardwarePrepService items = await HardwarePrepService(rt.db).list_candidates( _u(process_id), q=(q or "").strip()[:128] or None, limit=max(1, min(200, int(limit))), ) return {"data": {"items": items}} @mcp_server.tool( name="reserve_asset_for_hire", description=( "Earmark an in-stock device (idle / available / held_by_office " "/ held_by_leaver computer) for an incoming hire's onboarding " "process. The reservation blocks assigning/claiming/lending the " "device to anyone else and auto-clears when it is assigned to " "the hire; re-reserving releases the hire's prior earmark. " "Asset/global admin only (region-scoped). Mirrors HTTP POST " "/api/v1/assets/hardware-prep/{process_id}/reserve." ), ) async def reserve_asset_for_hire(process_id: str, asset_id: str) -> dict: async with tool_runtime( "reserve_asset_for_hire", {"process_id": process_id, "asset_id": asset_id}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) from app.core.exceptions import AppException from app.domains.asset.hardware_prep import ( HardwarePrepService, _serialize_asset_brief, ) try: asset = await HardwarePrepService(rt.db).reserve( _u(process_id), _u(asset_id), trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: await _safe_rollback(rt.db) raise return {"data": _serialize_asset_brief(asset)} @mcp_server.tool( name="release_asset_reservation", description=( "Release a device's incoming-hire reservation (optional " "reason). Idempotent — releasing an unreserved device is a " "no-op. Asset/global admin only (region-scoped). Mirrors HTTP " "POST /api/v1/assets/hardware-prep/release/{asset_id}." ), ) async def release_asset_reservation( asset_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "release_asset_reservation", {"asset_id": asset_id, "has_reason": bool(reason)}, mutates=True, ) as rt: _require_asset_admin(rt) await _mcp_enforce_asset_region(rt, asset_id) from app.core.exceptions import AppException from app.domains.asset.hardware_prep import ( HardwarePrepService, _serialize_asset_brief, ) try: asset = await HardwarePrepService(rt.db).release( _u(asset_id), trace_id=_trace_str(), actor_user_id=rt.user.id, reason=reason, ) except AppException: await _safe_rollback(rt.db) raise return {"data": _serialize_asset_brief(asset)} # ────────────────────────────────────────────────────────────────────── # Provisioning bundles (PR-2 of symmetric-onboarding, 2026-06-01). # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_provisioning_bundles", description=( "List provisioning bundles. Each row carries id / name / " "description / target_role_hint / is_active / member_count. " "Pass ``active_only=true`` to scope to is_active=1. Mirrors " "HTTP GET /api/v1/provisioning/bundles." ), ) async def list_provisioning_bundles(active_only: bool = False) -> dict: async with tool_runtime( "list_provisioning_bundles", {"active_only": active_only}, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.provisioning.bundle_service import BundleService rows = await BundleService(rt.db).list_bundles( active_only=bool(active_only), ) return { "data": [ { "id": str(b.id), "name": b.name, "description": b.description, "target_role_hint": b.target_role_hint, "is_active": bool(b.is_active), "member_count": int(c), } for b, c in rows ] } @mcp_server.tool( name="get_provisioning_bundle", description=( "Fetch a single bundle with its full policy member list (each " "policy carries target_type / target_id / department_name / " "match_job_title / match_location / is_active). Mirrors HTTP " "GET /api/v1/provisioning/bundles/{id}." ), ) async def get_provisioning_bundle(bundle_id: str) -> dict: async with tool_runtime( "get_provisioning_bundle", {"bundle_id": bundle_id}, ) as rt: _require_admin_read(rt.user.role_names) from app.domains.provisioning.bundle_service import BundleService bid = _u(bundle_id) bundle, policies = await BundleService(rt.db).get_bundle(bid) return { "data": { "bundle": { "id": str(bundle.id), "name": bundle.name, "description": bundle.description, "target_role_hint": bundle.target_role_hint, "is_active": bool(bundle.is_active), "member_count": len(policies), }, "policies": [ { "id": str(p.id), "department_name": p.department_name, "match_job_title": p.match_job_title, "match_location": p.match_location, "target_type": p.target_type, "target_id": str(p.target_id), "is_active": bool(p.is_active), } for p in policies ], } } @mcp_server.tool( name="create_provisioning_bundle", description=( "Create a new provisioning bundle. ``name`` is required and " "must be unique (1–128 chars). Members are bound separately " "via the HTTP membership endpoints; the freshly-created " "bundle starts with zero members. Mirrors HTTP POST /api/v1/" "provisioning/bundles." ), ) async def create_provisioning_bundle( name: str, description: str | None = None, target_role_hint: str | None = None, ) -> dict: async with tool_runtime( "create_provisioning_bundle", {"name": (name or "")[:60]}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.provisioning.bundle_service import BundleService try: bundle = await BundleService(rt.db).create_bundle( name=name, description=description, target_role_hint=target_role_hint, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(bundle.id), "name": bundle.name, "is_active": bool(bundle.is_active), } } @mcp_server.tool( name="apply_provisioning_bundle_to_person", description=( "Apply a bundle to a single person — creates a pending " "ProvisioningTask for every active policy in the bundle that " "the person doesn't already have. Used by AI clients to fan-" "out a 'SDE Standard Pack' to an in-flight onboarding process " "in one call. Returns ``created_task_ids`` + ``skipped`` " "counts. Mirrors HTTP POST /api/v1/provisioning/bundles/{id}" "/apply-to-person." ), ) async def apply_provisioning_bundle_to_person( bundle_id: str, person_id: str, ) -> dict: async with tool_runtime( "apply_provisioning_bundle_to_person", {"bundle_id": bundle_id, "person_id": person_id}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import AppException from app.domains.provisioning.bundle_service import BundleService bid = _u(bundle_id) pid = _u(person_id) try: result = await BundleService(rt.db).apply_to_person( bid, pid, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return {"data": result} # ────────────────────────────────────────────────────────────────────── # Manager team-joiners (PR-3 of symmetric-onboarding, 2026-06-01). # Mirrors /api/v1/my/team-joiners/*. Caller MUST be the joiner's direct # or skip-level manager — gated by the same _viewer_can_manage_process # check used by the HTTP route. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_my_team_joiners", description=( "List incoming hires the caller manages (direct + skip-level). " "Read-only — mirrors HTTP GET /api/v1/my/team-joiners. Returns " "page of items with hire info / status / SLA / " "hardware_allocation / pending_blocker_count." ), ) async def list_my_team_joiners( page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_my_team_joiners", {"page": page, "page_size": page_size}, ) as rt: # No admin gate — this is intentionally a self-scoped call; # the service-layer query filters to people who manage the # caller. Any authenticated user can call it for themselves. from app.domains.onboarding.service import OnboardingService # page=0 would compute a negative OFFSET (SQL error); the HTTP # twin caps page_size at le=200. page, page_size = _clamp_pagination(page, page_size, max_size=200) items, total = await OnboardingService(rt.db).list_my_team_joiners( rt.user.id, page=int(page), page_size=int(page_size), ) return { "data": items, "meta": { "total": int(total), "page": int(page), "page_size": int(page_size), }, } @mcp_server.tool( name="set_my_team_joiner_hardware_allocation", description=( "Set the per-asset-slot hardware allocation JSON map for an " "incoming hire the caller manages. Caller MUST be the hire's " "direct or skip-level manager. ``allocation`` shape (each key " "is a free-text slot label like 'laptop'): " "``{source: office_stock|new_purchase|inherit_from_leaver, " "office_id?, asset_id?, leaver_person_id?, leaver_asset_id?, " "note?}``. Pass null to clear. Mirrors HTTP PUT /api/v1/my/" "team-joiners/{id}/hardware-allocation." ), ) async def set_my_team_joiner_hardware_allocation( process_id: str, allocation: dict | None = None, ) -> dict: async with tool_runtime( "set_my_team_joiner_hardware_allocation", { "process_id": process_id, "slot_count": len(allocation) if allocation else 0, }, mutates=True, ) as rt: from app.core.exceptions import AppException from app.domains.onboarding.service import OnboardingService pid = _u(process_id) svc = OnboardingService(rt.db) proc = await svc._get_or_raise(pid) if not await svc._viewer_can_manage_process(rt.user.id, proc): await _safe_rollback(rt.db) raise AppException( code="NOT_MANAGER_OF_HIRE", message=( "caller is not the manager (direct or skip-level) of " "this hire" ), status_code=403, ) try: proc = await svc.set_hardware_allocation( pid, allocation, trace_id=_trace_str(), actor_user_id=rt.user.id, ) except AppException: # Typed business errors (404/409/422…) must propagate 1:1 — # str(AppException) was empty pre-2026-06-10 (and the typed # code/status would still be lost today), so the previous # ``raise ValueError(str(exc))`` flattened them into blank # INTERNAL_ERROR 500s. Anything else falls through to the # runtime catch-all (rollback + 500 + full trace logged). # (Launch audit 2026-06-10.) await _safe_rollback(rt.db) raise return { "data": { "id": str(proc.id), "hardware_allocation": proc.hardware_allocation, } } # ────────────────────────────────────────────────────────────────────── # New-hire self-service (PR-4 of symmetric-onboarding, 2026-06-01). # Caller reads their OWN onboarding process + readiness. Used by AI # clients embedded in the new-hire portal to summarise Day-1 state. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="get_my_onboarding_status", description=( "Return the caller's own onboarding process + blockers + 10-dim " "Day-1 readiness summary + the 入职须知 acknowledgement " "checklist (``acknowledgements``; confirm items via " "acknowledge_onboarding_item). Self-scoped — no admin role " "needed. Mirrors HTTP GET /api/v1/me/onboarding. Raises " "NO_OPEN_ONBOARDING_PROCESS if the caller has no open process " "(past the 60-day window or never onboarded through Helix)." ), ) async def get_my_onboarding_status() -> dict: async with tool_runtime( "get_my_onboarding_status", {}, ) as rt: from app.domains.onboarding.me_router import _build_self_payload payload = await _build_self_payload(rt.user.id, rt.db) return {"data": payload} # ────────────────────────────────────────────────────────────────────── # Access review (PR-5 of symmetric-onboarding, 2026-06-01). # Per-row reviewer assignment + per-row deadline; self-scoped pending # list. Pairs with auditor.privileged_review_escalate cron (14-day # past-deadline → compliance_incident). # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_my_pending_access_reviews", description=( "List access-certification items assigned to the caller as " "reviewer that are still pending. Self-scoped — no admin role " "needed. Returns id / access_label / holder_email / " "decided_at_deadline so AI clients can summarise the caller's " "open review backlog." ), ) async def list_my_pending_access_reviews() -> dict: from sqlalchemy import select from app.domains.auditor.models import AccessCertificationItem async with tool_runtime( "list_my_pending_access_reviews", {}, ) as rt: rows = ( ( await rt.db.execute( select(AccessCertificationItem).where( AccessCertificationItem.assigned_reviewer_person_id == rt.user.id, AccessCertificationItem.decision == "pending", ) ) ) .scalars() .all() ) return { "data": [ { "id": str(r.id), "certification_id": str(r.certification_id), "access_type": r.access_type, "access_label": r.access_label, "holder_email": r.holder_email, "holder_display_name": r.holder_display_name, "decided_at_deadline": ( r.decided_at_deadline.isoformat() if r.decided_at_deadline else None ), "escalated_at": ( r.escalated_at.isoformat() if r.escalated_at else None ), } for r in rows ] } # ────────────────────────────────────────────────────────────────────── # Feed domain (动态广场) — migration 235. # 10 tools: list / post / react / comment / send blessing / search / # today's celebrations / announcements / preferences get & update. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="feed_list_posts", description=( "List feed posts (动态广场 stream). `tab` ∈ " "{mine, department, public} controls which slice. `category` " "optionally narrows to general / work_plan / other. Returns " "`items[]` + `next_cursor` for cursor-based pagination — pass " "the cursor back as `cursor` to fetch the next page. Each item " "carries author, body, attachment URLs, like/comment counts, " "`liked_by_me`, and (for system posts) the celebrant snapshot." ), ) async def feed_list_posts( tab: str = "public", category: str | None = None, cursor: str | None = None, limit: int = 20, ) -> dict: async with tool_runtime( "feed_list_posts", {"tab": tab, "category": category, "limit": limit}, ) as rt: from app.domains.feed.service import FeedService svc = FeedService(rt.db) posts, next_cursor = await svc.list_posts( viewer=rt.user, tab=tab, category=category, cursor=cursor, limit=max(1, min(int(limit), 50)), ) items = await svc.hydrate_posts(posts, viewer=rt.user) return serialize({"items": items, "next_cursor": next_cursor}) @mcp_server.tool( name="feed_create_post", description=( "Publish a new user post. `visibility` ∈ {public, department} " "(default public). `category` ∈ {general, work_plan, other}. " "`mentioned_person_ids` lets you @ teammates; they get an " "in-app + Feishu notification. `attachments` is a list of " "`{s3_key, mime_type, width, height}` rows where each `s3_key` " "came from `presign_upload_put`. Max 9 attachments, max 20 " "mentions. `client_token` is an idempotency key unique to you: " "pass the SAME value when retrying a call whose result you did " "not see, and the original post is returned instead of a " "duplicate being published (`replayed: true` in the result). " "Use a fresh value for a genuinely new post." ), ) async def feed_create_post( body: str, visibility: str = "public", category: str = "general", mentioned_person_ids: list[str] | None = None, attachments: list[dict] | None = None, client_token: str | None = None, ) -> dict: async with tool_runtime( "feed_create_post", { "visibility": visibility, "category": category, "mentions": len(mentioned_person_ids or []), "attachments": len(attachments or []), }, mutates=True, ) as rt: from app.domains.feed import notifications as fn from app.domains.feed.service import FeedService # Bounded HERE too. The HTTP schema caps this at the column width and # answers 422; the MCP tool had no bound at all, so an oversized token # reached the INSERT and raised ``DataError`` — which the caller's # ``except IntegrityError`` does not catch, so it escaped the savepoint # as a 500 instead of a clean rejection. if client_token is not None and len(client_token) > 128: raise ValueError( "client_token must be at most 128 characters " f"(got {len(client_token)})" ) svc = FeedService(rt.db) post, created = await svc.create_user_post( author=rt.user, body=body, visibility=visibility, category=category, mentioned_person_ids=mentioned_person_ids or [], attachments=attachments or [], client_token=client_token, ) # An agent retry loop must not push a second round of @-mentions to # everyone named in the post — the replay is not a new publish. if created: await fn.notify_mentions_on_post( rt.db, post=post, mentioned_person_ids=mentioned_person_ids or [], ) [dto] = await svc.hydrate_posts([post], viewer=rt.user) return serialize({**dto, "replayed": not created}) @mcp_server.tool( name="feed_react_post", description=( "Like or unlike a post. `liked` controls direction (True = " "like, False = unlike). Idempotent — a duplicate like is a " "no-op. Returns the new `like_count` + `liked_by_me`." ), ) async def feed_react_post(post_id: str, liked: bool = True) -> dict: async with tool_runtime( "feed_react_post", {"post_id": post_id, "liked": liked}, mutates=True, ) as rt: from app.domains.feed import notifications as fn from app.domains.feed.service import FeedService svc = FeedService(rt.db) n, changed = await svc.toggle_like( post_id=post_id, person=rt.user, like=liked, ) # Only notify when the like row was actually inserted — a # repeated like (LLM retry loop) must not spam the author. if liked and changed: await fn.notify_like_added(rt.db, post_id=post_id, liker=rt.user) return {"like_count": n, "liked_by_me": bool(liked)} @mcp_server.tool( name="feed_add_comment", description=( "Add a comment on a post. `mentioned_person_ids` triggers " "in-app + Feishu notifications. `reply_to_comment_id` sets " "this comment as a reply to another (purely a display hint — " "the comment list stays flat)." ), ) async def feed_add_comment( post_id: str, body: str, mentioned_person_ids: list[str] | None = None, reply_to_comment_id: str | None = None, ) -> dict: async with tool_runtime( "feed_add_comment", {"post_id": post_id, "mentions": len(mentioned_person_ids or [])}, mutates=True, ) as rt: from app.domains.feed import notifications as fn from app.domains.feed.service import FeedService svc = FeedService(rt.db) comment = await svc.add_comment( post_id=post_id, author=rt.user, body=body, mentioned_person_ids=mentioned_person_ids or [], reply_to_comment_id=reply_to_comment_id, ) await fn.notify_comment_added(rt.db, comment=comment) await fn.notify_mentions_on_comment( rt.db, comment=comment, mentioned_person_ids=mentioned_person_ids or [], ) return { "id": comment.id, "post_id": comment.post_id, "created_at": comment.created_at.isoformat(), } @mcp_server.tool( name="feed_send_blessing", description=( "Send a birthday / anniversary blessing on a celebration. " "`sticker_key` is one of the platform's emoji sticker keys " "(e.g. cake, gift, balloon, trophy, medal — see the " "celebration detail page for the current set). `message` is " "the free-text greeting (≤ 200 chars). Idempotent per " "(celebration, sender) — a duplicate send returns 409." ), ) async def feed_send_blessing( celebration_id: str, sticker_key: str, message: str, ) -> dict: async with tool_runtime( "feed_send_blessing", {"celebration_id": celebration_id, "sticker_key": sticker_key}, mutates=True, ) as rt: from app.domains.feed import notifications as fn from app.domains.feed.service import FeedService svc = FeedService(rt.db) blessing = await svc.send_blessing( celebration_id=celebration_id, sender=rt.user, sticker_key=sticker_key, message=message, ) await fn.notify_blessing_received(rt.db, blessing_id=blessing.id) return { "id": blessing.id, "celebration_id": celebration_id, "created_at": blessing.created_at.isoformat(), } @mcp_server.tool( name="feed_ack_blessing", description=( "Acknowledge ONE blessing you received on your own celebration " "(the 'thanks' tap on a single blessing card). Only the person " "the celebration is about may call this — anyone else gets 403. " "Idempotent: re-acking returns success with `changed=false`, " "never a conflict. 1:1 with POST /api/v1/feed/celebrations/" "{id}/blessings/{blessing_id}/ack." ), ) async def feed_ack_blessing(celebration_id: str, blessing_id: str) -> dict: async with tool_runtime( "feed_ack_blessing", {"celebration_id": celebration_id, "blessing_id": blessing_id}, mutates=True, ) as rt: from app.domains.feed.service import FeedService svc = FeedService(rt.db) blessing, changed = await svc.ack_blessing( celebration_id=celebration_id, blessing_id=blessing_id, viewer=rt.user, ) await rt.db.commit() return { "id": blessing.id, "celebration_id": celebration_id, "acked_at": ( blessing.acked_at.isoformat() if blessing.acked_at is not None else None ), "changed": changed, } @mcp_server.tool( name="feed_thank_blessings", description=( "Thank everyone who blessed your celebration in one go: marks " "every blessing acknowledged and publishes one public feed post " "with `body` as your own words, @-mentioning each well-wisher so " "they are notified through the normal mention channel. Only the " "person the celebration is about may call this, and only ONCE per " "celebration — a replay returns 409 CELEBRATION_ALREADY_THANKED " "and publishes nothing; 409 CELEBRATION_NO_BLESSINGS when nobody " "has written yet. 1:1 with POST /api/v1/feed/celebrations/" "{id}/thanks." ), ) async def feed_thank_blessings(celebration_id: str, body: str) -> dict: async with tool_runtime( "feed_thank_blessings", {"celebration_id": celebration_id, "body_len": len(body)}, mutates=True, ) as rt: from app.domains.feed import notifications as fn from app.domains.feed.service import FeedService svc = FeedService(rt.db) post, acked_count, mentioned_ids = await svc.thank_all_blessings( celebration_id=celebration_id, viewer=rt.user, body=body, ) await rt.db.commit() await fn.notify_mentions_on_post( rt.db, post=post, mentioned_person_ids=mentioned_ids, ) return { "post_id": post.id, "celebration_id": celebration_id, "acked_count": acked_count, "thanked_person_ids": mentioned_ids, } @mcp_server.tool( name="feed_search_posts", description=( "LIKE search across post bodies the caller can see " "(public + their own + department-scoped to their primary " "department). Returns up to 30 most-recent matches." ), ) async def feed_search_posts(query: str, limit: int = 30) -> dict: async with tool_runtime( "feed_search_posts", {"query_len": len(query), "limit": limit}, ) as rt: from app.domains.feed.service import FeedService svc = FeedService(rt.db) rows = await svc.search_posts( viewer=rt.user, query=query, limit=max(1, min(int(limit), 50)), ) items = await svc.hydrate_posts(rows, viewer=rt.user) return serialize({"items": items}) @mcp_server.tool( name="feed_today_celebrations", description=( "List today's birthday and anniversary celebrants. Useful for " "a daily 'who should I greet today' prompt. Each celebrant has " "`celebration_id` you can pass to `feed_send_blessing`." ), ) async def feed_today_celebrations() -> dict: async with tool_runtime("feed_today_celebrations", {}) as rt: from datetime import datetime as _dt from zoneinfo import ZoneInfo from app.domains.feed.service import FeedService svc = FeedService(rt.db) today = _dt.now(ZoneInfo("Asia/Shanghai")).date() birthdays, anniversaries = await svc.get_today_celebrations( today=today, ) def lite(c): return { "person_id": str(c.person_id), "celebration_id": c.id, "display_name": c.display_name_snapshot, "department": c.department_snapshot, "avatar_url": c.avatar_url_snapshot, "years": c.years, } return { "occasion_date": today.isoformat(), "birthdays": [lite(c) for c in birthdays], "anniversaries": [lite(c) for c in anniversaries], } @mcp_server.tool( name="feed_list_announcements", description=( "List currently-active company announcements (the right-rail " "公告 list). Honors publish_at / expire_at gates. Returns " "title, markdown body, pinning, color, category, author." ), ) async def feed_list_announcements() -> dict: async with tool_runtime("feed_list_announcements", {}) as rt: from datetime import UTC as _UTC from datetime import datetime as _dt from app.domains.feed.service import FeedService svc = FeedService(rt.db) rows = await svc.list_active_announcements( now=_dt.now(_UTC), limit=8, ) author_ids = {str(a.author_person_id) for a in rows} persons = await svc._hydrate_persons(author_ids) return serialize({ "items": [ { "id": a.id, "title": a.title, "body_markdown": a.body_markdown, "category": a.category, "color": a.color, "is_pinned": a.is_pinned, "publish_at": ( a.publish_at.isoformat() if a.publish_at else None ), "expire_at": ( a.expire_at.isoformat() if a.expire_at else None ), "author_display_name": ( persons.get(str(a.author_person_id)).display_name if persons.get(str(a.author_person_id)) else None ), "created_at": a.created_at.isoformat(), } for a in rows ], }) @mcp_server.tool( name="search_policy_notices", description=( "Search the company policy library (制度中心) — the published " "制度/手册 entries every employee can browse. Optional `query` " "matches (AND over whitespace-split tokens, case-insensitive) " "against the bilingual title, summary, and keyword tags; omit it " "to list everything published. Each result has the bilingual " "title, summary, keyword tags, and `feishu_url` (the full doc). " "1:1 mirror of GET /api/v1/policy-notices/library." ), ) async def search_policy_notices(query: str | None = None) -> dict: async with tool_runtime( "search_policy_notices", {"query_len": len(query or "")}, ) as rt: from app.domains.policy_notices.service import PolicyNoticesService svc = PolicyNoticesService(rt.db) rows = await svc.list_library(q=query) return serialize({ "items": [ { "id": str(r.id), "title_zh": r.title_zh, "title_en": r.title_en, "summary_zh": r.summary_zh, "summary_en": r.summary_en, "keywords": list(r.keywords or []), "feishu_url": r.feishu_url, } for r in rows ], }) @mcp_server.tool( name="feed_get_my_preferences", description=( "Read the caller's feed-domain preferences: birthday " "(date or None), birthday_visibility (`month_day` displays " "MM-DD only; `hidden` suppresses the celebration entirely), " "notify_comment_feishu (Feishu DM on comments), " "notify_like_aggregate (in-app inbox on likes)." ), ) async def feed_get_my_preferences() -> dict: async with tool_runtime("feed_get_my_preferences", {}) as rt: from app.domains.feed.service import FeedService svc = FeedService(rt.db) prefs = await svc.get_preferences(person=rt.user) return { "birthday": ( prefs["birthday"].isoformat() if prefs["birthday"] else None ), "birthday_visibility": prefs["birthday_visibility"], "notify_comment_feishu": prefs["notify_comment_feishu"], "notify_like_aggregate": prefs["notify_like_aggregate"], } @mcp_server.tool( name="feed_update_my_preferences", description=( "Update the caller's feed-domain preferences. Any field omitted " "is left untouched. `birthday` accepts `YYYY-MM-DD` to set, or " "\"\" (empty string) to clear; omitting it (or passing null) " "leaves it unchanged. `birthday_visibility` ∈ " "{month_day, hidden}. The two notify_* keys are booleans." ), ) async def feed_update_my_preferences( birthday: PastDate = None, birthday_visibility: str | None = None, notify_comment_feishu: bool | None = None, notify_like_aggregate: bool | None = None, ) -> dict: async with tool_runtime( "feed_update_my_preferences", { "set_birthday": birthday is not None, "visibility": birthday_visibility, }, mutates=True, ) as rt: from app.domains.feed.service import FeedService svc = FeedService(rt.db) patch: dict = {} if birthday is not None: patch["birthday"] = ( _parse_iso_date(birthday, field="birthday") if birthday else None ) if birthday_visibility is not None: patch["birthday_visibility"] = birthday_visibility if notify_comment_feishu is not None: patch["notify_comment_feishu"] = notify_comment_feishu if notify_like_aggregate is not None: patch["notify_like_aggregate"] = notify_like_aggregate prefs = await svc.update_preferences(person=rt.user, patch=patch) return { "birthday": ( prefs["birthday"].isoformat() if prefs["birthday"] else None ), "birthday_visibility": prefs["birthday_visibility"], "notify_comment_feishu": prefs["notify_comment_feishu"], "notify_like_aggregate": prefs["notify_like_aggregate"], } # ────────────────────────────────────────────────────────────────────── # Leaver-blocked admin (2026-06-02): force-bypass the T+0 24h IT grace # window from an AI client. Mirrors HTTP # POST /api/v1/leaver-blocked/processes/{id}/force-bypass-grace. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="leaver_blocked_force_bypass_grace", description=( "**IT-admin only.** Force-advance one leaver's offboarding " "process, collapsing the T+0 24h IT grace window to zero so " "physical force-transfer + revoke fire NOW instead of the next " "tick after the 24h timer expires. Use only when IT has " "already drained the leaver's resources manually (HRIS-late-" "detection case: termination weeks ago, no T-N reminders ever " "fired). The grace window exists precisely for IT response " "time — by closing it early you assert IT is ready. Requires " "a written ``reason`` ≥ 30 chars; recorded in audit_logs " "alongside the standard advance_now summary. Returns the same " "``summary`` shape as advance_now (force_transferred_blockers," " transitioned_to_left, held_pending_blockers, …). Mirrors " "POST /api/v1/leaver-blocked/processes/{id}/force-bypass-grace." ), ) async def leaver_blocked_force_bypass_grace( process_id: str, reason: str, idempotency_key: str | None = None, ) -> dict: async with tool_runtime( "leaver_blocked_force_bypass_grace", {"process_id": process_id, "reason": reason[:120]}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.core.exceptions import ValidationError from app.domains.leaver_blocked.models import MIN_REASON_CHARS if len(reason) < MIN_REASON_CHARS: raise ValidationError( f"reason must be ≥ {MIN_REASON_CHARS} chars; " f"got {len(reason)}.", ) pid = _u(process_id) # Reuse the same shared body the HTTP route uses so the audit # trail / summary shape stay 1:1, including the optional # ``idempotency_key`` dedup the HTTP twin gets from the # Idempotency-Key header. The runtime request object is # synthesised — its only consumers in the helper are # ``request.state.trace_id`` (we pass our own) and the # success_response builder; everything else is independent. from starlette.requests import Request as _Req from app.core.exceptions import AppException from app.domains.leaver_blocked.router import ( _run_advance_for_process, ) scope = { "type": "http", "method": "POST", "path": f"/api/v1/leaver-blocked/processes/{pid}/force-bypass-grace", "headers": [], } synth_req = _Req(scope) synth_req.state.trace_id = _trace_str() try: resp = await _run_advance_for_process( process_id=pid, request=synth_req, idempotency_key=idempotency_key, user=rt.user, db=rt.db, bypass_grace=True, audit_action=( "leaver_blocked.force_bypass_grace_advance" ), extra_audit_detail={"reason": reason}, ) except AppException: # Preserve the typed code/status (NotFoundError → 404, etc.) # so the runtime's ``except AppException`` handler maps it # 1:1. ``str(AppException)`` is empty (it doesn't call # ``super().__init__(message)``), so flattening to ValueError # would surface a non-existent process_id as a generic 500. # Anything else falls through to the runtime catch-all # (rollback + 500 + full trace logged). (Launch audit # 2026-06-10.) await _safe_rollback(rt.db) raise # ``_run_advance_for_process`` returns the plain success_response # envelope dict (``{success, data, meta, errors}``) — NOT a # starlette Response (see ``core/response.py``). Return the # ``data`` payload so the MCP caller gets ``{summary, # transitioned}`` directly without having to unwrap. if isinstance(resp, dict) and "data" in resp: return resp["data"] return resp # ────────────────────────────────────────────────────────────────────── # Hardware-asset financial lifecycle (depreciation / book value / disposal # P&L). Read-only twins of the /assets/financial-stats/* + /depreciation- # policies HTTP surface — closes the finance-persona hardware-cost gap for # AI clients too. RBAC mirrors require_software_read (admin / asset_admin / # finance / auditor / management). # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="hardware_financial_stats", description=( "Finance/admin: company-wide hardware-asset financial summary — " "net book value by category / department / office, accumulated " "depreciation, lifetime repair cost, and disposal P&L (proceeds " "vs. frozen book value). Currency-exact (``by_currency_*`` maps; " "``total_*`` set only in a single currency). Mirrors GET " "/assets/financial-stats/all. Read-only. Roles: global_admin / " "asset_admin / finance / auditor / management — others 403." ), ) async def hardware_financial_stats() -> dict: async with tool_runtime("hardware_financial_stats", {}) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_all_assets from app.domains.asset import financial_service if not can_read_all_assets(rt.user.role_names): raise ForbiddenError( "hardware_financial_stats requires finance / asset_admin / " "auditor / management / global_admin." ) return serialize( await financial_service.compute_financial_stats(rt.db, scope="all") ) @mcp_server.tool( name="get_asset_financial_profile", description=( "Per-asset financial timeline: acquisition (cost / date / entity) " "→ depreciation schedule (annual net-book-value checkpoints) → " "lifetime repair investment → disposal (proceeds / frozen book " "value / gain-loss) → total cost of ownership. Mirrors GET " "/assets/{asset_id}/financial. Read-only; asset-read scope." ), ) async def get_asset_financial_profile(asset_id: str) -> dict: async with tool_runtime( "get_asset_financial_profile", {"asset_id": asset_id}, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_read_all_assets from app.domains.asset import financial_service if not can_read_all_assets(rt.user.role_names): raise ForbiddenError( "get_asset_financial_profile requires asset-read scope." ) data = await financial_service.get_asset_financial_profile(rt.db, _u(asset_id)) if data is None: raise NotFoundError("Asset", asset_id) return serialize(data) @mcp_server.tool( name="list_depreciation_policies", description=( "List hardware depreciation policies — one per asset_category " "plus the platform default (asset_category=null). Each carries " "method (straight_line / declining_balance), useful_life_months, " "residual_rate, declining_rate. Mirrors GET /assets/depreciation-" "policies. Read-only; finance / asset_admin / admin scope." ), ) async def list_depreciation_policies() -> dict: async with tool_runtime("list_depreciation_policies", {}) as rt: from app.core.exceptions import ForbiddenError from app.core.rbac import can_read_all_assets from app.domains.asset import financial_service if not can_read_all_assets(rt.user.role_names): raise ForbiddenError( "list_depreciation_policies requires finance / asset_admin / " "admin scope." ) rows = await financial_service.list_policies(rt.db) return {"policies": [serialize({ "id": str(r.id), "asset_category": r.asset_category, "method": r.method, "useful_life_months": r.useful_life_months, "residual_rate": r.residual_rate, "declining_rate": r.declining_rate, "is_active": r.is_active, "notes": r.notes, }) for r in rows]} @mcp_server.tool( name="list_team_lifecycle", description=( "Manager-scope: where each of the caller's direct/transitive " "reports sits in onboarding (Day-1 readiness — hire date, " "countdown, pending readiness blockers) or offboarding " "(exit countdown, status, pending blockers incl. hardware-return, " "SLA, handover). Read-only — mirrors GET /api/v1/me/team/lifecycle. " "Empty for a leaf-node employee with no reports." ), ) async def list_team_lifecycle() -> dict: async with tool_runtime("list_team_lifecycle", {}) as rt: from app.domains.me_universe.team_service import build_team_lifecycle return serialize(await build_team_lifecycle(rt.db, manager=rt.user)) @mcp_server.tool( name="list_team_api_keys", description=( "Manager-scope: AI API keys (OpenAI/Azure Foundry, AWS Bedrock, " "GCP Vertex, Alibaba Bailian, …) HELD by the caller's " "direct/transitive reports, each with its month-to-date spend, " "monthly budget, and over-budget / approaching flags. Includes a " "team summary (total MTD spend, total budget, # over budget). " "Per-key spend reconciles to the cent with the per-AK detail " "page. Read-only — mirrors GET /api/v1/me/team/api-keys. Set " "include_left=true to also surface keys still held by departed " "reports. Empty for a leaf-node employee with no reports." ), ) async def list_team_api_keys(include_left: bool = False) -> dict: async with tool_runtime( "list_team_api_keys", {"include_left": include_left}, ) as rt: from app.domains.me_universe.team_service import build_team_api_keys return serialize(await build_team_api_keys( rt.db, manager=rt.user, include_left=include_left, )) # ────────────────────────────────────────────────────────────────────── # Self-service request lifecycle-verb parity (PR3). 1:1 MCP twins for the # newly-added withdraw (permission) + handover/revoke (ai_tool) flows — # MCP-sync rule: every new self-service HTTP flow ships its MCP tool. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="cancel_permission_request", description=( "Withdraw your own still-PENDING software-permission request. " "Lifecycle-verb parity with sso/vpn/domain/cloud-scope/asset " "withdrawal. Owner-only, PENDING-only. Mirrors POST " "/api/v1/permissions/requests/{id}/cancel." ), ) async def cancel_permission_request(user_permission_id: str) -> dict: async with tool_runtime( "cancel_permission_request", {"user_permission_id": user_permission_id}, mutates=True, ) as rt: from app.domains.permission.service import PermissionService up = await PermissionService(rt.db).cancel_request( _u(user_permission_id), actor_user_id=rt.user.id, trace_id=_trace_str(), ) return {"id": str(up.id), "status": up.status} @mcp_server.tool( name="handover_ai_tool_application", description=( "Transfer an APPROVED AI-tool seat to an active colleague " "(role change / pre-departure). Owner or admin; successor must " "be active — use search_directory_people to resolve their name to " "a person UUID. Mirrors POST " "/api/v1/ai-tool-applications/{id}/handover." ), ) async def handover_ai_tool_application(app_id: str, successor_person_id: str) -> dict: async with tool_runtime( "handover_ai_tool_application", {"app_id": app_id, "successor_person_id": successor_person_id}, mutates=True, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import GLOBAL_ADMIN_ROLE, has_role from app.domains.ai_tool_application.models import AiToolApplication from app.domains.ai_tool_application.service import AiToolApplicationService existing = await rt.db.get(AiToolApplication, app_id) if existing is None: raise NotFoundError("AiToolApplication", app_id) if existing.person_id != str(rt.user.id) and not has_role(rt.user.role_names, GLOBAL_ADMIN_ROLE): raise ForbiddenError("Only the current holder or an admin can hand over this seat.") row = await AiToolApplicationService(rt.db).handover( app_id=app_id, successor_person_id=successor_person_id, actor=rt.user, trace_id=_trace_str(), ) return {"id": str(row.id), "person_id": row.person_id, "status": row.status} @mcp_server.tool( name="revoke_ai_tool_application", description=( "**Admin only.** Revoke an APPROVED AI-tool seat (lifecycle-verb " "parity — was previously only reachable via offboarding). Mirrors " "POST /api/v1/ai-tool-applications/{id}/revoke." ), ) async def revoke_ai_tool_application(app_id: str, reason: str | None = None) -> dict: async with tool_runtime( "revoke_ai_tool_application", {"app_id": app_id}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.ai_tool_application.service import AiToolApplicationService row = await AiToolApplicationService(rt.db).revoke( app_id=app_id, actor=rt.user, reason=reason, trace_id=_trace_str(), ) return {"id": str(row.id), "status": row.status} # ────────────────────────────────────────────────────────────────────── # Cloud-sync observability (PR4). Read-only diagnostic: per-capability # latest sync outcome for one cloud account — the "why did servers sync # but IAM fail?" answer, without reading raw capability_results JSON. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="get_cloud_sync_status", description=( "Per-capability latest sync outcome for one cloud integration: " "status / counts / reason / partial-failure count / last-run time " "for each configured capability (servers / domains / dns_records / " "iam / …), including capabilities that have NEVER synced. Mirrors " "GET /api/v1/cmdb/integrations/{id}/sync-capability-status. " "Read-only; cloud-read scope (admin / cloud_admin / auditor / " "finance / management)." ), ) async def get_cloud_sync_status(integration_id: str) -> dict: async with tool_runtime( "get_cloud_sync_status", {"integration_id": integration_id}, ) as rt: from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_read_cloud from app.domains.cmdb.sync_status import build_capability_status from app.integrations.models import Integration if not can_read_cloud(rt.user.role_names): raise ForbiddenError("get_cloud_sync_status requires cloud-read scope.") integration = await rt.db.get(Integration, _u(integration_id)) if integration is None or getattr(integration, "kind", None) != "cloud": raise NotFoundError("CloudIntegration", integration_id) # Annotated rather than returned straight from ``serialize``: # that helper is typed Any, and letting it flow out untouched is # what the mypy ratchet counts as a new no-any-return. payload: dict = serialize(await build_capability_status(rt.db, integration)) return payload @mcp_server.tool( name="classify_external_account", description=( "Classify + annotate an external (non-employee) account by email, " "across ALL vendors at once. Seat reconciliation flags accounts " "that don't match an active employee as 'external'; the same email " "often appears in Google + Feishu + Slack + Jira + Zoom together. " "This sets ONE cross-vendor annotation (classification + note) that " "every vendor's panel reads. classification ∈ {shared_service, " "external_guest, contractor (=known/expected → " "dropped from each panel's 外部未匹配 count), former_employee, " "unknown (=still flagged for cleanup)}. note is free text. " "For accounts with NO email (Feishu no-email consultant " "accounts), pass the directory row's key instead " "(e.g. 'feishu-user:'). " "Mutating; restricted to global_admin. 1:1 with " "POST /api/v1/assets/external-accounts/classify." ), ) async def classify_external_account( email: str, classification: str | None = None, note: str | None = None, ) -> dict: async with tool_runtime( "classify_external_account", {"email": email, "classification": classification, "note": note}, mutates=True, ) as rt: _require_global_admin(rt.user.role_names) from app.domains.asset.external_account_service import ( ExternalAccountRegistryService, ) svc = ExternalAccountRegistryService(rt.db) data = await svc.classify( email, classification=classification, note=note, owner_person_id=None, set_owner=False, trace_id=f"mcp:classify_external_account:{email}", actor_user_id=rt.user.id, ) await rt.db.commit() # Annotated rather than returned straight from ``serialize``: # that helper is typed Any, and letting it flow out untouched is # what the mypy ratchet counts as a new no-any-return. payload: dict = serialize(data) return payload @mcp_server.tool( name="submit_data_access_service_account_grant", description=( "Submit a Feishu-backed BigQuery table read request for one typed " "GCP service-account email. Helix validates and records the email " "with the request; it must not be pre-registered or have a technical " "owner. Pass exact dataset_resource_id/table_id selections; legacy " "resource_ids remain accepted for historical callers. This tool never " "mutates BigQuery directly. Mirrors POST " "/api/v1/data-access/requests/service-account-grants." ), ) async def submit_data_access_service_account_grant( service_account_email: str, business_reason: str, idempotency_key: str, table_selections: list[dict] | None = None, resource_ids: list[str] | None = None, ) -> dict: async with tool_runtime( "submit_data_access_service_account_grant", { "service_account_email": service_account_email, "resource_ids": resource_ids, "table_selections": table_selections, }, mutates=True, ) as rt: from app.core.exceptions import NotFoundError from app.domains.data_access.service import DataAccessService from app.identity.person_models import IdentityPerson from app.integrations.driver_base import DataAccessDriver from app.integrations.service import build_primary_driver requester = await rt.db.get(IdentityPerson, rt.user.id) if requester is None: raise NotFoundError("IdentityPerson", str(rt.user.id)) if bool(table_selections) == bool(resource_ids): raise ValueError("Provide exactly one of table_selections or resource_ids") selected_resource_ids = [_u(resource_id) for resource_id in resource_ids or []] if table_selections: from app.domains.data_access.schemas import DataAccessTableSelectionRequest driver = await build_primary_driver(DataAccessDriver.kind, session=rt.db) if not isinstance(driver, DataAccessDriver): raise ValueError("The primary data-access integration is invalid") selected_resource_ids = list( await DataAccessService().resolve_table_selection_resource_ids( rt.db, selections=[ DataAccessTableSelectionRequest.model_validate(item) for item in table_selections ], driver=driver, ), ) result = await DataAccessService().submit_service_account_grant_request( rt.db, requester=requester, service_account_email=service_account_email, resource_ids=selected_resource_ids, business_reason=business_reason, detail_url="/my/data-access/requests", idempotency_key=idempotency_key, trace_id=_trace_str(), ) await rt.db.commit() return { "request_id": str(result.request_id), "approval_request_id": ( str(result.approval_request_id) if result.approval_request_id else None ), "feishu_instance_code": result.feishu_instance_code, "status": result.status, "dataset_count": result.dataset_count, "reused_existing": result.reused_existing, } @mcp_server.tool( name="submit_data_access_personal_grant", description=( "Submit a Feishu-backed BigQuery table read request for the caller's " "own work account. The caller cannot supply or change the target email; " "Helix derives it from the authenticated active employee identity. Only " "exact table selections are validated against BigQuery and this tool never " "mutates BigQuery directly. Legacy resource_ids remain accepted. Mirrors " "POST /api/v1/data-access/requests/personal-grants." ), ) async def submit_data_access_personal_grant( business_reason: str, idempotency_key: str, table_selections: list[dict] | None = None, resource_ids: list[str] | None = None, ) -> dict: async with tool_runtime( "submit_data_access_personal_grant", {"resource_ids": resource_ids, "table_selections": table_selections}, mutates=True, ) as rt: from app.core.exceptions import NotFoundError from app.domains.data_access.service import DataAccessService from app.identity.person_models import IdentityPerson from app.integrations.driver_base import DataAccessDriver from app.integrations.service import build_primary_driver requester = await rt.db.get(IdentityPerson, rt.user.id) if requester is None: raise NotFoundError("IdentityPerson", str(rt.user.id)) if bool(table_selections) == bool(resource_ids): raise ValueError("Provide exactly one of table_selections or resource_ids") selected_resource_ids = [_u(resource_id) for resource_id in resource_ids or []] if table_selections: from app.domains.data_access.schemas import DataAccessTableSelectionRequest driver = await build_primary_driver(DataAccessDriver.kind, session=rt.db) if not isinstance(driver, DataAccessDriver): raise ValueError("The primary data-access integration is invalid") selected_resource_ids = list( await DataAccessService().resolve_table_selection_resource_ids( rt.db, selections=[ DataAccessTableSelectionRequest.model_validate(item) for item in table_selections ], driver=driver, ), ) result = await DataAccessService().submit_personal_grant_request( rt.db, requester=requester, resource_ids=selected_resource_ids, business_reason=business_reason, detail_url="/my/data-access/requests", idempotency_key=idempotency_key, trace_id=_trace_str(), ) await rt.db.commit() return { "request_id": str(result.request_id), "approval_request_id": ( str(result.approval_request_id) if result.approval_request_id else None ), "feishu_instance_code": result.feishu_instance_code, "status": result.status, "dataset_count": result.dataset_count, "reused_existing": result.reused_existing, } @mcp_server.tool( name="get_data_access_table_lineage", description=( "Read the deployed dbt lineage for one exact BigQuery table inside a " "requestable DAX dataset. Generated dbt relations return their upstream " "source path; source relations return the downstream models that reference " "them. Returns not_tracked rather than inventing a path otherwise. " "Read-only; does not query BigQuery table data or change access. Mirrors " "GET /api/v1/data-access/requestable-resources/{dataset_resource_id}/" "tables/{table_id}/lineage." ), ) async def get_data_access_table_lineage( dataset_resource_id: str, table_id: str, ) -> dict: async with tool_runtime( "get_data_access_table_lineage", {"dataset_resource_id": dataset_resource_id, "table_id": table_id}, ) as rt: from app.core.exceptions import AppException from app.domains.data_access.lineage import ( DataAccessLineageCatalogError, get_table_lineage, ) from app.domains.data_access.lineage_owners import ( lineage_dataset_owner_fields, resolve_lineage_dataset_owners, ) from app.domains.data_access.router import ( _requestable_dataset_for_schema_browser, _require_data_access_request_author, ) await _require_data_access_request_author(rt.db, rt.user) resource = await _requestable_dataset_for_schema_browser( rt.db, _u(dataset_resource_id), ) try: lineage = get_table_lineage( project_id=resource.project_id, dataset_id=resource.dataset_id, table_id=table_id, ) except DataAccessLineageCatalogError: raise AppException( code="DATA_ACCESS_LINEAGE_UNAVAILABLE", message="Table lineage is temporarily unavailable.", status_code=503, ) from None dataset_owners = await resolve_lineage_dataset_owners( rt.db, integration_id=str(resource.integration_id), nodes=(node for node in lineage.nodes if node.node_type == "source"), ) return { "status": lineage.status, "direction": lineage.direction, "catalog_id": lineage.catalog_id, "source_version": lineage.source_version, "generated_at": lineage.generated_at, "project_id": lineage.project_id, "dataset_id": lineage.dataset_id, "table_id": lineage.table_id, "truncated": lineage.truncated, "nodes": [ { "node_id": node.node_id, "project_id": node.project_id, "dataset_id": node.dataset_id, "table_id": node.table_id, "node_type": node.node_type, "materialization": node.materialization, "description": node.description, "layer": node.layer, "level": node.level, "is_root": node.is_root, "is_target": node.is_target, **lineage_dataset_owner_fields(node, dataset_owners), } for node in lineage.nodes ], "edges": [ { "upstream_node_id": edge.upstream_node_id, "downstream_node_id": edge.downstream_node_id, "relationship": edge.relationship, } for edge in lineage.edges ], } @mcp_server.tool( name="transfer_my_data_access_dataset_owner", description=( "Transfer one requestable BigQuery dataset currently owned by the caller " "to an active Helix person. Inherited table owners follow atomically; " "tables with their own manual owner stay unchanged. Mutating; mirrors PUT " "/api/v1/data-access/requestable-resources/{dataset_resource_id}/owner." ), ) async def transfer_my_data_access_dataset_owner( dataset_resource_id: str, owner_person_id: str, ) -> dict: async with tool_runtime( "transfer_my_data_access_dataset_owner", { "dataset_resource_id": dataset_resource_id, "owner_person_id": owner_person_id, }, mutates=True, ) as rt: from app.domains.data_access.service import DataAccessService from app.identity.person_models import IdentityPerson actor = await rt.db.get(IdentityPerson, rt.user.id) if actor is None: raise ValueError("The authenticated person is not available") updated = await DataAccessService().transfer_bigquery_dataset_owner( rt.db, dataset_resource_id=_u(dataset_resource_id), actor=actor, new_owner_person_id=_u(owner_person_id), trace_id=_trace_str(), ) await rt.db.commit() return { "resource_id": str(updated.resource_id), "project_id": updated.project_id, "dataset_id": updated.dataset_id, "owner_person_id": updated.owner_person_id, "owner_source": updated.owner_source, } @mcp_server.tool( name="reassign_data_access_table_owner", description=( "Reassign one requestable BigQuery table to an active Helix person. " "Only the current effective table owner may transfer it. Future " "requests for this exact table go to the new owner. Mutating; " "mirrors PUT /api/v1/data-access/requestable-resources/{dataset_id}/" "tables/{table_id}/owner." ), ) async def reassign_data_access_table_owner( dataset_resource_id: str, table_id: str, owner_person_id: str, ) -> dict: async with tool_runtime( "reassign_data_access_table_owner", { "dataset_resource_id": dataset_resource_id, "table_id": table_id, "owner_person_id": owner_person_id, }, mutates=True, ) as rt: from app.domains.data_access.service import DataAccessService from app.identity.person_models import IdentityPerson from app.integrations.driver_base import DataAccessDriver from app.integrations.service import build_primary_driver service = DataAccessService() dataset_id = _u(dataset_resource_id) dataset = await service.validate_requestable_bigquery_dataset_resource( rt.db, dataset_id, require_resource_owner_authority=True, ) driver = await build_primary_driver(DataAccessDriver.kind, session=rt.db) if not isinstance(driver, DataAccessDriver): raise ValueError("The primary data-access integration is invalid") schema = await driver.get_dataset_table_schema( dataset.project_id, dataset.dataset_id, table_id, ) await service.resolve_requestable_bigquery_table_resource( rt.db, dataset_resource_id=dataset_id, table_id=schema.table_id, table_type=schema.table_type, ) actor = await rt.db.get(IdentityPerson, rt.user.id) if actor is None: raise ValueError("The authenticated person is not available") updated = await service.reassign_bigquery_table_owner( rt.db, dataset_resource_id=dataset_id, table_id=schema.table_id, actor=actor, new_owner_person_id=_u(owner_person_id), trace_id=_trace_str(), ) await rt.db.commit() return { "resource_id": str(updated.resource_id), "dataset_resource_id": str(dataset_id), "project_id": updated.project_id, "dataset_id": updated.dataset_id, "table_id": updated.table_id, "table_type": updated.table_type, "owner_person_id": updated.owner_person_id, "owner_source": updated.owner_source, } @mcp_server.tool( name="list_my_data_access_grants", description=( "List BigQuery dataset or table grants created by requests submitted by the " "caller, including personal-account, Google Group, and Service Account " "identities. Read-only and requester-scoped; Group access inherited " "only through membership is not reported as caller-owned. Mirrors GET " "/api/v1/data-access/me/grants." ), ) async def list_my_data_access_grants( search: str | None = None, principal_type: str | None = None, page: int = 1, page_size: int = 20, ) -> dict: async with tool_runtime( "list_my_data_access_grants", { "search": search, "principal_type": principal_type, "page": page, "page_size": page_size, }, ) as rt: from app.domains.data_access.binding_projection import ( binding_access_state, provider_member_for_binding, ) from app.domains.data_access.my_grants import MY_GRANT_PRINCIPAL_TYPES from app.domains.data_access.my_grants import ( list_my_data_access_grants as list_grants, ) if principal_type is not None and principal_type not in MY_GRANT_PRINCIPAL_TYPES: raise ValueError( "principal_type must be google_user, google_group, or " "gcp_service_account", ) if page < 1 or page_size < 1 or page_size > 200: raise ValueError("page must be >= 1 and page_size must be between 1 and 200") result = await list_grants( rt.db, requester_person_id=rt.user.id, search=search, principal_type=principal_type, page=page, page_size=page_size, ) payload: dict = serialize({ "items": [ { "binding_id": item.binding.id, "resource_id": item.resource.id, "source_request_id": item.request.id, "source_request_submitted_at": item.request.submitted_at, "project_id": item.resource.project_id, "dataset_id": item.resource.dataset_id, "table_id": item.resource.table_id, "resource_type": item.resource.resource_type, "locator": ".".join(filter(None, ( item.resource.project_id, item.resource.dataset_id, item.resource.table_id, ))), "display_name": item.resource.display_name, "description": item.resource.notes, "owner_person_id": item.resource.owner_person_id, "principal_type": item.binding.principal_type_snapshot, "principal_email": item.binding.principal_email_snapshot, "provider_member": ( item.effect.provider_member if item.effect else provider_member_for_binding(item.binding) ), "provider_role": ( item.effect.provider_role if item.effect else item.entitlement.provider_role ), "access_state": binding_access_state( item.binding, item.effect, ), "binding_status": item.binding.status, "reconcile_status": item.binding.reconcile_status, "granted_at": item.binding.granted_at, "revoked_at": item.binding.revoked_at, } for item in result.items ], "page": result.page, "page_size": result.page_size, "total": result.total, }) return payload # ────────────────────────────────────────────────────────────────────── # Public holidays — the company-wide multi-country calendar. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="get_public_holidays", description=( "查询公司法定假期日历: list PingCAP public holidays for a country and " "date window. Defaults to the CALLER's own country (resolved from their " "work location) and the next 12 months. Pass country=\"all\" for every " "maintained country, or an ISO-3166 code / country name for one. " "Includes 调休 makeup workdays as their own entries with " "day_type=makeup_workday, so day counting stays correct. A country HR " "has not maintained comes back with coverage=no_data rather than an " "empty list — never read that as 'no holidays'." ), ) async def get_public_holidays( country: str | None = None, date_from: str | None = None, date_to: str | None = None, year: int | None = None, ) -> dict: async with tool_runtime( "get_public_holidays", {"country": country, "date_from": date_from, "date_to": date_to, "year": year}, ) as rt: from datetime import date as _date from datetime import datetime as _datetime from datetime import timedelta as _timedelta from app.core.working_hours import resolve_timezone from app.domains.hr import holiday_service as hsvc my_country, person = await hsvc.resolve_person_country( rt.db, person_id=str(rt.user.id), ) today = ( _datetime.now(resolve_timezone(person)).date() if person else _date.today() ) if year is not None: start, end = hsvc.year_bounds(int(year)) else: start = _date.fromisoformat(date_from) if date_from else today end = ( _date.fromisoformat(date_to) if date_to else start + _timedelta(days=365) ) wants_all = (country or "").strip().lower() in {"all", "*", "全部"} if wants_all: scope = None elif country: scope = [hsvc.resolve_country(country)] elif my_country: scope = [my_country] else: scope = None rows = await hsvc.list_days(rt.db, countries=scope, start=start, end=end) calendars = await hsvc.list_calendars(rt.db, countries=scope) labels = {c.country: (c.label or c.country) for c in calendars} # Coverage must be intersected with the years the window touches — the # same contract the External API enforces. Without it a 2027 question # about a country maintained only through 2026 came back # coverage="covered" with an empty list, and an AI client reading that # concludes people are working. Two surfaces over one dataset must not # disagree about what "we don't know" looks like. window_years = set(range(start.year, end.year + 1)) covered = { c.country for c in calendars if c.year in window_years } groups = await hsvc.complete_groups(rt.db, rows) return { "my_country": my_country, "today": today.isoformat(), "from": start.isoformat(), "to": end.isoformat(), "scope": scope or "all", "coverage": { code: ("covered" if code in covered else "no_data") for code in (scope or sorted(covered)) }, "holidays": [ { "country": g.country, "country_name": labels.get(g.country, g.country), "group_key": g.group_key, "name": g.name_en, "name_local": g.name_local, "start": g.start.isoformat(), "end": g.end.isoformat(), "days": [d.isoformat() for d in g.days], "total_days": g.total_days, "observed_days": g.observed_days, "makeup_workdays": [d.isoformat() for d in g.makeup_days], "days_until": (g.start - today).days, } for g in groups ], } @mcp_server.tool( name="get_my_next_holiday", description=( "我的下一个假期是什么时候: the CALLER's next upcoming public holiday, " "resolved from their own work country and their own local date. A " "holiday already under way is reported with in_progress=true rather " "than skipped. Falls back to the company-wide next holiday when the " "caller's country has no maintained calendar." ), ) async def get_my_next_holiday(limit: int = 3) -> dict: async with tool_runtime("get_my_next_holiday", {"limit": limit}) as rt: from datetime import date as _date from datetime import datetime as _datetime from app.core.working_hours import resolve_timezone from app.domains.hr import holiday_service as hsvc my_country, person = await hsvc.resolve_person_country( rt.db, person_id=str(rt.user.id), ) today = ( _datetime.now(resolve_timezone(person)).date() if person else _date.today() ) bounded = max(1, min(int(limit or 3), 10)) scope = [my_country] if my_country else [] groups = await hsvc.upcoming_groups( rt.db, countries=scope, today=today, limit=bounded, ) if scope else [] widened = False if not groups: widened = bool(scope) all_codes = [c["country"] for c in await hsvc.list_countries(rt.db)] groups = await hsvc.upcoming_groups( rt.db, countries=all_codes, today=today, limit=bounded, ) return { "my_country": my_country, "today": today.isoformat(), "widened_to_all_countries": widened, "upcoming": [ { "country": g.country, "group_key": g.group_key, "name": g.name_en, "name_local": g.name_local, "start": g.start.isoformat(), "end": g.end.isoformat(), "total_days": g.total_days, "days_until": (g.start - today).days, "in_progress": g.start <= today <= g.end, "makeup_workdays": [d.isoformat() for d in g.makeup_days], } for g in groups ], } @mcp_server.tool( name="generate_holiday_notice", description=( "生成放假通知文案 (HR only): render the copy-ready 放假通知 for one " "holiday — title, date/补班 lines, the standard office-safety " "reminders, and a seasonal closing line — in Chinese, English or both. " "Returns TEXT ONLY; Helix never sends it, HR copies it into the " "channel they choose. Find the group_key with get_public_holidays." ), ) async def generate_holiday_notice( country: str, group_key: str, language: str = "zh", include_tips: bool = True, closing: str | None = None, ) -> dict: async with tool_runtime( "generate_holiday_notice", {"country": country, "group_key": group_key, "language": language}, ) as rt: from app.core.exceptions import ( ForbiddenError, NotFoundError, ValidationError, ) from app.core.rbac import GLOBAL_ADMIN_ROLE, HR_ROLE, has_role from app.domains.hr import holiday_service as hsvc from app.domains.hr.holiday_notice import NoticeRequest, render_notice # Mirror the HTTP twin's ``require_hr_read`` gate. ``tool_runtime`` only # enforces active-employee status, and any employee can self-mint a PAT, # so without this an ordinary employee could render an all-hands notice # through MCP that the HTTP layer refuses them. ``has_role`` canonicalises # the legacy ``it_admin`` alias, so don't compare raw strings here. if not has_role(rt.user.role_names or [], GLOBAL_ADMIN_ROLE, HR_ROLE): raise ForbiddenError( "Generating an all-hands holiday notice is restricted to HR.", ) resolved = hsvc.resolve_country(country) rows = await hsvc.list_days( rt.db, countries=[resolved], group_key=group_key, ) if not rows: raise NotFoundError( f"holiday group {group_key!r} not found for {resolved}", ) group = hsvc.build_groups(rows)[0] lang = (language or "zh").strip().lower() try: payload = render_notice( group, NoticeRequest( language=lang, include_tips=include_tips, closing_zh=closing if lang in ("zh", "bilingual") else None, closing_en=closing if lang == "en" else None, region_label=None if resolved == "CN" else group.country, ), ) except ValueError as exc: raise ValidationError(str(exc)) from exc return payload # ════════════════════════════════════════════════════════════════════ # Intelligence Center — exact read-only HTTP twins (zero LLM calls) # ════════════════════════════════════════════════════════════════════ async def _intelligence_access(rt, *, consume_read_limit: bool = True): from app.intelligence.access import resolve_access_plan from app.intelligence.settings import ( apply_intelligence_read_limit, require_intelligence_enabled, ) await require_intelligence_enabled(rt.db) plan = await resolve_access_plan( rt.db, person_id=rt.user.id, role_names=rt.user.role_names, ) if consume_read_limit: await apply_intelligence_read_limit(rt.db, user_id=rt.user.id) return plan @mcp_server.tool( name="intelligence_get_overview", description=( "Read the current AI 大脑 / Intelligence Center health overview allowed " "for the caller's Helix roles. Returns deterministic scores, visible " "aggregate facts, confidence, data gaps and capabilities. This is the " "1:1 twin of GET /api/v1/intelligence/overview and never calls a model." ), ) async def intelligence_get_overview() -> dict: async with tool_runtime("intelligence_get_overview", {}) as rt: from app.intelligence.read_service import read_overview plan = await _intelligence_access(rt) return await read_overview(rt.db, plan=plan) @mcp_server.tool( name="intelligence_get_domain", description=( "Read one role-visible Intelligence health dimension such as people_org, " "lifecycle, assets, software_api, cloud_network, cost_efficiency, " "security_compliance or platform_ops. Exact twin of GET " "/api/v1/intelligence/domains/{domain}; never calls a model." ), ) async def intelligence_get_domain(domain: str) -> dict: async with tool_runtime( "intelligence_get_domain", {"domain": domain}, ) as rt: from app.intelligence.read_service import read_domain plan = await _intelligence_access(rt) return await read_domain(rt.db, plan=plan, domain=domain) @mcp_server.tool( name="intelligence_get_history", description=( "Read role-visible Intelligence score history as honest per-dimension " "series without fabricating aligned timestamps across global and regional " "sources. Exact twin of GET /api/v1/intelligence/history; never calls a model." ), ) async def intelligence_get_history(limit: int = 90) -> dict: bounded = max(1, min(int(limit or 90), 365)) async with tool_runtime( "intelligence_get_history", {"limit": bounded}, ) as rt: from app.intelligence.read_service import read_history plan = await _intelligence_access(rt) return await read_history(rt.db, plan=plan, limit=bounded) @mcp_server.tool( name="intelligence_get_latest_analysis", description=( "Read the latest shared bilingual AI 大脑 platform brief, risks, " "opportunities and forecasts for the caller's role-visible scope. " "Exact twin of GET /api/v1/intelligence/analyses/latest; never calls a model." ), ) async def intelligence_get_latest_analysis() -> dict: async with tool_runtime("intelligence_get_latest_analysis", {}) as rt: from app.intelligence.read_service import read_latest_analysis plan = await _intelligence_access(rt) return await read_latest_analysis(rt.db, plan=plan) @mcp_server.tool( name="intelligence_generate_platform_analysis", description=( "Generate the shared bilingual AI 大脑 platform brief for a global admin. " "This is a costly mutation twin of POST /api/v1/intelligence/analyses/generate; " "it reuses the deterministic snapshot, cache, quota, cooldown and audit ledger." ), ) async def intelligence_generate_platform_analysis( force: bool = False, confirm_force: bool = False, ) -> dict: async with tool_runtime( "intelligence_generate_platform_analysis", {"force": force, "confirm_force": confirm_force}, mutates=True, ) as rt: from app.core.exceptions import AppException from app.intelligence.generation import generate_analysis from app.intelligence.provider_registry import BUSINESS_AUDIENCE_KEY from app.intelligence.recommendations import materialize_analysis_recommendations from app.intelligence.snapshot_service import SnapshotSubject, latest_snapshot plan = await _intelligence_access(rt, consume_read_limit=False) if not plan.can_force_generate: raise AppException( code="INTELLIGENCE_GENERATE_FORBIDDEN", message="Only global administrators can generate the platform brief.", status_code=403, ) if force and not confirm_force: raise AppException( code="INTELLIGENCE_FORCE_CONFIRMATION_REQUIRED", message="Force generation requires explicit confirmation.", status_code=400, ) snapshot = await latest_snapshot( rt.db, subject=SnapshotSubject("platform", "platform", BUSINESS_AUDIENCE_KEY), ) if snapshot is None: raise AppException( code="INTELLIGENCE_NO_SNAPSHOT", message="A deterministic platform snapshot is required before analysis.", status_code=409, ) result = await generate_analysis( rt.db, snapshot=snapshot, analysis_kind="platform_daily_brief", actor_id=rt.user.id, force=force, ) materialized = await materialize_analysis_recommendations(rt.db, result.analysis) return { "status": result.analysis.status, "analysis_id": str(result.analysis.id), "cache_reused": result.cache_reused, "recommendations_created": materialized.created, "recommendations_refreshed": materialized.refreshed, "recommendations_superseded": materialized.superseded, "model_calls": 0 if result.cache_reused else 1, "input_token_count": result.analysis.input_token_count, "output_token_count": result.analysis.output_token_count, } @mcp_server.tool( name="intelligence_get_recommendations", description=( "Read active role-visible AI 大脑 recommendations and their audited " "lifecycle status. Exact twin of GET /api/v1/intelligence/recommendations; " "never calls a model." ), ) async def intelligence_get_recommendations(limit: int = 50) -> dict: bounded = max(1, min(int(limit or 50), 200)) async with tool_runtime( "intelligence_get_recommendations", {"limit": bounded}, ) as rt: from app.intelligence.recommendations import ( read_recommendation_roi, read_recommendations, ) plan = await _intelligence_access(rt) subjects = { (item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions } allowed_dimensions = {item.dimension for item in plan.dimensions} return { "items": await read_recommendations(rt.db, subject_keys=subjects, allowed_dimensions=allowed_dimensions, limit=bounded), "roi": await read_recommendation_roi(rt.db, subject_keys=subjects, allowed_dimensions=allowed_dimensions), "model_calls": 0, } @mcp_server.tool( name="intelligence_update_recommendation", description=( "Update a role-visible recommendation status and audited savings outcome. " "Mutation twin of PATCH /api/v1/intelligence/recommendations/{id}; no model call." ), ) async def intelligence_update_recommendation( recommendation_id: str, status: str, ignored_reason: str | None = None, confirmed_savings: str | None = None, realized_savings: str | None = None, currency: str | None = None, ) -> dict: async with tool_runtime( "intelligence_update_recommendation", {"recommendation_id": recommendation_id, "status": status, "confirmed_savings": confirmed_savings, "realized_savings": realized_savings, "currency": currency}, mutates=True, ) as rt: from decimal import Decimal, InvalidOperation from uuid import UUID from app.core.exceptions import AppException from app.intelligence.models import IntelligenceRecommendation from app.intelligence.recommendations import transition_recommendation # Literal transcription of the HTTP handler's ORDER and MESSAGES # (app/intelligence/router.py::intelligence_recommendation_update); # the parity pattern PR #2973 established. Two drifts used to live # here: the UUID was parsed together with the Decimals up front, so # a malformed id answered 422 where HTTP answers 404 and an # unauthorized caller could still learn that its argument parsed; # and both savings checks collapsed into one "Savings cannot be # negative." string, so an agent could not tell which field it got # wrong. Only the decimal DECODE stays first, because on HTTP the # equivalent failure is pydantic body validation, which likewise # runs before the handler body. try: confirmed = Decimal(confirmed_savings) if confirmed_savings is not None else None realized = Decimal(realized_savings) if realized_savings is not None else None except (TypeError, ValueError, InvalidOperation) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_INPUT", message="Savings value is not a valid decimal.", status_code=422) from exc plan = await _intelligence_access(rt) if not plan.can_manage_recommendations: raise AppException(code="INTELLIGENCE_RECOMMENDATION_FORBIDDEN", message="Only global administrators can update recommendations.", status_code=403) try: parsed_id = UUID(recommendation_id) except (TypeError, ValueError) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) from exc row = await rt.db.get(IntelligenceRecommendation, parsed_id) if row is None: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) visible_subjects = { (item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions } if (row.subject_type, row.subject_id, row.audience_key) not in visible_subjects: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) if confirmed is not None and (not confirmed.is_finite() or confirmed < 0): raise AppException(code="INTELLIGENCE_RECOMMENDATION_SAVINGS", message="Confirmed savings cannot be negative.", status_code=422) if realized is not None and (not realized.is_finite() or realized < 0): raise AppException(code="INTELLIGENCE_RECOMMENDATION_SAVINGS", message="Realized savings cannot be negative.", status_code=422) if realized is not None and status != "resolved": raise AppException(code="INTELLIGENCE_RECOMMENDATION_SAVINGS", message="Realized savings require a resolved recommendation.", status_code=422) transition_recommendation( row, status=status, ignored_reason=ignored_reason, actor_id=rt.user.id, confirmed_savings=confirmed, realized_savings=realized, currency=currency, ) await rt.db.commit() return {"id": str(row.id), "status": row.status, "model_calls": 0} @mcp_server.tool( name="intelligence_send_recommendation", description=( "Enqueue one role-visible recommendation for existing IT admin " "notification delivery. Mutation twin of POST /api/v1/intelligence/" "recommendations/{id}/send; no model call and outbox delivery is retried." ), ) async def intelligence_send_recommendation(recommendation_id: str) -> dict: async with tool_runtime( "intelligence_send_recommendation", {"recommendation_id": recommendation_id}, mutates=True, ) as rt: from uuid import UUID from app.core.exceptions import AppException from app.intelligence.models import IntelligenceRecommendation from app.intelligence.recommendations import send_recommendation_notifications plan = await _intelligence_access(rt) if not plan.can_manage_recommendations: raise AppException(code="INTELLIGENCE_RECOMMENDATION_FORBIDDEN", message="Only global administrators can send recommendations.", status_code=403) try: parsed_id = UUID(recommendation_id) except (TypeError, ValueError) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) from exc row = await rt.db.get(IntelligenceRecommendation, parsed_id) subjects = {(item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions} if row is None or row.status == "ignored" or (row.subject_type, row.subject_id, row.audience_key) not in subjects: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) queued = await send_recommendation_notifications(rt.db, row, actor_id=rt.user.id) return {"id": str(row.id), "queued": queued, "model_calls": 0} @mcp_server.tool( name="intelligence_execute_recommendation", description=( "Hand a recommendation to an existing audited Helix action boundary. " "Notification actions enqueue through the existing outbox; other actions " "persist a structured, approval-aware handoff and open the existing " "domain workbench. Mutation twin of POST /api/v1/intelligence/" "recommendations/{id}/execute; no model call." ), ) async def intelligence_execute_recommendation(recommendation_id: str) -> dict: async with tool_runtime( "intelligence_execute_recommendation", {"recommendation_id": recommendation_id}, mutates=True, ) as rt: from uuid import UUID from app.core.exceptions import AppException from app.intelligence.models import IntelligenceRecommendation from app.intelligence.recommendations import ( send_recommendation_notifications, transition_recommendation, upgrade_recommendation_handoff, ) plan = await _intelligence_access(rt) if not plan.can_manage_recommendations: raise AppException(code="INTELLIGENCE_RECOMMENDATION_FORBIDDEN", message="Only global administrators can execute recommendations.", status_code=403) try: parsed_id = UUID(recommendation_id) except (TypeError, ValueError) as exc: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) from exc row = await rt.db.get(IntelligenceRecommendation, parsed_id) subjects = {(item.subject.subject_type, item.subject.subject_id, item.subject.audience_key) for item in plan.dimensions} if row is None or row.status == "ignored" or (row.subject_type, row.subject_id, row.audience_key) not in subjects: raise AppException(code="INTELLIGENCE_RECOMMENDATION_NOT_FOUND", message="Recommendation not found.", status_code=404) action_kind = str((row.action_contract or {}).get("action_kind") or "").strip().lower() if action_kind == "notify": queued = await send_recommendation_notifications(rt.db, row, actor_id=rt.user.id) # ``status`` travels on every other branch and clients merge this # response into their row, so omitting it here wrote ``undefined`` # over a valid status and hid every action button. The HTTP twin # was fixed; this one kept the removed shape, which is exactly the # drift the parity rule exists to prevent. A notify does not # advance the lifecycle — the UNCHANGED status is returned. return { "id": str(row.id), "action_kind": action_kind, "queued": queued, "status": row.status, "model_calls": 0, } # Keep the MCP twin behaviorally identical to the HTTP endpoint: this # records a human-initiated handoff, never performs a model-directed # domain write, and preserves the original RBAC/approval boundary. contract = upgrade_recommendation_handoff(row.action_contract) row.action_contract = contract transition_recommendation( row, status="in_progress", ignored_reason=None, actor_id=rt.user.id, ) await rt.db.commit() return { "id": str(row.id), "action_kind": action_kind, "handoff": contract, "status": row.status, "model_calls": 0, } @mcp_server.tool( name="cloud_scope_get_ai_analysis", description=( "Read the latest shared AI analysis and deterministic five-dimension " "health for one cloud subscription. Requires admin-domain authority, " "scope ownership, or an active unexpired grant. Exact twin of GET " "/api/v1/cmdb/scopes/{scope_id}/ai-analysis. Never calls a model." ), ) async def cloud_scope_get_ai_analysis(scope_id: str) -> dict: async with tool_runtime( "cloud_scope_get_ai_analysis", {"scope_id": scope_id}, ) as rt: from app.intelligence.cloud_scope_facade import ( get_cloud_scope_analysis_public, ) return await get_cloud_scope_analysis_public( rt.db, scope_id=scope_id, person_id=rt.user.id, role_names=rt.user.role_names, ) @mcp_server.tool( name="cloud_scope_get_ai_analysis_history", description=( "Read bounded newest-first shared analysis attempt history for one " "authorized cloud subscription. Returns summaries and generation " "metadata, not repeated full narratives. Exact twin of GET " "/api/v1/cmdb/scopes/{scope_id}/ai-analysis/history. Never calls a model." ), ) async def cloud_scope_get_ai_analysis_history( scope_id: str, limit: int = 50, ) -> dict: try: parsed_limit = int(limit or 50) except (TypeError, ValueError): parsed_limit = 50 bounded = max(1, min(parsed_limit, 365)) async with tool_runtime( "cloud_scope_get_ai_analysis_history", {"scope_id": scope_id, "limit": bounded}, ) as rt: from app.intelligence.cloud_scope_facade import ( get_cloud_scope_analysis_history_public, ) return await get_cloud_scope_analysis_history_public( rt.db, scope_id=scope_id, person_id=rt.user.id, role_names=rt.user.role_names, limit=bounded, ) @mcp_server.tool( name="cloud_scope_generate_ai_analysis", description=( "Explicitly generate or reuse the shared analysis for one authorized " "cloud subscription. This mutation may call the configured Bailian LLM " "and consume tokens; unchanged facts reuse the seven-day cache. force " "is restricted to global/cloud admins and requires confirm_force=true. " "Exact twin of POST /api/v1/cmdb/scopes/{scope_id}/ai-analysis/generate." ), ) async def cloud_scope_generate_ai_analysis( scope_id: str, force: bool = False, confirm_force: bool = False, ) -> dict: args = { "scope_id": scope_id, "force": force, "confirm_force": confirm_force, } async with tool_runtime( "cloud_scope_generate_ai_analysis", args, mutates=True, ) as rt: from app.intelligence.cloud_scope_facade import ( generate_cloud_scope_analysis_public, ) return await generate_cloud_scope_analysis_public( rt.db, scope_id=scope_id, person_id=rt.user.id, role_names=rt.user.role_names, force=force, confirm_force=confirm_force, ) # ────────────────────────────────────────────────────────────────────── # Unified cloud resource directory. These adapters intentionally call the # endpoint bodies directly: authorization, visibility, optimistic locking, # auditing and transaction semantics therefore cannot drift between HTTP and # MCP. Provider assets remain read-only; mutations affect Helix governance. # ────────────────────────────────────────────────────────────────────── def _cloud_directory_request(method: str, path: str): from starlette.requests import Request request = Request({ "type": "http", "method": method, "path": path, "headers": [], "query_string": b"", }) request.state.trace_id = _trace_str() return request def _cloud_directory_payload(response: Any) -> Any: if isinstance(response, dict) and "data" in response: payload = response["data"] meta = response.get("meta") or {} if "total" in meta: return { "items": payload, "meta": { key: meta[key] for key in ("page", "page_size", "total") if key in meta }, } return payload return response def _cloud_directory_data(response: Any) -> dict[str, Any]: return cast(dict[str, Any], _cloud_directory_payload(response)) def _cloud_directory_list_data(response: Any) -> list[Any]: return cast(list[Any], _cloud_directory_payload(response)) @mcp_server.tool(name="cloud_resource_directory_list", description="List permission-scoped cloud resources across every discovered provider and resource type. Exact twin of GET /api/v1/cmdb/resource-directory/resources; read-only.") async def cloud_resource_directory_list( q: str | None = None, provider: list[str] | None = None, category: list[str] | None = None, resource_type: list[str] | None = None, status: list[str] | None = None, sensitivity: list[str] | None = None, governance_status: list[str] | None = None, finding_severity: list[str] | None = None, integration_id: str | None = None, scope_external_id: str | None = None, include_deleted: bool = False, sort: str = "last_seen_desc", page: int = 1, page_size: int = 50, ) -> dict: args = locals().copy() async with tool_runtime("cloud_resource_directory_list", args) as rt: from app.domains.cmdb.resource_directory_router import list_resources response = await list_resources( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/resources"), user=rt.user, db=rt.db, **args, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_get", description="Get one visible cloud resource with normalized metadata, governance and role-sanitized raw metadata. Exact HTTP twin; read-only.") async def cloud_resource_directory_get(resource_id: str) -> dict: async with tool_runtime("cloud_resource_directory_get", {"resource_id": resource_id}) as rt: from app.domains.cmdb.resource_directory_router import get_resource response = await get_resource( resource_id=_u(resource_id), request=_cloud_directory_request("GET", f"/api/v1/cmdb/resource-directory/resources/{resource_id}"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_history", description="Read immutable discovery and metadata-change history for one visible cloud resource. Exact HTTP twin; read-only.") async def cloud_resource_directory_history(resource_id: str, page: int = 1, page_size: int = 50) -> dict: args = {"resource_id": resource_id, "page": page, "page_size": page_size} async with tool_runtime("cloud_resource_directory_history", args) as rt: from app.domains.cmdb.resource_directory_router import get_resource_history response = await get_resource_history( resource_id=_u(resource_id), page=page, page_size=page_size, request=_cloud_directory_request("GET", f"/api/v1/cmdb/resource-directory/resources/{resource_id}/history"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_types", description="List dynamically discovered cloud resource types visible to the caller. Exact HTTP twin; read-only.") async def cloud_resource_directory_types(provider: str | None = None, category: str | None = None) -> list: args = {"provider": provider, "category": category} async with tool_runtime("cloud_resource_directory_types", args) as rt: from app.domains.cmdb.resource_directory_router import list_resource_types response = await list_resource_types( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/types"), user=rt.user, db=rt.db, **args, ) return _cloud_directory_list_data(response) @mcp_server.tool(name="cloud_resource_directory_summary", description="Summarize the caller's visible cloud inventory by provider, category, type and lifecycle status. Exact HTTP twin; read-only.") async def cloud_resource_directory_summary( provider: list[str] | None = None, category: list[str] | None = None, integration_id: str | None = None, scope_external_id: str | None = None, ) -> dict: args = { "provider": provider, "category": category, "integration_id": integration_id, "scope_external_id": scope_external_id, } async with tool_runtime("cloud_resource_directory_summary", args) as rt: from app.domains.cmdb.resource_directory_router import get_summary response = await get_summary( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/summary"), provider=provider, category=category, integration_id=integration_id, scope_external_id=scope_external_id, user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_update_governance", description="Update Helix-local ownership, classification, risk, lifecycle or exception metadata for a cloud resource. Cloud-admin only; never mutates the provider asset.") async def cloud_resource_directory_update_governance(resource_id: str, governance: dict[str, Any]) -> dict: args = {"resource_id": resource_id, "governance": governance} async with tool_runtime("cloud_resource_directory_update_governance", args, mutates=True) as rt: from app.domains.cmdb.resource_directory_router import update_governance from app.domains.cmdb.resource_directory_schemas import GovernancePatch response = await update_governance( resource_id=_u(resource_id), body=GovernancePatch.model_validate(governance), request=_cloud_directory_request("PATCH", f"/api/v1/cmdb/resource-directory/resources/{resource_id}/governance"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_findings", description="List permission-scoped deterministic cloud-governance findings. Exact HTTP twin; read-only.") async def cloud_resource_directory_findings( status: list[str] | None = None, severity: list[str] | None = None, rule_key: str | None = None, assigned_to_person_id: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: args = locals().copy() async with tool_runtime("cloud_resource_directory_findings", args) as rt: from app.domains.cmdb.resource_directory_router import list_findings response = await list_findings( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/findings"), user=rt.user, db=rt.db, **args, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_act_on_finding", description="Acknowledge, reopen or time-bound suppress a visible governance finding. Uses the same role gates, audit and validation as HTTP; mutates Helix only.") async def cloud_resource_directory_act_on_finding(finding_id: str, action: dict[str, Any]) -> dict: args = {"finding_id": finding_id, "action": action} async with tool_runtime("cloud_resource_directory_act_on_finding", args, mutates=True) as rt: from app.domains.cmdb.resource_directory_router import act_on_finding from app.domains.cmdb.resource_directory_schemas import FindingAction response = await act_on_finding( finding_id=_u(finding_id), body=FindingAction.model_validate(action), request=_cloud_directory_request("POST", f"/api/v1/cmdb/resource-directory/findings/{finding_id}/action"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_rules", description="List deterministic cloud-governance rules. Full-directory roles only; exact HTTP twin and read-only.") async def cloud_resource_directory_rules(enabled: bool | None = None) -> list: async with tool_runtime("cloud_resource_directory_rules", {"enabled": enabled}) as rt: from app.domains.cmdb.resource_directory_router import list_rules response = await list_rules( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/rules"), enabled=enabled, user=rt.user, db=rt.db, ) return _cloud_directory_list_data(response) @mcp_server.tool(name="cloud_resource_directory_create_rule", description="Create a bounded deterministic cloud-governance rule. Cloud-admin only; audited Helix-local mutation.") async def cloud_resource_directory_create_rule(rule: dict[str, Any]) -> dict: async with tool_runtime("cloud_resource_directory_create_rule", {"rule": rule}, mutates=True) as rt: from app.domains.cmdb.resource_directory_router import create_rule from app.domains.cmdb.resource_directory_schemas import GovernanceRuleCreate response = await create_rule( body=GovernanceRuleCreate.model_validate(rule), request=_cloud_directory_request("POST", "/api/v1/cmdb/resource-directory/rules"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_update_rule", description="Update a deterministic cloud-governance rule with optimistic version checking. Cloud-admin only; audited Helix-local mutation.") async def cloud_resource_directory_update_rule(rule_id: str, rule: dict[str, Any]) -> dict: args = {"rule_id": rule_id, "rule": rule} async with tool_runtime("cloud_resource_directory_update_rule", args, mutates=True) as rt: from app.domains.cmdb.resource_directory_router import update_rule from app.domains.cmdb.resource_directory_schemas import GovernanceRulePatch response = await update_rule( rule_id=_u(rule_id), body=GovernanceRulePatch.model_validate(rule), request=_cloud_directory_request("PATCH", f"/api/v1/cmdb/resource-directory/rules/{rule_id}"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_directory_rollups", description="Read daily cloud-directory governance rollups for dashboards and trends. Full-directory roles only; exact HTTP twin.") async def cloud_resource_directory_rollups( days: int = 90, provider: str | None = None, category: str | None = None, resource_type: str | None = None, scope_external_id: str | None = None, ) -> list: args = locals().copy() async with tool_runtime("cloud_resource_directory_rollups", args) as rt: from app.domains.cmdb.resource_directory_router import list_rollups response = await list_rollups( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/rollups"), user=rt.user, db=rt.db, **args, ) return _cloud_directory_list_data(response) @mcp_server.tool(name="cloud_resource_report_subscription_get", description="Read the caller's cloud-directory report delivery preferences. Exact HTTP twin; recipient-owned and read-only.") async def cloud_resource_report_subscription_get() -> dict: async with tool_runtime("cloud_resource_report_subscription_get", {}) as rt: from app.domains.cmdb.resource_reporting_router import get_subscription response = await get_subscription( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/reports/subscription"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_report_subscription_update", description="Set weekly, monthly or off cloud-directory report delivery, channels, timezone and scope. Recipient-owned, permission-checked and audited.") async def cloud_resource_report_subscription_update(subscription: dict[str, Any]) -> dict: async with tool_runtime("cloud_resource_report_subscription_update", {"subscription": subscription}, mutates=True) as rt: from app.domains.cmdb.resource_reporting_router import SubscriptionUpdate, put_subscription response = await put_subscription( body=SubscriptionUpdate.model_validate(subscription), request=_cloud_directory_request("PUT", "/api/v1/cmdb/resource-directory/reports/subscription"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool( name="list_asset_location_admins", description=( "Who administers each managed asset location — the people who hold " "`asset_admin` for that region. Answers 「北京的资产管理员是谁」" "「谁能处理这台机器」「我管哪些位置」. Each row is {location_id, " "code, name, is_active, admins:[{person_id, person_name, " "work_email, is_primary}]}. The primary is who single-recipient " "flows address (inventory confirmation, recycling); the rest are " "included in broadcasts. A location's administrator list IS the " "asset_admin grant set, so this doubles as 「这个角色现在有谁」 " "scoped by region. Read-only, and the write side is deliberately " "not exposed over MCP — granting a role is a global-tier action " "that belongs behind the browser-session-gated HTTP endpoint. " "Requires asset_admin or global_admin, the same gate as HTTP " "GET /api/v1/assets/locations?with_admins=true." ), ) async def list_asset_location_admins(include_inactive: bool = False) -> dict: async with tool_runtime( "list_asset_location_admins", {"include_inactive": include_inactive}, ) as rt: _require_asset_admin(rt) from app.domains.asset.location_admin_sync import ( list_location_admin_ids_bulk, ) from app.domains.asset.service import AssetService locations = await AssetService(rt.db).list_locations( include_inactive=include_inactive, ) admins_by_location = await list_location_admin_ids_bulk( rt.db, [loc.id for loc in locations], ) return { "items": [ { "location_id": str(loc.id), "code": loc.code, "name": loc.name, "is_active": bool(loc.is_active), "admins": [ { "person_id": str(a["person_id"]), "person_name": a["person_name"], "work_email": a["work_email"], "is_primary": a["is_primary"], } for a in admins_by_location.get(loc.id, []) ], } for loc in locations ], } @mcp_server.tool( name="get_asset_location_coverage", description=( "Administrator coverage across asset locations, with what each one " "carries. Answers 「哪些位置没有资产管理员」「有多少资产没人管」. " "Returns `summary` {total, covered, uncovered, assets_uncovered, " "people_uncovered} and `items` with per-location asset_count, " "person_count and an `is_uncovered` flag. Uncovered means the " "location is active, holds assets or people, and has nobody " "assigned — every write there falls through to the global tier and " "inventory has no one to address. A retired location with nothing " "left at it is NOT flagged. Person counts run through the managed-" "location resolver, so 'Santa Clara' and 'San Jose' both count " "toward 美国 rather than reporting zero. Read-only; exact twin of " "HTTP GET /api/v1/assets/locations/coverage." ), ) async def get_asset_location_coverage() -> dict: async with tool_runtime("get_asset_location_coverage", {}) as rt: _require_asset_admin(rt) from app.domains.asset.location_coverage import ( build_location_coverage, ) coverage = await build_location_coverage(rt.db) return { "summary": coverage["summary"], "items": [ {**row, "location_id": str(row["location_id"])} for row in coverage["items"] ], } @mcp_server.tool(name="cloud_resource_report_generate", description="Generate a frozen, permission-scoped cloud-directory report. Optional AI interpretation is cloud-admin only and may consume model tokens; exact HTTP twin.") async def cloud_resource_report_generate(days: int = 30, include_ai: bool = False, scope_config: dict[str, Any] | None = None) -> dict: args = {"days": days, "include_ai": include_ai, "scope_config": scope_config} async with tool_runtime("cloud_resource_report_generate", args, mutates=True) as rt: from app.domains.cmdb.resource_reporting_router import GenerateReport, generate_report response = await generate_report( body=GenerateReport.model_validate(args), request=_cloud_directory_request("POST", "/api/v1/cmdb/resource-directory/reports/generate"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_report_runs", description="List the caller's frozen cloud-directory report history. Recipient-owned and read-only; exact HTTP twin.") async def cloud_resource_report_runs(page: int = 1, page_size: int = 20) -> dict: args = {"page": page, "page_size": page_size} async with tool_runtime("cloud_resource_report_runs", args) as rt: from app.domains.cmdb.resource_reporting_router import list_runs response = await list_runs( request=_cloud_directory_request("GET", "/api/v1/cmdb/resource-directory/reports/runs"), user=rt.user, db=rt.db, **args, ) return _cloud_directory_data(response) @mcp_server.tool(name="cloud_resource_report_get", description="Read one frozen cloud-directory report owned by the caller, including facts and interpretations. Exact HTTP twin; read-only.") async def cloud_resource_report_get(run_id: str) -> dict: async with tool_runtime("cloud_resource_report_get", {"run_id": run_id}) as rt: from app.domains.cmdb.resource_reporting_router import get_run response = await get_run( run_id=_u(run_id), request=_cloud_directory_request("GET", f"/api/v1/cmdb/resource-directory/reports/runs/{run_id}"), user=rt.user, db=rt.db, ) return _cloud_directory_data(response) # ════════════════════════════════════════════════════════════════════ # Bounty — the business owner's confirmation surface (self-scope) # ════════════════════════════════════════════════════════════════════ # # Exact twins of ``/api/v1/me/bounty/*``. Self-scope only: every one goes # through ``self_service.load_my_report``, which 403s a report assigned to # somebody else — an AI client holding the caller's token can no more read # a colleague's report than the caller's own browser can. The admin # console surface (`/api/v1/bounty/*`) is deliberately NOT mirrored here: # it decides other people's rewards and reads external submitters' contact # details, which is authz-wise nothing like answering "is this bug real". @mcp_server.tool( name="list_my_bounty_confirmations", description=( "List the externally-reported security vulnerabilities assigned to " "the caller for business confirmation, with what each one still " "needs: whether it is awaiting their verdict, whether a fix is " "outstanding, and whether the fix deadline has passed. Use this to " "answer 「有没有安全问题等我确认」「我这边的漏洞修复到期了吗」. " "Self-scope: only the caller's own assignments. Never includes the " "external researcher's identity or the bounty amount — those are " "security-team-only. Read-only." ), ) async def list_my_bounty_confirmations(include_closed: bool = False) -> dict: async with tool_runtime( "list_my_bounty_confirmations", {"include_closed": include_closed}, ) as rt: from app.domains.bounty import self_service return await self_service.list_my_reports( rt.db, person_id=rt.user.id, include_closed=include_closed, ) @mcp_server.tool( name="get_my_bounty_confirmation", description=( "Full detail of one security vulnerability report assigned to the " "caller: the internal summary written for the business owner, " "reproduction steps, affected system and URLs, severity, " "remediation state, fix deadline, and the discussion shared with " "the security team. Pass the report id or its code (BB-2026-001). " "403s if the report is assigned to somebody else. Read-only." ), ) async def get_my_bounty_confirmation(report_id: str) -> dict: async with tool_runtime( "get_my_bounty_confirmation", {"report_id": report_id}, ) as rt: from app.domains.bounty import self_service from app.domains.bounty.mcp_support import resolve_my_report_id resolved = await resolve_my_report_id( rt.db, report_id, person_id=rt.user.id, ) return await self_service.my_report_detail( rt.db, resolved, person=rt.user, ) @mcp_server.tool( name="confirm_bounty_report", description=( "Submit the business owner's verdict on a security vulnerability " "report assigned to the caller. `decision` is 'valid' (it is a real " "issue), 'false_positive' (it is not) or 'need_info'. Optionally " "add `note` (required in practice for a false positive — the " "security team quotes the reasoning back to the researcher), " "`suggested_severity` (a tier key such as 'medium' — advisory; the " "security team sets the final severity and the reward) and " "`fix_eta` (YYYY-MM-DD). Does NOT close the report or decide the " "bounty: it records the verdict and hands the report back to the " "security team. Mutating; self-scope only." ), ) async def confirm_bounty_report( report_id: str, decision: str, note: str | None = None, suggested_severity: str | None = None, fix_eta: str | None = None, ) -> dict: args = { "report_id": report_id, "decision": decision, "suggested_severity": suggested_severity, "fix_eta": fix_eta, } async with tool_runtime( "confirm_bounty_report", args, mutates=True, ) as rt: from app.domains.bounty import notify, self_service from app.domains.bounty.mcp_support import ( parse_optional_date, resolve_my_report_id, ) resolved = await resolve_my_report_id( rt.db, report_id, person_id=rt.user.id, ) report = await self_service.respond( rt.db, resolved, person=rt.user, decision=decision, note=note, suggested_severity=suggested_severity, fix_eta=parse_optional_date(fix_eta, field="fix_eta"), remediation_status=None, ) notified = await notify.notify_security_of_owner_response( rt.db, report, owner=rt.user, ) payload = await self_service.my_report_detail( rt.db, report.id, person=rt.user, ) payload["security_notified"] = notified return payload @mcp_server.tool( name="update_bounty_remediation", description=( "Move the fix forward on a security vulnerability report assigned " "to the caller. `remediation_status` is 'pending' (not started), " "'in_progress' or 'fixed'. Optionally set `fix_eta` (YYYY-MM-DD) " "and a `note`. Deciding NOT to fix something ('won't fix' / " "'accept the risk') is deliberately not available here — that is a " "security-team call; leave a comment asking for it instead. " "Mutating; self-scope only." ), ) async def update_bounty_remediation( report_id: str, remediation_status: str, fix_eta: str | None = None, note: str | None = None, ) -> dict: args = { "report_id": report_id, "remediation_status": remediation_status, "fix_eta": fix_eta, } async with tool_runtime( "update_bounty_remediation", args, mutates=True, ) as rt: from app.domains.bounty import self_service from app.domains.bounty.mcp_support import ( parse_optional_date, resolve_my_report_id, ) resolved = await resolve_my_report_id( rt.db, report_id, person_id=rt.user.id, ) report = await self_service.update_remediation( rt.db, resolved, person=rt.user, remediation_status=remediation_status, fix_eta=parse_optional_date(fix_eta, field="fix_eta"), note=note, ) return await self_service.my_report_detail( rt.db, report.id, person=rt.user, ) @mcp_server.tool( name="comment_on_bounty_report", description=( "Leave a comment for the security team on a vulnerability report " "assigned to the caller — ask a question, add context, or say the " "area belongs to someone else and who to ask. Always shared with " "the security team. Mutating; self-scope only." ), ) async def comment_on_bounty_report(report_id: str, body: str) -> dict: async with tool_runtime( "comment_on_bounty_report", {"report_id": report_id}, mutates=True, ) as rt: from app.domains.bounty import self_service from app.domains.bounty.mcp_support import resolve_my_report_id resolved = await resolve_my_report_id( rt.db, report_id, person_id=rt.user.id, ) return await self_service.add_comment( rt.db, resolved, person=rt.user, body=body, ) # ════════════════════════════════════════════════════════════════════ # Internal system registry (系统台账) # ════════════════════════════════════════════════════════════════════ # # One self-scope tool + three admin-tier reads. Writes stay HTTP-only: # creating an entry and naming its owner decides who is accountable for a # running system, and the offboarding blocker is built on that answer — # an authority a stolen PAT should not reach. Same reasoning as the # asset-location administrator grant next door. def _require_registry_read(role_names) -> None: """Mirror the HTTP route's ``can_view_system_registry`` gate.""" from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_system_registry if not can_view_system_registry(role_names): raise ForbiddenError( "Reading the internal system registry requires the security " "lane, HR or global_admin. An employee asking what THEY own " "should use list_my_systems instead." ) @mcp_server.tool( name="list_my_systems", description=( "The internal systems the caller is accountable for — as primary " "owner or as backup owner. Answers 「我名下有哪些系统」「离职前我要" "交接什么系统」. Each row carries the system's name, what it is, " "whether it is reachable from the internet, and whether the caller " "is the primary or the backup owner. Self-scope: no role needed and " "no other person's systems. Read-only." ), ) async def list_my_systems() -> dict: async with tool_runtime("list_my_systems", {}) as rt: from app.domains.system_registry import service as svc from app.domains.system_registry.serializers import serialize_system rows = await svc.systems_owned_by(rt.db, rt.user.id) items = [] for system in rows: item = serialize_system(system, include_notes=False) item["my_role"] = ( "owner" if system.owner_person_id == rt.user.id else "backup_owner" ) items.append(item) return { "items": items, "owned": len([i for i in items if i["my_role"] == "owner"]), "backup": len( [i for i in items if i["my_role"] == "backup_owner"], ), } @mcp_server.tool( name="list_registered_systems", description=( "The register of first-party systems we build and run, with who " "owns each one. Answers 「有哪些系统没有负责人」「对外可达的系统有" "哪些」「XX 系统归谁」. `ownerless=true` returns exactly the " "governance gap: live systems with nobody named. `exposure` " "narrows to 'public' / 'authenticated' / 'internal' / 'unknown' " "('unknown' means nobody has verified it, which is NOT the same as " "internal). `q` matches name, slug or hostname. `summary` carries " "the ownership-coverage counts. Admin-tier read; read-only." ), ) async def list_registered_systems( q: str | None = None, status: str | None = None, kind: str | None = None, exposure: str | None = None, ownerless: bool | None = None, in_bounty_scope: bool | None = None, budget_track: str | None = None, page: int = 1, page_size: int = 50, ) -> dict: args = { "q": q, "status": status, "kind": kind, "exposure": exposure, "ownerless": ownerless, "in_bounty_scope": in_bounty_scope, "budget_track": budget_track, "page": page, "page_size": page_size, } async with tool_runtime("list_registered_systems", args) as rt: from app.domains.system_registry import service as svc from app.domains.system_registry.serializers import serialize_system _require_registry_read(rt.user.role_names) rows, total = await svc.list_systems( rt.db, q=q, status=status, kind=kind, exposure=exposure, ownerless=ownerless, in_bounty_scope=in_bounty_scope, budget_track=budget_track, page=max(1, page), page_size=min(200, max(1, page_size)), ) return { "items": [serialize_system(r) for r in rows], "total": total, "page": page, "summary": await svc.coverage_summary(rt.db), } @mcp_server.tool( name="get_registered_system", description=( "Full detail of one registered internal system plus its ownership " "history — who has been responsible for it and since when. Pass " "the system id or its slug ('ticard', 'tidb-cloud'). Admin-tier " "read; read-only." ), ) async def get_registered_system(system: str) -> dict: async with tool_runtime("get_registered_system", {"system": system}) as rt: from app.core.exceptions import NotFoundError from app.domains.system_registry import service as svc from app.domains.system_registry.serializers import ( serialize_event, serialize_system, ) _require_registry_read(rt.user.role_names) # An AI client passes whatever the user said — "ticard" as often # as a UUID. Decide by shape rather than by catching the id # lookup's error: ``_u`` raises a VALIDATION error, so a bare # ``except ValueError`` would let a slug surface as # "Invalid UUID" instead of resolving. row = None probe = (system or "").strip() try: parsed = UUID(probe) except (ValueError, AttributeError, TypeError): parsed = None if parsed is not None: row = await svc.get_system(rt.db, parsed) else: row = await svc.get_by_slug(rt.db, probe) if row is None: raise NotFoundError("System", system) payload = serialize_system(row) payload["events"] = [ serialize_event(e) for e in await svc.list_events(rt.db, row.id, limit=50) ] return payload @mcp_server.tool( name="find_system_owner", description=( "Who to ask about a hostname or system name. Answers 「clinic." "pingcap.com 是谁负责的」「这个域名归哪个团队」 — pass the host " "(a full URL is fine) or a partial name and get the matching " "registered systems with their owners. Returns candidates for a " "human to confirm, not a single verdict: a substring match is a " "suggestion, and treating it as fact would put the wrong person's " "name on a security finding. Admin-tier read; read-only." ), ) async def find_system_owner(host: str) -> dict: async with tool_runtime("find_system_owner", {"host": host}) as rt: from app.domains.system_registry import service as svc from app.domains.system_registry.serializers import ( serialize_system_option, ) _require_registry_read(rt.user.role_names) rows = await svc.suggest_for_host(rt.db, host, limit=10) return { "query": host, "candidates": [serialize_system_option(r) for r in rows], "count": len(rows), } # ────────────────────────────────────────────────────────────────────── # 审批流程台账 (approval-flow registry) # # ``find_approval_flow_owner`` and ``list_approval_flows`` are gated on # nothing beyond being signed in, because they read the PUBLISHED list — # the same rows every employee sees on the all-staff page, through the # same serializer. 「报销走哪个流程」「这个审批谁负责」 is a question the # platform exists to answer, and hiding it behind a role would send # people back to asking in a group chat. # # The operational view is a separate, gated tool: sync bookkeeping, # inference evidence, unpublished rows and ownership gaps are governance # data, and 「哪些流程没有负责人」 is a finding, not a directory lookup. # Writes stay HTTP-only: naming a flow's owner decides who a half-year # confirmation chases, which is not something a stolen PAT should reach. # ────────────────────────────────────────────────────────────────────── def _require_approval_registry_read(role_names) -> None: """Mirror the HTTP route's ``can_view_approval_registry`` gate.""" from app.core.exceptions import ForbiddenError from app.core.rbac import can_view_approval_registry if not can_view_approval_registry(role_names): raise ForbiddenError( "Reading the approval-flow registry's operational view " "requires the governance lane, HR or global_admin. Anyone " "asking which approval to use or who owns it should use " "find_approval_flow_owner instead." ) @mcp_server.tool( name="find_approval_flow_owner", description=( "Who is responsible for an approval flow, and which flow to use. " "Answers 「报销审批谁负责」「这个流程找谁」「有哪些审批流程」 — pass " "part of the flow's name (Chinese or English) or leave `query` " "empty to list every published flow grouped by category. Each hit " "carries the owner's name and email plus an `owner_state`: " "`confirmed` (the owner said so themselves, recently), " "`unconfirmed` (a candidate derived from who actually approves " "it — say so rather than presenting it as settled), `stale` (last " "confirmed too long ago), `departed` (the named owner has left), " "`unassigned` (nobody named). Each hit also carries " "`feishu_admins` — the 流程管理员 Feishu's own console lists for " "that definition, each with `active`. When `owner_state` is " "`unassigned` or `departed` but a live admin is listed, point the " "employee at the admin and say where the name came from: it is " "who administers the flow in Feishu, not somebody who has agreed " "to answer for it here. Only when there is neither an owner nor a " "live admin is the honest answer that IT does not know yet — " "never guess one. Any signed-in employee; read-only." ), ) async def find_approval_flow_owner(query: str = "", limit: int = 20) -> dict: async with tool_runtime( "find_approval_flow_owner", {"query": query, "limit": limit}, ) as rt: from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import ( serialize_published, ) rows = await svc.list_published(rt.db) needle = (query or "").strip().lower() if needle: rows = [ r for r in rows if needle in (r.name or "").lower() or needle in (r.category or "").lower() or needle in (r.description or "").lower() ] capped = rows[: min(100, max(1, limit))] return { "query": query, "matches": [serialize_published(r) for r in capped], "count": len(capped), "total_matched": len(rows), # A truncated answer that does not say so reads as "that is # all of them". "truncated": len(rows) > len(capped), } @mcp_server.tool( name="list_approval_flows", description=( "The approval-flow register's operational view: every flow the " "company runs in Feishu, with its owner, how that owner was " "established, and when they last confirmed it. Answers 「哪些审批" "流程没有负责人」「哪些负责人还没确认」「负责人已经离职的流程」. " "`ownerless=true` returns exactly the governance gap — live flows " "with nobody named OR whose named owner has left. " "`machine_owner=true` returns every owner a machine established " "and no human has stood behind — both the Feishu 流程管理员 the " "register adopted (`owner_source='feishu_admin'`) and the " "candidates inferred from approval traffic " "(`owner_source='inferred'`); pass `owner_source` to ask for one " "kind only. Each row also carries `feishu_admin_drift`: true " "means Helix and the Feishu approval console name DIFFERENT " "people for this flow — the register's owner changed (a " "half-year hand-over, an offboarding, an IT re-point) and the " "console still lists the previous person as 流程管理员. Feishu " "has no write API for 流程管理员 at all (the approval v4 " "definition surface is create / get / subscribe), so this can " "only be fixed by a human in the Feishu console — say that " "plainly rather than offering to do it, and note that the " "successor holds the accountability without the console rights " "until somebody does. `summary.feishu_admin_drift` is the count. " "Governance-tier read; read-only " "— ownership changes go through the HTTP surface." ), ) async def list_approval_flows( q: str = "", status: str = "", owner_source: str = "", ownerless: bool | None = None, machine_owner: bool | None = None, unconfirmed: bool | None = None, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_approval_flows", { "q": q, "status": status, "owner_source": owner_source, "ownerless": ownerless, "machine_owner": machine_owner, "unconfirmed": unconfirmed, "page": page, "page_size": page_size, }, ) as rt: from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import serialize_flow _require_approval_registry_read(rt.user.role_names) rows, total = await svc.list_flows( rt.db, q=q or None, status=status or None, owner_source=owner_source or None, ownerless=ownerless, machine_owner=machine_owner, unconfirmed=unconfirmed, page=max(1, page), page_size=min(200, max(1, page_size)), ) return { "items": [serialize_flow(r) for r in rows], "total": total, "page": max(1, page), "summary": await svc.coverage_summary(rt.db), } @mcp_server.tool( name="list_my_approval_flows", description=( "The approval flows the caller is responsible for. Answers " "「我名下有哪些审批流程」「我要确认哪些流程」「离职前我要交接什么" "审批」. `needs_confirmation` counts the ones still waiting for the " "caller to confirm they own them. Self-scope: no role needed and " "no other person's flows. Read-only — confirming or handing one " "over goes through the HTTP surface." ), ) async def list_my_approval_flows() -> dict: async with tool_runtime("list_my_approval_flows", {}) as rt: from app.domains.approval_registry import constants as const from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import serialize_flow rows = await svc.flows_owned_by(rt.db, rt.user.id) return { "items": [serialize_flow(r) for r in rows], "total": len(rows), "needs_confirmation": len( [ r for r in rows if r.owner_confirmed_at is None or r.owner_source == const.OWNER_SOURCE_INFERRED ], ), } @mcp_server.tool( name="get_my_approval_flow_confirmation", description=( "The half-yearly ownership check as it stands for the caller: " "which round is running, when it is due, and which of their " "approval flows are still waiting for an answer. Answers 「我这轮" "还有几条没确认」「确认截止到什么时候」. Returns `round: null` when " "no round is open — the flows are still theirs, nobody is being " "asked right now. Self-scope; read-only." ), ) async def get_my_approval_flow_confirmation() -> dict: async with tool_runtime("get_my_approval_flow_confirmation", {}) as rt: from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import service as svc round_row = await conf.current_open_round(rt.db) if round_row is None: return {"round": None, "items": [], "pending": 0} items = await conf.items_for_owner(rt.db, round_row.id, rt.user.id) flows = { f.id: f for f in await svc.flows_by_ids(rt.db, [i.flow_id for i in items]) } return { "round": { "label": round_row.label, "due_at": ( round_row.due_at.isoformat() if round_row.due_at else None ), }, "items": [ { "flow_id": str(i.flow_id), "flow_name": ( flows[i.flow_id].name if i.flow_id in flows else None ), "state": i.state, } for i in items ], "pending": len([i for i in items if i.state == "pending"]), } @mcp_server.tool( name="confirm_my_approval_flow", description=( "Confirm that an approval flow recorded against the caller is " "still theirs. This is the only thing that turns a name in the " "register into confirmed coverage, and it is what the half-yearly " "card asks for. Self-scope: only the caller's own flows, and it " "counts towards the open round when there is one. Idempotent." ), ) async def confirm_my_approval_flow(flow_id: str, note: str = "") -> dict: async with tool_runtime( "confirm_my_approval_flow", {"flow_id": flow_id}, mutates=True, ) as rt: import uuid as _uuid from app.core.exceptions import NotFoundError from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import serialize_flow flow = await svc.get_flow(rt.db, _uuid.UUID(flow_id)) # 404, never 403: a 403 would confirm the row exists and that # somebody else owns it. if flow.owner_person_id != rt.user.id: raise NotFoundError("ApprovalFlow", flow_id) person = await svc.load_person(rt.db, rt.user.id) outcome = await conf.record_self_answer( rt.db, flow, actor=person, keep=True, note=note or None, via="assistant", ) await rt.db.commit() return { "flow": serialize_flow(await svc.get_flow(rt.db, flow.id)), "round": outcome.get("round"), } @mcp_server.tool( name="describe_my_approval_flow", description=( "Write (or clear) the one-line 用途说明 for an approval flow the " "caller owns — what it is for and when a colleague should use " "it. This is what the all-staff directory prints under the flow " "name; every flow in this tenant shipped with it empty, so the " "page could say who to ask and not what the thing was for. " "Answers 「我名下那个流程该怎么描述」「帮我补一句说明」. Self-scope: " "only the caller's own flows. Pass an empty string to clear. " "Keep it to one sentence a colleague can act on — this is not " "the place for the approval's internal policy." ), ) async def describe_my_approval_flow(flow_id: str, description: str = "") -> dict: async with tool_runtime( "describe_my_approval_flow", {"flow_id": flow_id}, mutates=True, ) as rt: import uuid as _uuid from app.core.exceptions import NotFoundError from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import serialize_flow flow = await svc.get_flow(rt.db, _uuid.UUID(flow_id)) # 404, never 403 — same reasoning as the confirm tool above. if flow.owner_person_id != rt.user.id: raise NotFoundError("ApprovalFlow", flow_id) person = await svc.load_person(rt.db, rt.user.id) await svc.set_description( rt.db, flow, description=description or None, actor=person, ) await rt.db.commit() return {"flow": serialize_flow(await svc.get_flow(rt.db, flow.id))} @mcp_server.tool( name="transfer_my_approval_flow", description=( "Hand an approval flow the caller owns to a named successor. " "Answers 「这个流程以后归某某管」. A successor is REQUIRED — 「不是我」 " "without saying who leaves the register no better off, and the " "platform will not guess. The successor must be an active " "employee; their ownership lands as IT-assigned rather than " "confirmed, because a handover is a decision about who should " "answer, not their own statement that they do. Self-scope." ), ) async def transfer_my_approval_flow( flow_id: str, to_person_id: str, note: str = "", ) -> dict: async with tool_runtime( "transfer_my_approval_flow", {"flow_id": flow_id, "to_person_id": to_person_id}, mutates=True, ) as rt: import uuid as _uuid from app.core.exceptions import NotFoundError from app.domains.approval_registry import confirmation as conf from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import serialize_flow flow = await svc.get_flow(rt.db, _uuid.UUID(flow_id)) if flow.owner_person_id != rt.user.id: raise NotFoundError("ApprovalFlow", flow_id) person = await svc.load_person(rt.db, rt.user.id) outcome = await conf.record_self_answer( rt.db, flow, actor=person, keep=False, transfer_to_person_id=_uuid.UUID(to_person_id), note=note or None, via="assistant", ) await rt.db.commit() return { "flow": serialize_flow(await svc.get_flow(rt.db, flow.id)), "round": outcome.get("round"), } @mcp_server.tool( name="request_new_approval_flow", description=( "Ask IT to create an approval flow the company does not have yet. " "Answers 「公司好像没有这个流程,能不能建一个」. Search " "find_approval_flow FIRST — most of the time one already exists and " "using it is faster. `purpose` is required and matters: IT decides " "from it, and once the flow is built that sentence becomes its " "published description, which is what the next employee reads when " "choosing a flow. The caller becomes the flow's owner unless they " "nominate somebody else. Self-scope." ), ) async def request_new_approval_flow( proposed_name: str, purpose: str, proposed_category: str = "", proposed_owner_person_id: str = "", ) -> dict: async with tool_runtime( "request_new_approval_flow", { "proposed_name": proposed_name, "purpose": purpose, "proposed_category": proposed_category, }, mutates=True, ) as rt: import uuid as _uuid from app.domains.approval_registry import notify from app.domains.approval_registry import requests as reqs from app.domains.approval_registry import service as svc from app.domains.approval_registry.serializers import serialize_request person = await svc.load_person(rt.db, rt.user.id) row = await reqs.submit_request( rt.db, requester=person, proposed_name=proposed_name, purpose=purpose, proposed_category=proposed_category or None, proposed_owner_person_id=( _uuid.UUID(proposed_owner_person_id) if proposed_owner_person_id else None ), ) await rt.db.commit() row = await reqs.get_request(rt.db, row.id) notified = 0 try: notified = await notify.notify_flow_request_submitted(rt.db, row) await rt.db.commit() except Exception: # noqa: BLE001 — the request stands either way await rt.db.rollback() payload = serialize_request(await reqs.get_request(rt.db, row.id)) payload["notified"] = notified return payload # ────────────────────────────────────────────────────────────────────── # MDM managed-device ledger (migration 558) # ────────────────────────────────────────────────────────────────────── # Read-only, both of them. The one write this domain has — confirming a # disposed device was removed from the tenant — stays on HTTP: it closes # a cleanup todo on the strength of somebody's word, which is exactly the # kind of attestation that should cost a login rather than a token. def _require_mdm_read(role_names) -> None: """Match the HTTP route's ``Depends(require_asset_read())`` gate. Spelled out rather than delegated to ``can_read_all_assets``: that predicate also admits ``finance``, which ``require_asset_read`` does not. Reusing it would have made MCP a wider door than HTTP for the same data while the comment claimed parity — and a gate that says it mirrors another gate has to actually mirror it, or the next reader checks one and trusts both. """ from app.core.exceptions import ForbiddenError from app.core.rbac import ( ASSET_ADMIN_ROLE, AUDITOR_ROLE, GLOBAL_ADMIN_ROLE, MANAGEMENT_ROLE, has_role, ) if not has_role( role_names or [], GLOBAL_ADMIN_ROLE, ASSET_ADMIN_ROLE, AUDITOR_ROLE, MANAGEMENT_ROLE, ): raise ForbiddenError( "Reading the MDM device ledger requires an asset-domain, " "audit or leadership role.", ) @mcp_server.tool( name="list_mdm_devices", description=( "The MDM (Jamf) managed-device ledger: every enrolled Mac / iOS " "device, its serial, who holds it, and whether Helix matched it " "to an asset record. Answers 「这台序列号的设备还在 Jamf 里吗」" "「哪些设备已处置但还没从 MDM 移除」「Jamf 里还挂着离职同事名字的" "设备有哪些」. Enrolment belongs to the DEVICE, not to a person — " "an employee leaving does NOT require MDM cleanup (the machine is " "re-imaged and reassigned while staying enrolled); only a device " "leaving company ownership does, and those are exactly " "`enrollment_state='pending_cleanup'`. `match_category=" "'leaver_residual'` is a data-quality read on the MDM's own owner " "field, NOT a work queue. Read-only." ), ) async def list_mdm_devices( q: str = "", provider: str = "", device_type: str = "", enrollment_state: str = "", match_category: str = "", unlinked: bool | None = None, include_gone: bool = False, page: int = 1, page_size: int = 50, ) -> dict: async with tool_runtime( "list_mdm_devices", { "q": q, "provider": provider, "device_type": device_type, "enrollment_state": enrollment_state, "match_category": match_category, "unlinked": unlinked, "include_gone": include_gone, "page": page, "page_size": page_size, }, ) as rt: from app.domains.asset.router import ( _mdm_filtered, _mdm_load_ledger, _mdm_serialize, _mdm_summary_from_rows, ) _require_mdm_read(rt.user.role_names) page, page_size = _clamp_pagination(page, page_size, max_size=200) everything = await _mdm_load_ledger(rt.db) rows = _mdm_filtered( everything, q=q or None, provider=provider or None, device_type=device_type or None, enrollment_state=enrollment_state or None, match_category=match_category or None, unlinked=unlinked, include_gone=include_gone, ) start = (page - 1) * page_size window = rows[start:start + page_size] return { "items": await _mdm_serialize(rt.db, window), "total": len(rows), "page": page, "page_size": page_size, # Stated rather than left to be inferred from the counts: a # partial answer that does not say so reads as the whole fleet. "truncated": len(rows) > len(window), "summary": _mdm_summary_from_rows( everything, now=datetime.now(UTC), ).model_dump(), } @mcp_server.tool( name="list_mdm_cleanup_queue", description=( "Devices that must be removed from the MDM: the asset left company " "ownership (buyback / sale / scrap / write-off) but the tenant is " "still reporting the device. This is the ONLY MDM work queue — " "employee departures produce nothing here by design. Each entry " "carries the serial to search for in the vendor console, why the " "device left, and how many days the request has been open. Two " "fields carry the claim/fact distinction and they are NOT " "interchangeable: `confirmed_by_admin` means somebody has said " "they removed it and the next sync has not checked yet, while " "`contradicted_at` means somebody said so and the sync then found " "the device anyway — a queue entry with neither has simply never " "been actioned. Read-only; confirm removal on the device ledger " "page or the notification card." ), ) async def list_mdm_cleanup_queue(overdue_only: bool = False) -> dict: async with tool_runtime( "list_mdm_cleanup_queue", {"overdue_only": overdue_only}, ) as rt: from app.core.coordinators.mdm_device_retirement_coordinator import ( REMINDER_INTERVAL_DAYS, ) from app.domains.asset.models import ( MDM_STATE_PENDING_CLEANUP, MdmManagedDevice, ) _require_mdm_read(rt.user.role_names) now = datetime.now(UTC) rows = list( ( await rt.db.execute( select(MdmManagedDevice) .where( MdmManagedDevice.enrollment_state == MDM_STATE_PENDING_CLEANUP, ) .order_by(MdmManagedDevice.cleanup_requested_at.asc()) ) ).scalars().all() ) items = [] for r in rows: requested = r.cleanup_requested_at if requested is not None and requested.tzinfo is None: requested = requested.replace(tzinfo=UTC) days_open = (now - requested).days if requested else 0 if overdue_only and days_open < REMINDER_INTERVAL_DAYS: continue items.append({ "device_id": str(r.id), "provider": r.provider, "device_type": r.device_type, "serial_number": r.serial_number, "device_name": r.device_name, "model": r.model, "cleanup_reason": r.cleanup_reason, "requested_at": _iso_utc(r.cleanup_requested_at), "days_open": days_open, # A human said they did it; the tenant still returns the # device. Keeping the two apart is the difference between # a closed queue and a clean one — and it takes BOTH # fields. The sync CLEARS ``cleanup_confirmed_via`` when # it refutes a claim, so ``confirmed_by_admin`` alone # reports a refuted claim as "nobody has touched this", # which is the one reading that loses the fact worth # knowing: somebody believes this is done and it is not. "confirmed_by_admin": r.cleanup_confirmed_via == "admin", "confirmed_at": _iso_utc(r.cleanup_confirmed_at), "contradicted_at": _iso_utc(r.cleanup_contradicted_at), "last_seen_at": _iso_utc(r.last_seen_at), }) return { "items": items, "total": len(items), "overdue_after_days": REMINDER_INTERVAL_DAYS, } # ────────────────────────────────────────────────────────────────────── # 安全 FAQ — the employee-readable slice of the security answer library. # All-staff, read-only, and narrowed exactly like the HTTP self surface: # question, answer, category. No owner, no review state, no evidence # links. Archived and retracted entries are unreachable, including by id. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="search_security_faq", description=( "Search the company's internal security & compliance FAQ — the " "curated answers the security team publishes to all staff: device " "and MDM rules, generative-AI and data handling, USB / printing, " "email and phishing, source-code protection, shadow IT, access " "control, incident reporting. Use this whenever an employee asks " "what the company's security policy or practice IS, before " "answering from general knowledge — these answers are the " "authoritative internal position, not a best guess. Matches " "across both the Chinese and English text regardless of the " "language asked in, so an English keyword finds a Chinese entry. " "Omit `query` to browse. Read-only; every employee may call it." ), ) async def search_security_faq( query: str | None = None, category: str | None = None, lang: str = "zh", limit: int = 10, ) -> dict: async with tool_runtime( "search_security_faq", {"query": query, "category": category, "lang": lang, "limit": limit}, ) as rt: from app.domains.auditor import security_faq_service as faq resolved = faq.resolve_lang(lang) data = await faq.list_faq( rt.db, lang=resolved, category=category, search=query, page=1, page_size=max(1, min(limit, 50)), ) return { "items": data["items"], "total": data["meta"]["total"], "returned": len(data["items"]), "lang": resolved, } @mcp_server.tool( name="get_security_faq_entry", description=( "One security FAQ entry in full, by id, as an employee sees it. " "Use after `search_security_faq` when a listed answer was " "truncated or you need to quote it exactly. Returns NOT_FOUND for " "an entry that is archived or not published to employees — a " "retracted answer is indistinguishable from one that never " "existed, on purpose. Read-only; does not count as a page view." ), ) async def get_security_faq_entry(entry_id: str, lang: str = "zh") -> dict: async with tool_runtime( "get_security_faq_entry", {"entry_id": entry_id, "lang": lang}, ) as rt: from app.core.exceptions import AppException from app.domains.auditor import security_faq_service as faq try: parsed = UUID(entry_id) except (ValueError, TypeError) as exc: raise AppException( code="VALIDATION_ERROR", message="entry_id must be a UUID.", status_code=400, ) from exc entry = await faq.get_visible_entry(rt.db, parsed) return faq.faq_view(entry, lang=faq.resolve_lang(lang)) @mcp_server.tool( name="list_security_faq_categories", description=( "The security FAQ's categories with entry counts, plus the " "featured questions and when the library was last updated. Use it " "to orient before searching, or to tell an employee what the FAQ " "covers. Counts span the whole employee-visible library, not a " "filtered slice. Read-only; every employee may call it." ), ) async def list_security_faq_categories(lang: str = "zh") -> dict: async with tool_runtime( "list_security_faq_categories", {"lang": lang}, ) as rt: from app.domains.auditor import security_faq_service as faq return await faq.facets(rt.db, lang=faq.resolve_lang(lang)) @mcp_server.tool( name="ask_security_faq", description=( "Ask the internal security FAQ a question in natural language and " "get back the entries that actually answer it, ranked. Unlike " "`search_security_faq` (a keyword filter), this runs the platform's " "hybrid retrieval: lexical term scoring AND semantic embedding " "recall over the same library, fused. Use it whenever the employee " "phrased a question rather than a keyword — the library is written " "in customer-questionnaire voice (\"Do you deploy DLP?\"), so " "\"can I write company code on my own laptop\" shares no words with " "the entry that answers it and keyword search returns nothing. " "Answers come back as the security team's stored text; quote them, " "do not paraphrase policy. Read-only." ), ) async def ask_security_faq( question: str, lang: str = "zh", limit: int = 5, ) -> dict: async with tool_runtime( "ask_security_faq", {"question": question, "lang": lang, "limit": limit}, ) as rt: from app.domains.auditor import security_faq_service as faq # ``compose=False``: an MCP caller IS a model, and it has the stored # answers in hand — spending a second model call to pre-write prose # it will rewrite anyway is pure latency. The part it cannot do for # itself is the retrieval, which is what this tool exists to lend it. # # ``record_gaps=False``: the gap ledger means "a person asked and we # had no answer". An agent probing the library on someone's behalf # is not that signal, and a mutating tool would additionally lock # out leavers from asking a security question. return await faq.ask( rt.db, query=question, lang=faq.resolve_lang(lang), person_id=rt.user.id, limit=max(1, min(limit, 10)), compose=False, record_gaps=False, ) # ════════════════════════════════════════════════════════════════════ # GitHub 组织 # ════════════════════════════════════════════════════════════════════ async def _mcp_require_github_view(rt) -> None: """Mirror the HTTP route's ``can_view_github_org`` gate. MCP is a first-class surface, so the gate has to be the same predicate the router calls — not a hand-rolled role list that drifts from it. """ from app.core.exceptions import ForbiddenError # noqa: PLC0415 from app.core.rbac import can_view_github_org # noqa: PLC0415 if not can_view_github_org(rt.user.role_names): raise ForbiddenError( "Reading the GitHub organization registry requires github_admin, " "global_admin, auditor, security_officer or management.", ) @mcp_server.tool( name="list_github_orgs", description=( "List the GitHub organizations Helix governs, with per-org member " "counts: how many people are currently in it, how many invitations " "are still awaiting acceptance, and how many members Helix CANNOT " "match to an employee. Also returns each org's offboarding policy " "(`remove` = members are removed when they leave the company, " "`exempt` = they are kept, normally only for community orgs), " "whether employees can request it self-service, and whether " "auto-join rules apply to it. Use this to answer 「我们管着哪些 " "GitHub 组织」「哪个组织有多少人」「哪些组织离职后不回收」. " "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def list_github_orgs(include_inactive: bool = False) -> dict: async with tool_runtime( "list_github_orgs", {"include_inactive": include_inactive}, ) as rt: from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.serializers import ( # noqa: PLC0415 serialize_org, ) await _mcp_require_github_view(rt) orgs = await gh.list_orgs(rt.db, include_inactive=include_inactive) counts = await gh.org_member_counts(rt.db) return { "orgs": [serialize_org(o, member_counts=counts) for o in orgs], "unlinked_total": await gh.count_unlinked(rt.db), } @mcp_server.tool( name="list_my_github_orgs", description=( "Which GitHub organizations the CALLER is in, according to Helix's " "ledger — including invitations that were sent but not yet accepted " "(state `pending_invite`). Answers 「我在哪些 GitHub 组织里」" "「我的邀请还没接受吗」. Self-scoped: needs no role and never " "returns anybody else's membership. An empty list can also mean " "Helix does not know this person's GitHub handle yet, so say that " "rather than asserting the person is in no organization." ), ) async def list_my_github_orgs() -> dict: async with tool_runtime("list_my_github_orgs", {}) as rt: from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.serializers import ( # noqa: PLC0415 serialize_membership, ) rows = await gh.list_memberships_for_person( rt.db, str(rt.user.id), live_only=False, ) orgs = { o.id: o for o in await gh.list_orgs(rt.db, include_inactive=True) } return { "memberships": [ serialize_membership(m, org=orgs.get(m.org_id)) for m in rows ], # ``github_id`` is sparse (collected at onboarding, never # backfilled), and "no handle on file" is a different answer from # "in no organization". Surfacing it keeps the model from # collapsing the two. "github_handle_on_file": (rt.user.github_id or None), } @mcp_server.tool( name="lookup_github_org_member", description=( "Look up one GitHub username in Helix's membership ledger: which " "organizations that handle is in, in what state, where the " "membership came from (approved request / auto-join rule / imported " "/ added by an admin / found by reconcile), and which employee it " "is matched to — `person_label` is null when Helix cannot tell who " "it is, which is exactly the population offboarding removal cannot " "reach. Use it for 「这个 GitHub 账号是谁」「他在哪些组织里」. " "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def lookup_github_org_member(github_login: str) -> dict: async with tool_runtime( "lookup_github_org_member", {"github_login": github_login}, ) as rt: from sqlalchemy import select # noqa: PLC0415 from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.models import ( # noqa: PLC0415 GithubOrgMembership, ) from app.domains.github_org.serializers import ( # noqa: PLC0415 serialize_membership, ) from app.domains.github_org.validators import ( # noqa: PLC0415 normalize_github_login, ) from app.identity.person_models import IdentityPerson # noqa: PLC0415 await _mcp_require_github_view(rt) login = normalize_github_login(github_login) rows = list( ( await rt.db.execute( select(GithubOrgMembership).where( GithubOrgMembership.github_login_lower == login.lower() ) ) ).scalars().all() ) orgs = { o.id: o for o in await gh.list_orgs(rt.db, include_inactive=True) } people: dict[str, IdentityPerson] = {} person_ids = {m.person_id for m in rows if m.person_id} if person_ids: import uuid as _uuid # noqa: PLC0415 people = { str(p.id): p for p in ( await rt.db.execute( select(IdentityPerson).where( IdentityPerson.id.in_( [_uuid.UUID(pid) for pid in person_ids] ) ) ) ).scalars().all() } return { "github_login": login, "memberships": [ serialize_membership( m, org=orgs.get(m.org_id), person_label=( people[m.person_id].display_name if m.person_id in people else None ), person_email=( people[m.person_id].work_email if m.person_id in people else None ), person_status=( people[m.person_id].employment_status if m.person_id in people else None ), ) for m in rows ], } @mcp_server.tool( name="request_github_org_access", description=( "Submit a request to join a GitHub organization on the CALLER's " "behalf. Raises the company's existing Feishu approval " "(「申请加入 GitHub 组织」) — org owners still decide; this tool only " "files the request. Call `list_github_org_options` FIRST to get the " "`org_id` and to check whether the caller is already a member " "(requesting an org you are already in is refused, on purpose: it " "spends an org owner's attention on a decision that is already made). " "`github_login` is OPTIONAL and is NOT the source of truth: the " "request is always filed for the GitHub username ON FILE for the " "caller (`identity_persons.github_id`, confirmed by their HR Ops). " "Passing a different one is REFUSED rather than silently ignored — " "to use another handle the caller must change their GitHub ID first " "(`change_my_github_id`), which goes to their HR Ops. A caller with " "no handle on file is refused with `GITHUB_HANDLE_NOT_ON_FILE`; tell " "them to register it, do not invent one. Self-service: files only for " "the caller, never for somebody else. Refused for departing / " "departed employees." ), ) async def request_github_org_access( org_id: str, github_login: str | None = None, reason: str | None = None, ) -> dict: async with tool_runtime( "request_github_org_access", {"org_id": org_id, "github_login": github_login}, ) as rt: from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.request_service import ( # noqa: PLC0415 GithubOrgRequestService, ) from app.domains.github_org.serializers import ( # noqa: PLC0415 serialize_join_request, ) row = await GithubOrgRequestService(rt.db).submit( requester=rt.user, org_id=org_id, github_login=github_login, reason=reason, trace_id=_trace_str(), ) org = await gh.get_org(rt.db, row.org_id) return serialize_join_request(row, org=org) @mcp_server.tool( name="list_github_org_options", description=( "What GitHub organizations the CALLER can request to join, and where " "they already stand with each one: `my_state` (null / pending_invite " "/ active), `has_pending_request`, and `requestable` (false means " "Helix cannot raise a Feishu approval for it yet — tell the user to " "ask IT rather than retrying). Also returns `github_id_on_file`: the " "handle HRIS has for this person, which is SPARSE (collected at " "onboarding, never backfilled) — null means 「we have no handle on " "file」, which is a different statement from 「you have no GitHub " "account」. Use it to pre-fill `request_github_org_access`, and ask " "the user to confirm rather than assuming it is current." ), ) async def list_github_org_options() -> dict: async with tool_runtime("list_github_org_options", {}) as rt: from sqlalchemy import select # noqa: PLC0415 from app.domains.github_org import constants as ghc # noqa: PLC0415 from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.models import ( # noqa: PLC0415 GithubOrgJoinRequest, ) orgs = await gh.list_orgs(rt.db, request_enabled_only=True) mine = { m.org_id: m for m in await gh.list_memberships_for_person( rt.db, str(rt.user.id), live_only=True, ) } pending = { r.org_id for r in ( await rt.db.execute( select(GithubOrgJoinRequest).where( GithubOrgJoinRequest.person_id == str(rt.user.id), GithubOrgJoinRequest.status.in_( ghc.JOIN_REQUEST_OPEN_STATUSES, ), ) ) ).scalars().all() } return { "organizations": [ { "org_id": o.id, "slug": o.slug, "display_name": o.display_name, "is_community": o.is_community, "requestable": bool(o.feishu_option_value), "my_state": ( mine[o.id].state if o.id in mine else None ), "has_pending_request": o.id in pending, } for o in orgs ], "github_id_on_file": (rt.user.github_id or None), } @mcp_server.tool( name="get_my_github_id", description=( "The CALLER's GitHub ID as Helix has it, plus any change already " "waiting for confirmation. `github_id` is null when no handle is on " "file — that is 「we were never told」, NOT 「this person has no " "GitHub account」; it is collected once on the onboarding form and " "never backfilled, so 154 of 417 employees have none. " "`hris_sync_pending` true means the value shown is already live in " "Helix and Feishu but the HRIS write-back has not been confirmed " "yet — nothing for the user to do. Read this BEFORE calling " "`change_my_github_id` so you do not file a no-op change." ), ) async def get_my_github_id() -> dict: async with tool_runtime("get_my_github_id", {}) as rt: from app.domains.github_org.handle_change_service import ( # noqa: PLC0415 GithubIdChangeService, ) svc = GithubIdChangeService(rt.db) state = await svc.get_state(rt.user) state["history"] = await svc.list_mine(rt.user, limit=20) return state @mcp_server.tool( name="change_my_github_id", description=( "File a request to change the CALLER's own GitHub ID. It does NOT " "take effect immediately: the caller's HR Ops (falling back to their " "HRBP) confirms it, and only then does Helix update the HRIS " "(SuccessFactors) and the Feishu directory. A pasted profile URL or " "an @handle is accepted and normalised. Refused when the handle is " "unchanged, when another employee already has it on file (contact IT " "in that case — it is not retryable), when a change is already " "pending, and for departing / departed employees. Self-service: " "files only for the caller, never for somebody else." ), ) async def change_my_github_id( github_id: str, reason: str | None = None, ) -> dict: async with tool_runtime( "change_my_github_id", {"github_id": github_id}, mutates=True, ) as rt: from app.domains.github_org.handle_change_service import ( # noqa: PLC0415 GithubIdChangeService, ) svc = GithubIdChangeService(rt.db) row = await svc.submit( requester=rt.user, github_id=github_id, reason=reason, trace_id=_trace_str(), ) return await svc.serialize(row) @mcp_server.tool( name="cancel_my_github_id_change", description=( "Withdraw the CALLER's own still-pending GitHub ID change. Only the " "requester can withdraw, and only while it is pending — an already " "confirmed or rejected request cannot be withdrawn (submit a new " "one instead)." ), ) async def cancel_my_github_id_change(request_id: str) -> dict: async with tool_runtime( "cancel_my_github_id_change", {"request_id": request_id}, mutates=True, ) as rt: from app.domains.github_org.handle_change_service import ( # noqa: PLC0415 GithubIdChangeService, ) svc = GithubIdChangeService(rt.db) row = await svc.cancel( request_id=request_id, requester=rt.user, trace_id=_trace_str(), ) return await svc.serialize(row) @mcp_server.tool( name="list_github_id_changes_to_confirm", description=( "GitHub ID changes waiting for the CALLER to confirm. Being an " "employee's HR Ops (or HRBP) is DATA, not a role — so this returns " "rows for anyone named as the approver, and widens to the whole " "company only for HR / global_admin (who also cover requests where " "no approver could be derived). `github_exists` is advisory and " "THREE-valued: true / false / null, where null means Helix could " "not reach GitHub — never present null as 「no such account」." ), ) async def list_github_id_changes_to_confirm() -> dict: async with tool_runtime("list_github_id_changes_to_confirm", {}) as rt: from app.domains.github_org.handle_change_service import ( # noqa: PLC0415 GithubIdChangeService, ) rows = await GithubIdChangeService(rt.db).list_pending_for_approver( person=rt.user, role_names=list(rt.user.role_names or []), ) return {"requests": rows, "total": len(rows)} @mcp_server.tool( name="decide_github_id_change", description=( "Confirm or reject one GitHub ID change. `decision` is 'approve' or " "'reject'. Approving WRITES: the handle goes to SuccessFactors, to " "the Helix person record, and is pushed to the Feishu directory — " "confirm with the user before calling. Only the named approver (the " "requester's HR Ops / HRBP) or HR / global_admin may decide, and " "nobody may approve their own request. A rejection note is shown to " "the requester, so write it for them to read." ), ) async def decide_github_id_change( request_id: str, decision: str, note: str | None = None, ) -> dict: async with tool_runtime( "decide_github_id_change", {"request_id": request_id, "decision": decision}, mutates=True, ) as rt: from app.domains.github_org import constants as ghc # noqa: PLC0415 from app.domains.github_org.handle_change_service import ( # noqa: PLC0415 GithubIdChangeService, ) svc = GithubIdChangeService(rt.db) row, requester = await svc.decide( request_id=request_id, actor=rt.user, role_names=list(rt.user.role_names or []), decision=decision, note=note, trace_id=_trace_str(), ) payload = await svc.serialize(row) if row.status == ghc.HANDLE_CHANGE_STATUS_APPROVED: # AFTER the business commit — ``enqueue_person_sync`` opens its # own session and would otherwise read the pre-commit row and # push the OLD handle to Feishu. person_id = str(requester.id) async def _broadcast() -> None: GithubIdChangeService.broadcast_after_commit(person_id) rt.after_commit(_broadcast) return payload @mcp_server.tool( name="list_github_org_drift", description=( "The GitHub organization governance gap, in one call: for each " "organization, how many members Helix CANNOT match to an employee " "(`unlinked`), when it was last reconciled with GitHub, and any " "reconcile error. **`leavers_still_in_orgs` should be 0** — a departed " "employee still inside a non-exempt organization means removal did not " "take somewhere, which is the one thing this automation exists to " "prevent; say so plainly rather than burying it. **Unlinked is the number that matters** — those " "memberships exist on GitHub but Helix does not know whose they are, " "so offboarding removal cannot reach them. **It does NOT shrink on " "its own** — measured on production, 0 of 217 unidentified handles " "matched any corporate email prefix literally. Clearing it needs " "`suggest_github_member_links` plus a human confirming each row as " "a person or as a genuine external contributor. Also flags organizations " "whose credentials are not configured (nothing about them can be " "automated yet) and community organizations exempt from offboarding " "removal. Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def list_github_org_drift() -> dict: async with tool_runtime("list_github_org_drift", {}) as rt: from app.domains.github_org import constants as ghc # noqa: PLC0415 from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.driver_access import ( # noqa: PLC0415 resolve_org_driver, ) await _mcp_require_github_view(rt) orgs = await gh.list_orgs(rt.db) counts = await gh.org_member_counts(rt.db) rows = [] for org in orgs: resolution = await resolve_org_driver(rt.db, org) rows.append( { "slug": org.slug, "members": counts.get(f"{org.id}:active", 0), "pending_invites": counts.get( f"{org.id}:pending_invite", 0, ), "unlinked": counts.get(f"{org.id}:unlinked", 0), "offboarding_policy": org.offboarding_policy, "exempt_from_offboarding": ( org.offboarding_policy == ghc.OFFBOARDING_POLICY_EXEMPT ), "automation_ready": resolution.automatic, "automation_reason": resolution.reason, "last_reconciled_at": ( org.last_reconciled_at.isoformat() if org.last_reconciled_at else None ), "last_reconcile_error": org.last_reconcile_error, } ) return { "organizations": rows, "unlinked_total": await gh.count_unlinked(rt.db), # 这套自动化的全部意义就是这个数应该是 0:一个已离职的人 # 还在某个不豁免的组织里,说明回收在某处没有生效。 "leavers_still_in_orgs": ( await gh.count_leavers_still_in_orgs(rt.db) ), } async def _mcp_require_github_manage(rt) -> None: """Mirror the HTTP route's ``can_manage_github_org`` gate. Same predicate the router calls — not a hand-rolled role list. MCP is a first-class surface, and a gate that drifts from the HTTP one is a gate that eventually lets the wrong person remove somebody from an org. """ from app.core.exceptions import ForbiddenError # noqa: PLC0415 from app.core.rbac import can_manage_github_org # noqa: PLC0415 if not can_manage_github_org(rt.user.role_names): raise ForbiddenError( "Acting on GitHub organization memberships requires " "github_admin or global_admin.", ) async def _mcp_github_membership(rt, membership_id: str): from sqlalchemy import select # noqa: PLC0415 from app.core.exceptions import NotFoundError # noqa: PLC0415 from app.domains.github_org.models import ( # noqa: PLC0415 GithubOrg, GithubOrgMembership, ) row = ( await rt.db.execute( select(GithubOrgMembership, GithubOrg) .join(GithubOrg, GithubOrg.id == GithubOrgMembership.org_id) .where(GithubOrgMembership.id == membership_id) ) ).first() if row is None: raise NotFoundError(f"GitHub org membership {membership_id} not found") return row[0], row[1] @mcp_server.tool( name="list_github_action_items", description=( "Everything about the GitHub organizations that needs a HUMAN to do " "something, in one call — the same data the weekly digest and the " "「待处理」 tab use.\n" "Four kinds, ordered by the consequence of NOT acting:\n" "1. `leaver_still_member` — somebody who left the company is still " "in a non-exempt org. This is access exposure that has ALREADY " "happened; it should be 0. Fix with `remove_github_org_member`.\n" "2. `seat_low` — seats are nearly gone. Once full, new invitations " "fail outright, which looks like 「the approval passed but nobody got " "in」. Each row carries `billing_url`, the GitHub page where seats " "are actually bought — Helix cannot buy them.\n" "3. `awaiting_manual` — approved, but no credential is configured " "for that org so no invitation was sent. `waiting_days` says how " "stale it is.\n" "4. `unlinked` — on GitHub but not yet matched to an employee. " "Reported as a count only, and deliberately EXCLUDED from " "`actionable_total`: it is a few-hundred-row review task, not a " "this-week item. `external_confirmed` is the progress counter.\n" "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def list_github_action_items() -> dict: async with tool_runtime("list_github_action_items", {}) as rt: from app.domains.github_org import governance # noqa: PLC0415 await _mcp_require_github_view(rt) return await governance.collect_action_items(rt.db) @mcp_server.tool( name="get_github_org_report", description=( "The GitHub organization overview: is this automation actually " "working? Companion to `list_github_action_items` — that one answers " "「what needs doing today」, this one answers 「how much did we " "finish」.\n" "**The number to lead with is `by_source`, not the member count.** " "The total barely moves and says nothing. What says something is " "where the memberships came from: `import` / `reconcile` " "(`by_source.discovered`) is precisely the proof that NO process " "governed this before — those are pre-existing rows Helix merely " "found. Only `approval` / `auto_join` (`by_source.automated`) is what " "the automation actually delivered. `automated_pct` is null when the " "ledger is empty — that is 「unknown」, not 0%, and must not be " "reported as 0%.\n" "Also returns per-org identification coverage (a membership with no " "person attached is one offboarding reclaim cannot reach), the " "join/removal trend read from the append-only event stream (never " "inferred from current state, which would erase 「joined then " "left」), and the offboarding scoreboard whose " "`leavers_still_in_orgs` should be permanently 0.\n" "`seat_remaining` is null for free / enterprise plans on purpose: " "those report a number that is not our ceiling (one production org " "reports filled=203 against total=92), so a literal subtraction " "would be a permanent false alarm. Never compute headroom yourself " # 「use `seat_remaining`」 会被 test_descriptions_never_point_at_ # unregistered_tools 读成「去调一个叫 seat_remaining 的工具」—— # 因为 seat_reconciliation_overview 让 seat 进了动词表。它是响应 # 字段,不是工具,所以措辞要说「读这个字段」。 "from filled/total — read the `seat_remaining` field as given, " "or say headroom does not apply.\n" "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def get_github_org_report(days: int = 90) -> dict: async with tool_runtime("get_github_org_report", {"days": days}) as rt: from app.domains.github_org import report # noqa: PLC0415 await _mcp_require_github_view(rt) return await report.build_report( rt.db, days=max(7, min(int(days), 365)), ) @mcp_server.tool( name="remove_github_org_member", description=( "Remove one handle from one GitHub organization and record it in the " "ledger, cancelling any pending invitation. Same execution path as " "the offboarding reclaim, audited, idempotent.\n" "`reason` is REQUIRED and stored: manual removal is the only write " "here with no upstream evidence — approvals have a ticket, " "offboarding has a process, auto-join has a rule; this one rests " "entirely on a person's judgement, and six months later somebody " "will ask why this person was kicked out.\n" "Two outcomes that are NOT success and must be reported as such: " "`outcome: failed` means GitHub refused (the person is still in the " "org); `outcome: manual` means no credential is configured, so " "nothing was sent and the person is STILL IN THE ORG — never " "paraphrase either as 「removed」. On success `outcome` is `removed`.\n" "RBAC: ``can_manage_github_org`` (github_admin / global_admin only)." ), ) async def remove_github_org_member(membership_id: str, reason: str) -> dict: async with tool_runtime( "remove_github_org_member", {"membership_id": membership_id, "reason": reason}, mutates=True, ) as rt: from app.domains.github_org import constants as ghc # noqa: PLC0415 from app.domains.github_org import execution # noqa: PLC0415 await _mcp_require_github_manage(rt) reason = (reason or "").strip() if len(reason) < 2: return {"error": "reason is required"} membership, org = await _mcp_github_membership(rt, membership_id) outcome = await execution.revoke_membership( rt.db, membership=membership, org=org, reason=reason, removal_source=ghc.REMOVAL_SOURCE_ADMIN, trace_id=_trace_str(), actor_person_id=str(rt.user.id), ) return { "outcome": outcome.outcome, "ok": outcome.ok, "org": org.slug, "github_login": membership.github_login, # manual = 凭证没配好,人**还在**组织里。 "requires_manual_action": ( outcome.outcome == execution.OUTCOME_MANUAL ), "error": outcome.error, } @mcp_server.tool( name="suggest_github_member_links", description=( "For the memberships Helix cannot yet match to an employee, propose " "either WHO they are or that they are genuinely EXTERNAL.\n" "How it works: fetch each handle's public GitHub profile (name / " "company / email), score candidates against the employee directory, " "then optionally have an LLM review the ones scoring is unsure " "about — constrained to the given candidate set, so it cannot " "invent a person. Profiles are required, not optional: measured on " "production, 0 of 217 unidentified handles matched any corporate " "email prefix literally (they look like `Ehco1996` / `zz-jason`), " "while 21 of 30 sampled profiles carried a real name and 10 a " "public email.\n" "**Output is advice only. Nothing is written.** Attaching a " "membership to the wrong person means that when THEY leave, somebody " "else loses access — so a human confirms every row via " "`link_github_member_to_person` or " "`confirm_github_member_external`. Present the evidence " "(`profile`, each candidate's `reasons`, the AI `reason` and " "`confidence`) and let the person decide; do not describe a " "suggestion as if the link were made.\n" "RBAC: ``can_manage_github_org`` (github_admin / global_admin only)." ), ) async def suggest_github_member_links( limit: int = 20, use_ai: bool = True, org_id: str | None = None, ) -> dict: async with tool_runtime( "suggest_github_member_links", {"limit": limit, "use_ai": use_ai, "org_id": org_id}, # 会把拉到的 GitHub 画像写回台账当缓存 —— 是写,虽然不改任何判断。 mutates=True, ) as rt: from app.domains.github_org import ( driver_access, # noqa: PLC0415 handle_matching, # noqa: PLC0415 ) from app.domains.github_org import service as gh # noqa: PLC0415 await _mcp_require_github_manage(rt) fetcher = None for org in await gh.list_orgs(rt.db): resolution = await driver_access.resolve_org_driver(rt.db, org) if resolution.automatic and resolution.driver is not None: driver = resolution.driver async def fetcher(login: str, _d=driver): # noqa: ANN202 return await _d.get_user_profile(login) break return await handle_matching.suggest_links( rt.db, org_id=org_id, limit=max(1, min(int(limit), 100)), use_ai=use_ai, fetch_profile=fetcher, ) @mcp_server.tool( name="link_github_member_to_person", description=( "Attach one GitHub org membership to an employee (or detach it with " "`person_id=null`).\n" "Why it matters: offboarding reclaim works from `person_id`, so a " "row with nobody attached is invisible to it. Linking is the only " "way that membership ever gets reclaimed.\n" "If the row was previously confirmed external, that flag is cleared " "— the two states are mutually exclusive.\n" "Only do this when the user has confirmed WHO it is. A wrong link " "means that when that person leaves, somebody ELSE loses access.\n" "RBAC: ``can_manage_github_org`` (github_admin / global_admin only)." ), ) async def link_github_member_to_person( membership_id: str, person_id: str | None = None, ) -> dict: async with tool_runtime( "link_github_member_to_person", {"membership_id": membership_id, "person_id": person_id}, mutates=True, ) as rt: from uuid import UUID as _UUID # noqa: PLC0415 from sqlalchemy import select # noqa: PLC0415 from app.core.exceptions import NotFoundError # noqa: PLC0415 from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.serializers import ( # noqa: PLC0415 serialize_membership, ) from app.identity.person_models import IdentityPerson # noqa: PLC0415 await _mcp_require_github_manage(rt) membership, org = await _mcp_github_membership(rt, membership_id) pid = (person_id or "").strip() or None if pid: # 见 HTTP 侧同样的注释:畸形 id 是坏输入,不是我们坏了。 try: pid_uuid = _UUID(pid) except ValueError: raise NotFoundError(f"Person {pid} not found") from None found = ( await rt.db.execute( select(IdentityPerson.id).where( IdentityPerson.id == pid_uuid, IdentityPerson.deleted_at.is_(None), ) ) ).scalar_one_or_none() if found is None: raise NotFoundError(f"Person {pid} not found") await gh.relink_membership( rt.db, membership, person_id=pid, actor_person_id=str(rt.user.id), trace_id=_trace_str(), ) return { "org": org.slug, "membership": serialize_membership(membership, org=org), } @mcp_server.tool( name="label_github_handle", description=( "Attach (or remove) labels on a GitHub handle — the maintainable " "taxonomy for accounts Helix cannot tie to an employee.\n" "**Labels attach to the HANDLE, not to one membership.** Measured on " "production: 217 unidentified handles spread across 331 ledger rows, " "and `sre-bot` / `ti-chi-bot` / `crazycs520` are each in 5 orgs. " "「What kind of account is this」 is a property of the handle — one " "call covers every org it is in, so you cannot end up having fixed " "2 rows and missed 3.\n" "Four categories, each answering a different question: `account:*` " "(machine vs outside person — this one also drives the governance " "queue's external confirmation), `employer:*` (who they work for — " "what an annual access review filters on), `community:*` (their " "open-source standing — what decides whether a leaver may keep " "access), `purpose:*` (what a machine account is for).\n" "Only keys already in the vocabulary are accepted; an unknown key is " "REFUSED rather than silently created, because a controlled " "vocabulary is the entire reason filtering keeps working — free text " "rots into 「ByteDance」/「字节」/「bytedance guy」 and then 「pull up " "every ByteDance person」 can never be done. Call " "`list_github_labels` first if unsure. Set `remove=true` to detach.\n" "RBAC: ``can_manage_github_org`` (github_admin / global_admin only)." ), ) async def label_github_handle( github_login: str, label_keys: str, note: str | None = None, remove: bool = False, ) -> dict: async with tool_runtime( "label_github_handle", {"github_login": github_login, "label_keys": label_keys, "note": note, "remove": remove}, mutates=True, ) as rt: from app.core.exceptions import AppException # noqa: PLC0415 from app.domains.github_org import labels as _lb # noqa: PLC0415 await _mcp_require_github_manage(rt) keys = [k.strip() for k in (label_keys or "").split(",") if k.strip()] if not keys: return {"error": "label_keys is required (comma-separated)"} try: if remove: return await _lb.detach( rt.db, github_login=github_login, label_keys=keys, actor_person_id=str(rt.user.id), trace_id=_trace_str(), ) return await _lb.attach( rt.db, github_login=github_login, label_keys=keys, note=note, actor_person_id=str(rt.user.id), trace_id=_trace_str(), ) except ValueError as e: raise AppException( code="GITHUB_ORG_LABEL_INVALID", message=str(e), status_code=422, ) from None @mcp_server.tool( name="list_github_labels", description=( "The GitHub handle label vocabulary, plus the rule-based label " "suggestions for handles nobody has labelled yet.\n" "Each suggestion carries a `reason` — present the reason, never the " "label alone. The rules are heuristics and **misfire on real " "people**: `robot-liu` matches the `*robot*` glob, and a profile " "whose company says 「PingCAP」 may be a FORMER employee rather than " "a current one. Labelling an employee a machine account means they " "are not reclaimed when they leave.\n" "So: never describe a suggestion as if the label were applied, and " "never batch-apply on your own initiative — `label_github_handle` is " "a separate human-directed step.\n" "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def list_github_labels(include_suggestions: bool = True) -> dict: async with tool_runtime( "list_github_labels", {"include_suggestions": include_suggestions}, ) as rt: from app.domains.github_org import labels as _lb # noqa: PLC0415 await _mcp_require_github_view(rt) vocab = [ { "key": r.key, "category": r.category, "display_zh": r.display_zh, "display_en": r.display_en, "description": r.description, "is_active": r.is_active, } for r in await _lb.list_labels(rt.db, include_inactive=True) ] out: dict = {"labels": vocab} if include_suggestions: out["suggestions"] = await _lb.suggest(rt.db, limit=200) return out @mcp_server.tool( name="list_github_label_rules", description=( "The GitHub handle label RULES, each with how many unlabelled handles " "it would match right now and up to three sample handles.\n" "`enabled` means the rule writes labels BY ITSELF (applied at the " "tail of the daily member reconcile). A disabled rule still shows up " "as a suggestion — disabling is not the same as deleting.\n" "Report `would_label` whenever you mention a rule: an over-broad " "rule (a `value` of 「co」 matches ClickHouse, Databricks and " "Microsoft) mislabels dozens of handles at once, and the count is the " "only thing that makes that visible before it happens.\n" "You CANNOT enable a rule — there is deliberately no tool for it. " "Turning a heuristic into an automatic writer is a human decision " "made in the UI, next to that count. If asked to enable one, say " "where the switch is; do not look for another way.\n" "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def list_github_label_rules() -> dict: async with tool_runtime("list_github_label_rules", {}) as rt: from app.domains.github_org import labels as _lb # noqa: PLC0415 await _mcp_require_github_view(rt) rules = await _lb.rule_impact(rt.db) return { "rules": rules, "enabled": sum(1 for r in rules if r.get("enabled")), "total": len(rules), } @mcp_server.tool( name="apply_github_label_rules", description=( "Run the ENABLED label rules now instead of waiting for tomorrow's " "member reconcile. Adds labels only, never removes them.\n" "Only handles that carry NO label yet are touched — anything a " "person labelled by hand is left alone, because 「the rule changed it " "back」 is what stops people trusting the vocabulary at all.\n" "Enabling a rule is NOT possible here (no tool exists for it, on " "purpose). With no rule enabled this is a no-op and returns " "`labelled: 0` — report that as 「no rule is enabled」, not as " "「nothing matched」: those point at different fixes.\n" "Pass `dry_run: true` to get the counts without writing.\n" "RBAC: ``can_manage_github_org`` (github_admin / global_admin only)." ), ) async def apply_github_label_rules(dry_run: bool = False) -> dict: async with tool_runtime( "apply_github_label_rules", {"dry_run": dry_run}, mutates=not dry_run, ) as rt: from app.domains.github_org import labels as _lb # noqa: PLC0415 await _mcp_require_github_manage(rt) out = await _lb.apply_enabled_rules( rt.db, dry_run=dry_run, actor_person_id=str(rt.user.id) if rt.user else None, trace_id=_trace_str(), ) if not dry_run: await rt.db.commit() return out @mcp_server.tool( name="list_github_bot_candidates", description=( "Which unidentified GitHub handles look like MACHINE accounts, under " "the configured naming policy. Read-only preview — nothing is " "marked.\n" "Why a policy and not detection: **GitHub's own `type` field is " "useless here.** All seven machine accounts in this tenant " "(`sre-bot`, `ti-chi-bot`, `ti-srebot`, `tidb-ci-bot`, " "`tidbcloud-bot`, `pingcap-zendesk-bot`, `TestInfra-Bot`) were " "checked individually and GitHub reports every one as `type: User` — " "its `Bot` type only covers GitHub App identities, while these are " "human-created accounts used as bots. So the only signal is the " "naming convention, which **misfires on real people**: a handle like " "`robot-liu` matches `*robot*`.\n" "Therefore each row carries a `reason`, and marking is a separate " "human-pressed step (`confirm_github_member_external` with " "`kind='bot'`). **Never present these candidates as already " "classified, and never suggest auto-marking them** — labelling an " "employee a machine account means they are not reclaimed when they " "leave, the same silent access leak as attaching a membership to the " "wrong person.\n" "Read-only. Requires github_admin / global_admin / auditor / " "security_officer / management." ), ) async def list_github_bot_candidates() -> dict: async with tool_runtime("list_github_bot_candidates", {}) as rt: from app.domains.github_org import bot_policy # noqa: PLC0415 await _mcp_require_github_view(rt) return await bot_policy.preview(rt.db) @mcp_server.tool( name="confirm_github_member_external", description=( "Record that a GitHub handle is NOT one of our employees — an " "outside community contributor, another company's engineer, or a " "bot. Pass `undo=true` to reverse it.\n" "Nothing changes on GitHub: external contributors belong in " "open-source organizations. What changes is that the row stops " "counting as work.\n" "Why this state exists: after importing production there were 331 " "memberships nobody could identify, and sampling their GitHub " "profiles showed a large share list another company, a foundation, " "or a university — they will never be identified. Without this " "terminal state that number never reaches zero, and a to-do count " "that never reaches zero is a to-do count nobody reads by week " "three.\n" "Pass `kind='bot'` for a machine account and " "`kind='outside_human'` for a person at another company — they are " "kept apart because the follow-up question differs: an outside " "human holds access (review asks 「do they still need it」), a bot " "asks 「is this automation still in use, and who owns its token」. " "An unrecognised value is stored as null rather than verbatim.\n" "Refuses (409) on a membership already linked to a person: the two " "states contradict each other. `note` is optional but worth filling " "— six months on, 「works at ByteDance」 and a blank confirmation " "are not equally trustworthy.\n" "RBAC: ``can_manage_github_org`` (github_admin / global_admin only)." ), ) async def confirm_github_member_external( membership_id: str, note: str | None = None, undo: bool = False, kind: str | None = None, ) -> dict: async with tool_runtime( "confirm_github_member_external", {"membership_id": membership_id, "note": note, "undo": undo, "kind": kind}, mutates=True, ) as rt: from app.domains.github_org import service as gh # noqa: PLC0415 from app.domains.github_org.serializers import ( # noqa: PLC0415 serialize_membership, ) await _mcp_require_github_manage(rt) membership, org = await _mcp_github_membership(rt, membership_id) if undo: await gh.clear_external( rt.db, membership, actor_person_id=str(rt.user.id), trace_id=_trace_str(), ) else: await gh.confirm_external( rt.db, membership, actor_person_id=str(rt.user.id), note=note, kind=kind, trace_id=_trace_str(), ) return { "org": org.slug, "membership": serialize_membership(membership, org=org), } @mcp_server.tool( name="list_leaver_account_assurance", description=( "Whether the people who have LEFT can still sign in — per person, " "per identity system (Google Workspace, Atlassian). This is a " "different question from 「did Helix revoke their access」, which " "the offboarding tools already answer, and the difference is the " "point: in August 2026 every leaver's Atlassian product access was " "correctly stripped while their ACCOUNTS stayed live for 16 days, " "because the org-side directory sync that deactivates accounts had " "been switched off and nothing was watching it.\n" "`verdict` per person: `ok` (cannot sign in anywhere), `at_risk` " "(can still sign in and nobody has decided that), `exempt` (a " "retention decision in external_account_registry covers it — note " "`exemption.quality: unowned` means that decision has no owner, no " "note and no author, which is indistinguishable from having been " "forgotten), `unknown` (the vendor could not be read; never treat " "as ok). `evidence_age_hours` says how old the underlying vendor " "read is — quote it, because a stale answer here looks exactly " "like a good one. Read-only; admin-tier." ), ) async def list_leaver_account_assurance( verdict: str | None = None, system: str | None = None, q: str | None = None, ) -> dict: async with tool_runtime( "list_leaver_account_assurance", {"verdict": verdict, "system": system, "q": q}, ) as rt: from app.domains.offboarding import ( # noqa: PLC0415 assurance_service, ) _require_admin_read(rt.user.role_names) return { "summary": await assurance_service.summary(rt.db), "people": await assurance_service.list_people( rt.db, verdict=verdict, system=system, q=q, ), } @mcp_server.tool( name="close_leaver_accounts", description=( "Suspend a LEAVER's sign-in at the identity systems named in " "`systems` (default both: google, atlassian), then read the result " "back from the vendor. Global-admin only, audited, idempotent.\n" "Refuses (409) when the person's employment status is not `left` — " "the one unrecoverable outcome here is locking out somebody who " "still works here. Refuses when a retention decision covers the " "account; pass `override_exemption=true` only when the user has " "explicitly said that decision no longer holds, because reversing " "it is a people decision.\n" "Nothing is deleted: Atlassian accounts are suspended, not removed, " "so issue history stays attributed and `/restore-access` reverses " "it. Note the Atlassian org listing lags up to ~24h, so `verified: " "false` right after a successful call means 「not visible yet」, " "not 「it failed」." ), ) async def close_leaver_accounts( person_id: str, systems: str = "google,atlassian", override_exemption: bool = False, ) -> dict: async with tool_runtime( "close_leaver_accounts", { "person_id": person_id, "systems": systems, "override_exemption": override_exemption, }, mutates=True, ) as rt: from uuid import UUID as _UUID # noqa: PLC0415 from app.domains.offboarding import ( # noqa: PLC0415 assurance_service, ) _require_global_admin(rt.user.role_names) wanted = [x.strip() for x in (systems or "").split(",") if x.strip()] if not wanted: return {"error": "no systems selected"} try: result = await assurance_service.enforce_person( rt.db, person_id=_UUID(person_id), systems=wanted, actor_user_id=rt.user.id, trace_id="mcp", override_exemption=override_exemption, ) except assurance_service.AssuranceEnforceError as exc: return {"error": str(exc), "refused": True} await rt.db.commit() return result # ─── 合作方通讯录 (partner directory) ────────────────────────────── @mcp_server.tool( name="lookup_partner_employee", description=( "Is this email address or GitHub handle a PARTNER company's " "employee rather than one of ours? Answered from the partner's own " "directory, mirrored daily (CBG / 平凯星辰 is the one configured " "today).\n" "Why the question is hard, and why guessing is wrong: their staff " "carry `@pingcap.cn` addresses in their own Lark tenant while " "holding `@pingcap.com` Google accounts and GitHub handles in " "ours. Production has ZERO `@pingcap.cn` rows in " "google_orphan_accounts — matching on the full address finds " "nobody. The link is the MAILBOX NAME across the two domains, and " "this tool already applies it.\n" "`match_method` says how strong the answer is: `email_exact` / " "`email_localpart` / `github_id` are identifiers the partner's IT " "assigned and are treated as conclusive; `name` is a name " "collision and is NEVER conclusive — say so rather than reporting " "it as a match.\n" "An empty result means 'not in the partner roster', which is NOT " "the same as 'is our employee': an unmatched account may still be " "a service account, a contractor, or a never-offboarded " "ex-employee. Read-only; admin-tier." ), ) async def lookup_partner_employee( email: str | None = None, github_login: str | None = None, ) -> dict: async with tool_runtime( "lookup_partner_employee", {"email": email, "github_login": github_login}, ) as rt: from sqlalchemy import or_, select # noqa: PLC0415 from app.domains.partner_directory.models import ( # noqa: PLC0415 PartnerDirectoryMember, ) from app.domains.partner_directory.service import ( # noqa: PLC0415 member_json, ) _require_admin_read(rt.user.role_names) needle_email = (email or "").strip().lower() needle_login = (github_login or "").strip().lower() if not needle_email and not needle_login: return { "error": "pass at least one of email / github_login", "matches": [], } clauses = [] if needle_email: clauses.append(PartnerDirectoryMember.email == needle_email) clauses.append( PartnerDirectoryMember.secondary_email == needle_email, ) local = ( needle_email.split("@", 1)[0] if "@" in needle_email else needle_email ) clauses.append(PartnerDirectoryMember.email_name == local) if needle_login: clauses.append( PartnerDirectoryMember.github_login_lower == needle_login, ) rows = (await rt.db.execute( select(PartnerDirectoryMember).where( PartnerDirectoryMember.deleted_at.is_(None), or_(*clauses), ).limit(20), )).scalars().all() out = [] for row in rows: if needle_login and (row.github_login_lower or "") == needle_login: method = "github_id" elif needle_email and needle_email in { row.email or "", row.secondary_email or "", }: method = "email_exact" else: method = "email_localpart" out.append({**member_json(row), "match_method": method}) return { "query": { "email": needle_email or None, "github_login": needle_login or None, }, "matches": out, "match_count": len(out), } @mcp_server.tool( name="list_partner_directory_pending_links", description=( "The partner-attribution review queue: accounts on OUR side that " "look like a partner company's employee but matched on a signal " "too weak to write automatically (today: name only).\n" "Exact matches — mailbox name across domains, full email, or the " "GitHub handle the partner's own directory records — are applied " "by the daily sync and are NOT in this queue; pass " "`state='applied'` to see those, or `state='stale'` for matches " "the latest sync stopped proposing.\n" "Each row carries `match_method` plus the matched partner " "employee, which is the whole point: the tag itself lands on the " "external-account registry and the GitHub ledger, and neither of " "those can hold the reasoning behind it. Read-only; admin-tier." ), ) async def list_partner_directory_pending_links( state: str = "pending_review", surface: str | None = None, limit: int = 50, ) -> dict: async with tool_runtime( "list_partner_directory_pending_links", {"state": state, "surface": surface, "limit": limit}, ) as rt: from app.domains.partner_directory import service as pd # noqa: PLC0415 _require_admin_read(rt.user.role_names) return await pd.list_links( rt.db, state=state, surface=surface, page=1, page_size=max(1, min(limit, 200)), ) @mcp_server.tool( name="review_partner_directory_link", description=( "Decide one partner-attribution match. `confirm` writes the SAME " "tag the automatic path writes (external_guest on the " "external-account registry for email-keyed accounts; 'confirmed " "external' on a GitHub membership) — deliberately the same write, " "so a human-confirmed tag and a machine-applied one behave " "identically at offboarding.\n" "`dismiss` is TERMINAL: the daily sync never re-proposes a " "dismissed match. That is on purpose — a queue that re-raises what " "somebody already rejected is a queue nobody reads by week three. " "Reviving one takes a deliberate action.\n" "A confirm can succeed and still write nothing: if an admin has " "already classified that email by hand, the registry keeps their " "decision and the explanation comes back in `reason`. Report that " "reason rather than claiming the tag was applied.\n" "RBAC: global_admin." ), ) async def review_partner_directory_link( link_id: str, decision: str, note: str | None = None, ) -> dict: async with tool_runtime( "review_partner_directory_link", {"link_id": link_id, "decision": decision, "note": note}, mutates=True, ) as rt: from app.domains.partner_directory import service as pd # noqa: PLC0415 _require_global_admin(rt.user.role_names) result = await pd.review_link( rt.db, link_id=link_id, decision=decision, actor_person_id=str(rt.user.id), trace_id=_trace_str(), note=note, ) await rt.db.commit() return result # ────────────────────────────────────────────────────────────────────── # 助手评测台 (assistant evaluation harness) # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_assistant_eval_suites", description=( "The assistant's own evaluation suites, each with its latest pass " "rate and whether a run is in flight right now.\n" "This is how you answer 'is the Feishu IT assistant any good' with a " "number instead of an anecdote. A suite is a bank of questions asked " "under a simulated employee identity, through the assistant's real " "production path with a dry-run dispatcher — it can neither send a " "confirmation card nor file a request.\n" "READ THE PASS RATE AS A TREND, NOT A GRADE. It is produced by a " "model judging a model of the same family, which is measurably " "generous in absolute terms and reliable about CHANGE. " "`cases_flaky` is often the more useful number: those are questions " "the assistant answered inconsistently across repeats, and a headline " "rate read off a single attempt per case hides them entirely.\n" "Read-only. Requires global_admin / auditor." ), ) async def list_assistant_eval_suites() -> dict: async with tool_runtime("list_assistant_eval_suites", {}) as rt: from app.assistant.evaluation import service as _eval # noqa: PLC0415 from app.assistant.evaluation.constants import ( # noqa: PLC0415 RUN_RUNNING, ) _require_assistant_eval_read(rt.user.role_names) suites = await _eval.list_suites(rt.db) runs, _total = await _eval.list_runs(rt.db, page=1, page_size=200) latest: dict[str, dict] = {} running: set[str] = set() for run in runs: if run["status"] == RUN_RUNNING: running.add(run["suite_id"]) elif run["pass_rate"] is not None: latest.setdefault(run["suite_id"], run) for suite in suites: suite["latest_run"] = latest.get(suite["id"]) suite["run_in_flight"] = suite["id"] in running return {"suites": suites, "total": len(suites)} @mcp_server.tool( name="get_assistant_eval_run", description=( "One evaluation run in full: pass rate, per-dimension means, " "per-category breakdown, failure-type counts, and how it compares " "with the previous run of the same suite.\n" "`comparable` in the diff is not decoration — a run that reached only " "a handful of attempts before its budget expired can show a 20-point " "swing with nothing having changed. When it is false, do not report " "the delta as a finding.\n" "`attempts_errored` is excluded from the pass rate on purpose: a " "provider outage is not a measurement of answer quality. A run with " "many errors and a healthy pass rate means the questions that DID get " "through were answered well, not that everything is fine.\n" "Read-only. Requires global_admin / auditor." ), ) async def get_assistant_eval_run(run_id: str, compare: bool = True) -> dict: async with tool_runtime( "get_assistant_eval_run", {"run_id": run_id, "compare": compare}, ) as rt: import uuid as _uuid # noqa: PLC0415 from app.assistant.evaluation import service as _eval # noqa: PLC0415 from app.assistant.evaluation.models import ( # noqa: PLC0415 AssistantEvalRun, AssistantEvalSuite, ) _require_assistant_eval_read(rt.user.role_names) try: key = _uuid.UUID(str(run_id).strip()) except (TypeError, ValueError): return {"error": f"run_id is not a uuid: {run_id!r}"} run = await rt.db.get(AssistantEvalRun, key) if run is None: return {"error": "run not found", "run_id": run_id} suite = await rt.db.get(AssistantEvalSuite, run.suite_id) payload = _eval.serialize_run( run, suite_name=suite.name if suite else None, ) if compare: baseline = await _eval.previous_terminal_run(rt.db, run=run) payload["comparison"] = ( await _eval.compare_runs(rt.db, run=run, baseline=baseline) if baseline is not None else {"comparable": False, "reason": "no_baseline"} ) return payload @mcp_server.tool( name="list_assistant_eval_failures", description=( "The attempts a run marked FAILED, each with the question, the " "assistant's reply, the documents it actually read, the deterministic " "findings, and the reviewer's reasoning.\n" "This is the worklist, and the classification is the actionable part: " "`kb_doc` means somebody has to write a document, `retrieval` means " "the document exists but was not found or not used, " "`tool_field_or_data` means a tool returned wrong or missing data, " "`action` means a workflow should have been offered and was not, " "`out_of_scope` is a product decision rather than a defect.\n" "A `findings` entry with severity `blocking` was established WITHOUT " "a model — an ungrounded citation, a missing required tool call, a " "reply in the wrong language. Trust those over the reviewer's prose " "when the two disagree, and say so.\n" "Read-only. Requires global_admin / auditor." ), ) async def list_assistant_eval_failures( run_id: str, failure_type: str | None = None, limit: int = 20, ) -> dict: async with tool_runtime( "list_assistant_eval_failures", {"run_id": run_id, "failure_type": failure_type, "limit": limit}, ) as rt: import uuid as _uuid # noqa: PLC0415 from sqlalchemy import select as _select # noqa: PLC0415 from app.assistant.evaluation import service as _eval # noqa: PLC0415 from app.assistant.evaluation.constants import ( # noqa: PLC0415 FAILURE_TYPES, VERDICT_FAIL, ) from app.assistant.evaluation.models import ( # noqa: PLC0415 AssistantEvalResult, ) _require_assistant_eval_read(rt.user.role_names) try: key = _uuid.UUID(str(run_id).strip()) except (TypeError, ValueError): return {"error": f"run_id is not a uuid: {run_id!r}", "failures": []} stmt = _select(AssistantEvalResult).where( AssistantEvalResult.run_id == key, AssistantEvalResult.verdict == VERDICT_FAIL, ) if failure_type and failure_type in FAILURE_TYPES: stmt = stmt.where(AssistantEvalResult.failure_type == failure_type) rows = ( ( await rt.db.execute( stmt.order_by(AssistantEvalResult.created_at.asc()).limit( max(1, min(int(limit), 100)), ) ) ) .scalars() .all() ) return { "run_id": run_id, "failure_type": failure_type, "failures": [ _eval.serialize_result(row, full=True) for row in rows ], "total": len(rows), } @mcp_server.tool( name="run_assistant_eval", description=( "Start an evaluation run of one suite and return immediately — the " "run continues in the background, and `get_assistant_eval_run` " "follows it. Minutes to hours depending on the suite.\n" "It costs real provider tokens on the SAME backend that answers live " "employee questions, so do not start one speculatively or to 'check " "if it works'. One run per suite at a time is enforced; a second " "attempt while one is in flight is refused rather than queued.\n" "`repeats` is the number of times each question is asked. Three is " "the default and six is what an actual pass-rate estimate needs: one " "attempt per case is a sample of size one against a " "non-deterministic system, and it cannot see the inconsistent cases " "at all. Raising it multiplies both the wall clock and the bill.\n" "Nothing an employee sees changes: the run uses a dry-run dispatcher " "and cannot send a card, file a request, or write a transcript.\n" "RBAC: global_admin." ), ) async def run_assistant_eval( suite: str, repeats: int = 3, concurrency: int = 3, ) -> dict: async with tool_runtime( "run_assistant_eval", {"suite": suite, "repeats": repeats, "concurrency": concurrency}, mutates=True, ) as rt: from sqlalchemy import select as _select # noqa: PLC0415 from app.assistant.evaluation import service as _eval # noqa: PLC0415 from app.assistant.evaluation.constants import ( # noqa: PLC0415 TRIGGER_MCP, ) from app.assistant.evaluation.models import ( # noqa: PLC0415 AssistantEvalSuite, ) from app.assistant.evaluation.runner import ( # noqa: PLC0415 EvalRunError, execute_run, ) from app.core.background import spawn as _spawn # noqa: PLC0415 from app.domains.support.copilot import ( # noqa: PLC0415 copilot_available, ) _require_global_admin(rt.user.role_names) if not await copilot_available(rt.db): return { "error": ( "the assistant is disabled or no LLM backend is " "configured — there is nothing to evaluate" ), } needle = str(suite or "").strip() row = ( await rt.db.execute( _select(AssistantEvalSuite).where( AssistantEvalSuite.slug == needle, ) ) ).scalar_one_or_none() if row is None: return { "error": f"no evaluation suite with slug {needle!r}", "hint": "call list_assistant_eval_suites for the valid slugs", } try: run = await _eval.start_run( rt.db, suite_id=row.id, repeats=repeats, concurrency=concurrency, trigger=TRIGGER_MCP, actor_person_id=rt.user.id, trace_id=_trace_str(), ) except EvalRunError as exc: return {"error": str(exc), "code": exc.code} run_id = run.id await rt.db.commit() # After the commit, never before: a spawned run that outlives a # rolled-back transaction would execute against a run row that does # not exist. _spawn(execute_run(run_id), name="assistant.eval_run") return { "run_id": str(run_id), "suite": row.slug, "repeats": run.repeats, "cases": run.cases_total, "status": "running", "note": ( "started in the background; poll get_assistant_eval_run for " "the result" ), } # ────────────────────────────────────────────────────────────────────── # 知识循环 (knowledge loop) # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_knowledge_proposals", description=( "The knowledge-base review queue: machine-suggested changes to the " "company's published guidance, waiting for a person to decide.\n" "This is where 'the knowledge base has a hole' turns into something " "specific. Each row names ONE page and ONE change — add these words, " "append this section, merge these two, these two contradict each " "other — with the reasoning and the evidence that produced it.\n" "`kind` is the useful filter and the kinds are not interchangeable: " "`add_aliases` is vocabulary (the corpus's measured recall ceiling is " "words, not ranking, so these are the cheap wins), `merge` archives a " "page, `contradiction` deliberately picks NO winner and exists so the " "pair is visible to whoever owns the policy.\n" "`source` says what noticed it: `lint` is the weekly self-check, " "`ingest_ticket` is a resolved ticket arriving as new knowledge, " "`eval_failure` came from the assistant evaluation harness, " "`answer_backfill` from an answer an employee said was helpful.\n" "Read-only. Requires global_admin / auditor." ), ) async def list_knowledge_proposals( corpus: str | None = None, kind: str | None = None, source: str | None = None, status: str = "open", limit: int = 30, ) -> dict: async with tool_runtime( "list_knowledge_proposals", {"corpus": corpus, "kind": kind, "source": source, "status": status}, ) as rt: from app.core.knowledge_loop import service as _loop # noqa: PLC0415 from app.core.knowledge_loop.constants import ( # noqa: PLC0415 CORPORA, KINDS, SOURCES, STATUSES, ) _require_assistant_eval_read(rt.user.role_names) rows, total = await _loop.list_proposals( rt.db, corpus=corpus if corpus in CORPORA else None, kind=kind if kind in KINDS else None, source=source if source in SOURCES else None, status=status if status in STATUSES else "open", page=1, page_size=max(1, min(int(limit), 200)), ) summary = await _loop.queue_summary(rt.db) return {"proposals": rows, "total": total, "queue": summary} @mcp_server.tool( name="get_knowledge_proposal", description=( "One proposal in full, together with the page it targets AS IT STANDS " "TODAY.\n" "The live re-read is the point: a proposal written last week against a " "page somebody has since rewritten is a proposal about a document that " "no longer exists, and judging it against the version the proposer saw " "is the most confident possible way to reach a wrong conclusion.\n" "`context.target.read_only` true means the page is a Feishu wiki " "mirror — Feishu is its only editing surface, an edit made in Helix " "would be overwritten by the next sync, and this proposal cannot be " "applied. Say that rather than suggesting someone accept it.\n" "Read-only. Requires global_admin / auditor." ), ) async def get_knowledge_proposal(proposal_id: str) -> dict: async with tool_runtime( "get_knowledge_proposal", {"proposal_id": proposal_id}, ) as rt: import uuid as _uuid # noqa: PLC0415 from app.core.knowledge_loop import service as _loop # noqa: PLC0415 from app.core.knowledge_loop.models import ( # noqa: PLC0415 KnowledgeProposal, ) from app.core.knowledge_loop.registry import ( # noqa: PLC0415 adapter_for, ) _require_assistant_eval_read(rt.user.role_names) try: key = _uuid.UUID(str(proposal_id).strip()) except (TypeError, ValueError): return {"error": f"proposal_id is not a uuid: {proposal_id!r}"} row = await rt.db.get(KnowledgeProposal, key) if row is None: return {"error": "proposal not found", "proposal_id": proposal_id} data = _loop.serialize(row) adapter = adapter_for(row.corpus) data["context"] = ( await adapter.describe(rt.db, proposal=row) if adapter else {} ) return data @mcp_server.tool( name="read_kb_index", description=( "The IT knowledge base as a LIST — one line per published page: id, " "title, a one-sentence summary of what it is for, and its alternate " "phrasings.\n" "Use this when the question is about the corpus rather than about one " "answer: what does the knowledge base cover, is X written down " "anywhere, which pages have no alternate phrasings, where would a new " "document belong. `search_support_kb` answers a question; this shows " "the shape of what exists.\n" "`has_summary` false means the line you are reading is the head of the " "body rather than a written summary — usable, but do not quote it as " "if it were a description somebody wrote.\n" "Read-only. Requires global_admin / auditor." ), ) async def read_kb_index() -> dict: async with tool_runtime("read_kb_index", {}) as rt: from app.domains.support.kb_wiki import ( # noqa: PLC0415 index, index_health, ) _require_assistant_eval_read(rt.user.role_names) return { "entries": await index(rt.db), "health": await index_health(rt.db), } @mcp_server.tool( name="run_knowledge_lint", description=( "Read the whole knowledge base and file proposals for what is wrong " "with it: pages that duplicate each other, pages that contradict each " "other, questions employees asked that hit nothing but should have " "matched an existing page, and links pointing at pages that have since " "been archived.\n" "It CHANGES NOTHING PUBLISHED. Every finding lands as a proposal for a " "person to accept or reject — accepting is deliberately not available " "through MCP, because it changes what every employee is told and the " "decision needs the target page side by side.\n" "It costs real model calls on the same backend that answers live " "employee questions (one per candidate pair, one per gap question), so " "do not run it to see what it does — it runs weekly on its own. " "Proposals deduplicate on content, so a re-run mostly produces " "nothing.\n" "RBAC: global_admin." ), ) async def run_knowledge_lint(window_days: int = 14) -> dict: async with tool_runtime( "run_knowledge_lint", {"window_days": window_days}, mutates=True, ) as rt: from app.domains.support.copilot import ( # noqa: PLC0415 copilot_available, ) from app.domains.support.kb_lint import run_lint # noqa: PLC0415 _require_global_admin(rt.user.role_names) if not await copilot_available(rt.db): return { "error": ( "no LLM backend is configured — every pass here needs a " "model to confirm its findings, so there is nothing to run" ), } result = await run_lint( rt.db, window_days=max(1, min(int(window_days), 90)), ) await rt.db.commit() return result # ────────────────────────────────────────────────────────────────────── # Security awareness (安全宣贯) — the employee learning library. # # Read-only on purpose, and deliberately WITHOUT an impression write. # The HTTP detail route records an exposure because a human opened the # page; an agent paging through the library on somebody's behalf has not # shown them anything, and counting it would inflate the exact coverage # number this domain exists to make trustworthy. See the note in # ``security_awareness/router.py::get_material``. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_security_awareness_materials", description=( "The security-awareness library the employee can read in Helix: " "comics, articles and links published by the security team, with " "category, tags, an estimated reading time, and whether THIS user " "has opened each one before.\n" "Use it to answer 'what security training material is there', 'what " "have I not read yet', or to find the right piece to point somebody " "at. `q` matches titles / summaries / tags across all languages, so " "an English query still finds a Chinese-titled item.\n" "`is_fallback_lang` true means this user's language is not available " "for that item and they are being shown another one — say so rather " "than presenting it as if it were written for them.\n" "Published items only; drafts and archived items are invisible here " "exactly as they are in the UI. Read-only, any employee, own scope." ), ) async def list_security_awareness_materials( category: str | None = None, tag: str | None = None, q: str | None = None, lang: str | None = None, ) -> dict: async with tool_runtime( "list_security_awareness_materials", {"category": category, "tag": tag, "q": q, "lang": lang}, ) as rt: from app.domains.security_awareness.service import ( # noqa: PLC0415 SecurityAwarenessService, normalize_lang, ) svc = SecurityAwarenessService(rt.db) resolved = normalize_lang(lang) rows = await svc.list_library( person_id=rt.user.id, lang=resolved, category=category, tag=tag, q=q, ) facets = await svc.facets(person_id=rt.user.id, lang=resolved) return { "materials": [ { "id": str(r["id"]), "kind": r["kind"], "category": r["category"], "tags": r["tags"], "title": r["title"], "summary": r["summary"], "estimated_minutes": r["estimated_minutes"], "is_pinned": r["is_pinned"], "lang": r["lang"], "is_fallback_lang": r["is_fallback_lang"], "viewed_at": ( r["viewed_at"].isoformat() if r["viewed_at"] else None ), } for r in rows ], "total_published": facets["total"], "unread": facets["unread"], } @mcp_server.tool( name="read_security_awareness_material", description=( "The full content of one security-awareness item: the article body " "(markdown), or the link to follow, plus which languages it exists " "in.\n" "Comics come back as a page COUNT, not as images — the pages are " "private object-storage files behind short-lived signed URLs, and a " "URL handed to an agent is a URL that outlives the read. Send the " "person to the item in Helix to look at it.\n" "Reading through this tool does NOT record that the employee saw the " "material — only a human opening it in Helix does. So do not tell " "somebody they are 'covered' because you read it for them.\n" "Read-only, any employee, own scope." ), ) async def read_security_awareness_material( material_id: str, lang: str | None = None, ) -> dict: async with tool_runtime( "read_security_awareness_material", {"material_id": material_id, "lang": lang}, ) as rt: import uuid as _uuid # noqa: PLC0415 from app.domains.security_awareness.service import ( # noqa: PLC0415 SecurityAwarenessService, normalize_lang, ) try: mid = _uuid.UUID(str(material_id)) except (ValueError, AttributeError): return {"error": f"not a material id: {material_id!r}"} detail = await SecurityAwarenessService(rt.db).get_material_detail( person_id=rt.user.id, material_id=mid, lang=normalize_lang(lang), ) return { "id": str(detail["id"]), "kind": detail["kind"], "category": detail["category"], "tags": detail["tags"], "title": detail["title"], "summary": detail["summary"], "body": detail["body"], "link_url": detail["link_url"], "page_count": len(detail["image_urls"]), "estimated_minutes": detail["estimated_minutes"], "lang": detail["lang"], "is_fallback_lang": detail["is_fallback_lang"], "available_langs": detail["available_langs"], "viewed_at": ( detail["viewed_at"].isoformat() if detail["viewed_at"] else None ), } # ────────────────────────────────────────────────────────────────────── # Security awareness campaigns — the employee's own side. # # The acknowledgement IS available here, unlike the material reads above: # pressing "我已阅读并理解" is a deliberate act by the person, and an # agent relaying that act on their explicit instruction is the same act. # What is NOT available is anything that would let an agent decide # somebody has been reached — the exposure ledger stays untouched by MCP. # ────────────────────────────────────────────────────────────────────── @mcp_server.tool( name="list_my_security_awareness", description=( "Security-awareness reading THIS user still owes: required " "material pushed to them that they have not yet confirmed, with " "the due date and whether they have opened it.\n" "Use it for 'what security training do I still need to do' and " "before telling somebody they are up to date. Set " "`include_voluntary` to also list the optional notices they were " "sent — those are not work and are excluded by default, which is " "the same distinction the product makes.\n" "`confirmed_by_quiz` true means that item is completed by " "answering the material's quiz in Helix — `acknowledge_security_" "awareness` will refuse it, and there is no tool that answers a " "quiz. Send the person to the material.\n" "An empty list means nothing is outstanding. Read-only, any " "employee, own scope." ), ) async def list_my_security_awareness(include_voluntary: bool = False) -> dict: async with tool_runtime( "list_my_security_awareness", {"include_voluntary": include_voluntary}, ) as rt: from app.domains.security_awareness.campaign_service import ( # noqa: PLC0415 CampaignService, ) rows = await CampaignService(rt.db).my_pending( rt.user.id, include_voluntary=bool(include_voluntary), ) return { "pending": [ { "campaign_id": r["campaign_id"], "material_id": r["material_id"], "title": r["title"], "summary": r["summary"], "kind": r["kind"], "category": r["category"], "mandatory": r["mandatory"], "due_at": r["due_at"].isoformat() if r["due_at"] else None, "opened": r["opened_at"] is not None, # True = completed by answering the material's quiz, # NOT by acknowledge_security_awareness (which # returns SECAW_QUIZ_REQUIRED for these). "confirmed_by_quiz": r["has_quiz"], "estimated_minutes": r["estimated_minutes"], } for r in rows ], "count": len(rows), } @mcp_server.tool( name="acknowledge_security_awareness", description=( "Record that THIS user has read and understood one piece of " "required security material. Same act as pressing the button in " "Helix, and it lands in the compliance record with a timestamp.\n" "Only do this when the person has actually told you they have " "read it. It is not a way to clear a list — the timestamp is " "evidence somebody may rely on later, and confirming on their " "behalf without them reading it makes that evidence false.\n" "Some material is confirmed by ANSWERING ITS QUIZ instead, and " "this tool returns `SECAW_QUIZ_REQUIRED` for those. That is not " "a transient failure and there is no tool that answers a quiz — " "answering for somebody would manufacture the exact evidence the " "quiz exists to produce honestly. Send them to the material in " "Helix; the quiz is two or three questions.\n" "Idempotent: acknowledging twice returns the FIRST timestamp " "rather than moving it. `already` true means it was already " "confirmed. Any employee, own scope." ), ) async def acknowledge_security_awareness(campaign_id: str) -> dict: async with tool_runtime( "acknowledge_security_awareness", {"campaign_id": campaign_id}, mutates=True, ) as rt: import uuid as _uuid # noqa: PLC0415 from app.domains.security_awareness.campaign_service import ( # noqa: PLC0415 CampaignService, ) from app.domains.security_awareness.catalog import ACK_MCP # noqa: PLC0415 try: cid = _uuid.UUID(str(campaign_id)) except (ValueError, AttributeError): return {"error": f"not a campaign id: {campaign_id!r}"} result = await CampaignService(rt.db).acknowledge( person_id=rt.user.id, campaign_id=cid, source=ACK_MCP, trace_id=_trace_str(), ) await rt.db.commit() return { "acknowledged_at": result["acknowledged_at"].isoformat(), "already": result["already"], } @mcp_server.tool( name="get_security_awareness_coverage", description=( "How much of the workforce confirmed the security material they " "were REQUIRED to read: per campaign, plus a people-weighted " "roll-up over the window.\n" "Use it for 'what is our security awareness coverage', for a " "compliance or audit question about training, and before " "asserting anything about how well-covered the company is.\n" "Voluntary notices are excluded — they ask nothing of anybody, " "so counting them would inflate the number in the one direction " "that matters. The roll-up weights by PEOPLE, not by campaign: a " "campaign sent to 3 people does not move it as much as one sent " "to 480. Nothing is rounded up; a closed campaign keeps its " "unconfirmed rows.\n" "`targeted` is who the campaign was ACTUALLY sent to at the time, " "from the per-person ledger — not today's headcount. Do not " "recompute a percentage against a different denominator.\n" "Read-only. Requires the security read scope (global_admin / " "auditor / security_officer / management)." ), ) async def get_security_awareness_coverage(window_days: int = 365) -> dict: async with tool_runtime( "get_security_awareness_coverage", {"window_days": window_days}, ) as rt: from app.core.rbac import can_view_security # noqa: PLC0415 from app.domains.security_awareness.compliance import ( # noqa: PLC0415 coverage_report, evaluate_awareness_control, ) if not can_view_security(rt.user.role_names): return {"error": "requires the security read scope"} window = max(1, min(int(window_days), 1825)) report = await coverage_report(rt.db, window_days=window) control = await evaluate_awareness_control(rt.db) return { "rollup": report["rollup"], "window_days": report["window_days"], "campaigns": [ { "name": c["name"], "status": c["status"], "sent_at": c["sent_at"], "targeted": c["targeted"], "opened": c["opened"], "acknowledged": c["acknowledged"], "coverage_pct": c["coverage_pct"], } for c in report["campaigns"] ], # The GRC control's own verdict, so an agent answering "are we # compliant" reads the same status the compliance page shows # rather than inventing a threshold of its own. "control": { "key": "security_awareness_training", "status": control["status"], "score": control["score"], "summary": control["summary"], }, } # ══════════════════════════════════════════════════════════════════════ # Automation exceptions (migration 618) — what broke, and what to do # ══════════════════════════════════════════════════════════════════════ @mcp_server.tool( name="list_automation_exceptions", description=( "What automation is currently broken, worst first: failed account " "provisioning, undelivered notifications, failed cron runs, blocked " "identity syncs, and integrations whose credentials no longer " "work — deduplicated into one row per distinct problem.\n" "Use it for 'what is broken right now', 'why did this person not " "get their account', 'is anything wrong with ', and before " "asserting that a piece of automation is healthy.\n" "Each row is a CLUSTER, not a single failure: `occurrence_count` " "is how many times it happened, `open_occurrence_count` is how " "many have not recovered, and `affected_person_count` is how many " "real people are still waiting. Quote the open count, not the " "total — the difference is the part that already fixed itself.\n" "`root_cause` is one of a closed set and `remedy` is derived from " "it, so every row carries a next step. A `root_cause` of " "'unknown' means the rules declined to guess, NOT that the " "problem is mysterious — say so rather than inventing a cause.\n" "Defaults to still-open rows. Read-only. Requires the admin read " "scope (global_admin / auditor / management)." ), ) async def list_automation_exceptions( status: str = "active", source: str = "", severity: str = "", root_cause: str = "", unowned_only: bool = False, search: str = "", limit: int = 20, ) -> dict: async with tool_runtime( "list_automation_exceptions", { "status": status, "source": source, "severity": severity, "root_cause": root_cause, "unowned_only": unowned_only, "search": search, "limit": limit, }, ) as rt: from app.domains.automation_exception import service # noqa: PLC0415 _require_admin_read(rt.user.role_names) _, size = _clamp_pagination(1, limit, max_size=100) data = await service.list_exceptions( rt.db, status=status or "active", source=source or None, severity=severity or None, root_cause=root_cause or None, unowned_only=bool(unowned_only), search=_bound_free_text(search, field="search", max_len=200) or None, page=1, page_size=size, ) return { "total": data["total"], "returned": len(data["items"]), "items": [ { "id": item["id"], "source": item["source"], "scope": item["scope_key"], "signature": item["signature"], "status": item["status"], "severity": item["severity"], "root_cause": item["root_cause"], "remedy": item["remedy"], "occurrence_count": item["occurrence_count"], "open_occurrence_count": item["open_occurrence_count"], "affected_person_count": item["affected_person_count"], "recurrence_count": item["recurrence_count"], "first_seen_at": item["first_seen_at"], "last_seen_at": item["last_seen_at"], "owner": item["owner"], "integration": item["integration"], } for item in data["items"] ], } @mcp_server.tool( name="get_automation_exception", description=( "One automation exception in full: its evidence, the people still " "affected, and the remedy implied by its root cause.\n" "Use it after list_automation_exceptions to explain a specific " "problem, or when asked why a named person's account, seat or " "notification did not go through.\n" "`occurrences` are the individual failing rows behind the " "cluster, newest first and capped — `occurrences_truncated` says " "when there are more. An occurrence with `recovered_at` set is no " "longer broken and must not be counted as outstanding.\n" "`sample_error` is one representative vendor message, not a " "summary of all of them. Read-only. Requires the admin read scope." ), ) async def get_automation_exception(exception_id: str) -> dict: async with tool_runtime( "get_automation_exception", {"exception_id": exception_id}, ) as rt: from app.domains.automation_exception import service # noqa: PLC0415 _require_admin_read(rt.user.role_names) return await service.get_exception(rt.db, _u(exception_id)) @mcp_server.tool( name="automation_exception_overview", description=( "Counters for the automation-exception workbench: how many " "problems are open by severity, by source and by root cause, how " "many people are affected right now, how many rows nobody owns, " "and how many closed in the window without anyone touching them.\n" "Use it for 'how healthy is our automation', for a weekly or " "monthly operations summary, and to answer whether things are " "getting better or worse.\n" "`resolved_by_kind` separates self-healing ('auto') from human " "and replay-driven closures — the self-healing share is the " "honest measure of whether the platform recovers on its own, and " "its denominator is CLOSED clusters in the window, not all rows.\n" "`unowned_active` is a finding, not a gap in the data: an " "exception with no resolvable owner is reported as unowned on " "purpose rather than defaulted to IT.\n" "Read-only. Requires the admin read scope." ), ) async def automation_exception_overview(window_days: int = 7) -> dict: async with tool_runtime( "automation_exception_overview", {"window_days": window_days}, ) as rt: from app.domains.automation_exception import service # noqa: PLC0415 _require_admin_read(rt.user.role_names) return await service.get_overview( rt.db, window_days=max(1, min(int(window_days), 180)), ) __all__: list[str] = []