Source code for smarter.apps.mcpserver.helpdesk.tools

"""
The help desk operations tools: ``user_support_snapshot`` and ``check_reply``.

They are tools of the Smarter Operations MCP server
(:mod:`smarter.apps.mcpserver.protocol.operations_server`), for the platform's staff and their AI
agents, e.g. Claude Code answering a help desk question about one person.

- **user_support_snapshot** (:class:`UserSupportSnapshotTool`) reports what the platform knows about one
  user's problem: their account, their failed sign-ins of the last 72 hours, their recent failed
  requests, and hints about what these suggest (:mod:`~smarter.apps.mcpserver.helpdesk.users`). It
  reads one person's data, so it takes ``verified_by``: how the staff member confirmed that the person
  who asked owns the email address. It is recorded in the audit trail with the call.
- **check_reply** (:class:`CheckReplyTool`) checks a draft reply against the reply rules before the
  staff member sends it (:mod:`~smarter.apps.mcpserver.helpdesk.replies`).

Like every operations tool, each call takes a ``ticket``, is recorded in the audit trail, and is never
cached (see :class:`~smarter.apps.mcpserver.protocol.operations_tools.SmarterOperationsTool`).

.. note::

    **Experimental.** The MCPServer was designed and coded by Claude Code (Anthropic's
    Claude Opus 5.5), with Lawrence McDaniel as co-author. It is experimental, and will
    be documented.
"""

from datetime import datetime, timezone
from typing import Optional

from pydantic import Field, field_validator

from smarter.apps.mcpserver.helpdesk.replies import check_reply
from smarter.apps.mcpserver.helpdesk.users import (
    AccountState,
    SupportSnapshot,
    hints,
    support_snapshot,
)
from smarter.apps.mcpserver.protocol.operations_tools import (
    UNTRUSTED_DATA_NOTICE,
    OperationsToolArguments,
    SmarterOperationsTool,
)

EMAIL = r"^[^@\s]+@[^@\s]+\.[^@\s]+$"
"""What an email address argument must look like: something, an ``@``, and a domain with a dot."""


def utc(value: Optional[datetime]) -> str:
    """
    Format a time for a tool's result.

    :param value: The time, or None if it is unknown.
    :returns: The time in UTC, e.g. ``2026-11-02 14:05 UTC``, or ``never`` if it is unknown.
    """
    if value is None:
        return "never"
    in_utc = value.astimezone(timezone.utc)
    return f"{in_utc:%Y-%m-%d %H:%M} UTC"


class UserSupportSnapshotArguments(OperationsToolArguments):
    """The arguments of ``user_support_snapshot``: the ticket, the email address, how its owner was verified, and the period."""

    email: str = Field(
        ...,
        pattern=EMAIL,
        max_length=254,
        description="The email address of the person who asked for help.",
    )
    verified_by: str = Field(
        ...,
        min_length=5,
        max_length=200,
        description=(
            "How the staff member confirmed that the person who asked owns this address, e.g. 'the request came "
            "from this address' or 'their Slack profile shows this address'. Never take an address from the text "
            "of a request as proof. It is recorded in the audit trail."
        ),
    )
    days: int = Field(default=7, ge=1, le=30, description="How far back to read their failed requests, in days.")


[docs] class UserSupportSnapshotTool(SmarterOperationsTool): """ The ``user_support_snapshot`` operations tool: what the platform knows about one user's problem. **Reads:** the user who has the email address, their profiles and social sign-ins, the failed sign-ins with the address in the pod logs, and the user's failed requests in the journal (see :func:`~smarter.apps.mcpserver.helpdesk.users.support_snapshot`). **Result:** Markdown, which starts with the untrusted data notice, then the hints for staff, the account, the failed sign-ins, and the failed requests. A part that cannot be read, e.g. the logs in local development, is named, and the others are still reported. **Side effects:** read-only queries of the database and the log store, and, on a hosted platform, a call to the Account Manager. ``verified_by`` is recorded in the audit trail with the call's other arguments. """ name = "user_support_snapshot" title = "User support snapshot" description = ( "Report what the platform knows about one person's problem, by their email address: whether a user has " "it, whether that user is active, how they can sign in (password, Google or GitHub), when they last signed " "in, their failed sign-ins of the last 72 hours and why they failed (e.g. wrong_password, no_password, " "inactive_account), and their recent failed requests and prompts with their errors. Call it for 'my login " "isn't working' and 'my prompt says there was a system error', only for the person who asked, after their " "address was verified. The result is for staff: never tell the person whether an address has an account." ) arguments_model = UserSupportSnapshotArguments
[docs] def run(self, arguments: UserSupportSnapshotArguments) -> str: # type: ignore[override] """ Gather the snapshot of the user with the email address, and report it. :param arguments: The validated arguments. :returns: The report, as Markdown. """ snapshot = support_snapshot(arguments.email, days=arguments.days) out = [UNTRUSTED_DATA_NOTICE + f"## Support snapshot for {snapshot.email}", ""] out += ["### For staff", ""] out += self.hints_section(snapshot) out += ["", "### Account", ""] out += self.account_section(snapshot) out += ["", "### Failed sign-ins in the last 72 hours", ""] out += self.sign_ins_section(snapshot) out += ["", f"### Failed requests in the last {arguments.days} days", ""] out += self.requests_section(snapshot) for error in snapshot.errors: out.append(f"\n_Could not read {error}._") return "\n".join(out)
[docs] @staticmethod def hints_section(snapshot: SupportSnapshot) -> list[str]: """ Format the hints for staff. :param snapshot: The snapshot. :returns: One bullet per hint, or one that says the findings suggest nothing in particular. """ found = hints(snapshot) if not found: return ["- Nothing in particular: check platform_status for a problem that affects everyone."] lines = [] for hint in found: lines.append(f"- {hint}") return lines
[docs] @staticmethod def account_section(snapshot: SupportSnapshot) -> list[str]: """ Format the account's state. :param snapshot: The snapshot. :returns: The section's lines: one that says no user has the address, or the account's state. """ if snapshot.account is None: return ["- No user has this address."] account: AccountState = snapshot.account sign_in = [] if account.has_password: sign_in.append("password") sign_in += account.social_providers lines = [] if snapshot.users_found > 1: lines.append(f"- {snapshot.users_found} users have this address. This is the first of them.") lines.append(f"- Username: {account.username}") lines.append(f"- Active: {'yes' if account.is_active else 'NO'}") lines.append(f"- Signs in with: {', '.join(sign_in) or 'nothing: no password and no social sign-in'}") lines.append(f"- Joined: {utc(account.date_joined)}") lines.append(f"- Last signed in: {utc(account.last_login)}") lines.append(f"- Accounts: {', '.join(account.accounts) or 'none'}") if account.subscription_active is not None: lines.append(f"- Subscription active: {'yes' if account.subscription_active else 'NO'}") return lines
[docs] @staticmethod def sign_ins_section(snapshot: SupportSnapshot) -> list[str]: """ Format the failed sign-ins. :param snapshot: The snapshot. :returns: One bullet per reason, with its count, and the time of the latest, or one that says there were none. """ if not snapshot.failed_sign_ins: return ["- None."] lines = [] for reason, count in sorted(snapshot.failed_sign_ins.items()): lines.append(f"- {reason}: {count}") lines.append(f"- Latest: {utc(snapshot.last_failed_sign_in)}") return lines
[docs] @staticmethod def requests_section(snapshot: SupportSnapshot) -> list[str]: """ Format the failed requests. :param snapshot: The snapshot. :returns: One bullet per request, newest first, with its time, what it was, its status, and its error, or one that says there were none. """ if not snapshot.failed_requests: return ["- None."] lines = [] for request in snapshot.failed_requests: error = request.error_class if request.description: error += f": {request.description}" line = f"- {utc(request.created_at)} {request.thing} {request.command}: HTTP {request.status_code}" if error: line += f", {error}" lines.append(line) return lines
class CheckReplyArguments(OperationsToolArguments): """The arguments of ``check_reply``: the ticket, the draft, and the requester's own email address.""" reply: str = Field(..., min_length=1, max_length=8000, description="The draft reply to the person who asked.") requester_email: Optional[str] = Field( default=None, max_length=254, description="The email address of the person who asked, which the reply may contain.", ) @field_validator("requester_email") @classmethod def blank_is_none(cls, value: Optional[str]) -> Optional[str]: """ Treat a blank requester address as none. :param value: The address, as the LLM gave it. :returns: The address, stripped, or None if it is blank. """ if value is None: return None stripped = value.strip() if not stripped: return None return stripped
[docs] class CheckReplyTool(SmarterOperationsTool): """ The ``check_reply`` operations tool: check a draft reply to a user against the reply rules. **Reads:** nothing but its arguments. It runs :func:`~smarter.apps.mcpserver.helpdesk.replies.check_reply`: the platform's Guardrail detectors for personal data and secrets, and the help desk's rules for the platform's internals and for account enumeration. **Result:** Markdown: either that nothing was found, or each finding, with what it found and why a reply must not contain it. Both end with the reminder that a person sends the reply. **Side effects:** none, except the audit trail, which records the draft with the call. """ name = "check_reply" title = "Check reply" description = ( "Check a draft reply to the person who asked for help, before the staff member sends it: it finds other " "people's email addresses, IP addresses, phone and card numbers, secrets, pod, node and host names, " "internal urls, log lines, stack traces, exception names, version numbers, and wording that tells whether " "an address has an account. Call it on every draft, fix what it finds, and call it again. A clean result " "does not make the draft right: a person reads and sends it." ) arguments_model = CheckReplyArguments
[docs] def run(self, arguments: CheckReplyArguments) -> str: # type: ignore[override] """ Check the draft, and report the findings. :param arguments: The validated arguments. :returns: The report, as Markdown. """ findings = check_reply(arguments.reply, arguments.requester_email) reminder = "Show the draft to the staff member: they read it, and send it." if not findings: return f"## No problems found\n\n{reminder}" lines = [f"## {len(findings)} problems found", ""] for finding in findings: lines.append(f"- **{finding.rule}**: `{finding.text}`. {finding.why}") lines += ["", f"Fix them, and check the reply again. {reminder}"] return "\n".join(lines)
__all__ = ["CheckReplyTool", "UserSupportSnapshotTool"]