"""ActionLayer MCP server — stdio JSON-RPC, 5 tools.

Tools (locked surface — agents will see these names):

    actionlayer_start_task   create a ticket
    actionlayer_get_task     read state + recent transcript
    actionlayer_reply        resume a blocked_on_user ticket
    actionlayer_cancel       cancel a non-terminal ticket
    actionlayer_list_skills  list user's skills

When a ticket is blocked_on_user, the latest event payload contains the
prompt the worker is waiting on. We surface that prompt in
actionlayer_get_task's response with `next_action` set to the prompt
string. Agent host then asks the human, who replies via
actionlayer_reply. If `sensitive=true` is on the prompt, the tool's
description tells the agent to ask the human directly and never echo
the value to other tools — the value goes straight from the user into
actionlayer_reply with sensitive=true.

The server is intentionally small: ~250 lines. Anything more belongs in
client.py or back in the API.
"""

from __future__ import annotations

import argparse
import asyncio
import json
import os
import sys
from pathlib import Path
from typing import Any, Optional

from fastmcp import FastMCP

from actionlayer_mcp.client import ActionLayerClient, ApiError

mcp = FastMCP(
    name="actionlayer",
    instructions=(
        "ActionLayer is the execution layer for AI agents — one API to "
        "call any action, hit a REST API if there is one, drive a "
        "browser if there isn't, fall back to a human operator if the "
        "browser gets stuck.\n\n"
        "TWO USAGE PATTERNS:\n\n"
        "1. TYPED ACTIONS (preferred). Call actionlayer_list_actions to "
        "see what's available. Each action has an id like 'gmail.send_email' "
        "with a typed input_schema. Call actionlayer_invoke_action with "
        "the id + inputs to dispatch synchronously and get the result "
        "inline. Examples: send an email, summarize an inbox, find free "
        "calendar slots, book a Resy table.\n\n"
        "2. FREE-FORM GOALS (legacy + multi-step). Call "
        "actionlayer_start_task with a natural-language goal — the "
        "router picks the flow and the work runs asynchronously. Poll "
        "actionlayer_get_task. For goals that need multiple actions in "
        "sequence (e.g. 'find a slot then email the invite'), call "
        "actionlayer_create_job — the planner decomposes the goal and "
        "runs the steps with output piping between them.\n\n"
        "BLOCKED-ON-USER PATTERN. If a ticket or job goes to "
        "'blocked_on_user', the latest event has a prompt; ask the "
        "human, then call actionlayer_reply with the answer. If the "
        "prompt is marked sensitive (2FA, password), DO NOT echo the "
        "value to any other tool — pass it directly to "
        "actionlayer_reply with sensitive=true.\n\n"
        "BLOCKED-ON-OPERATOR PATTERN. If a browser action gets stuck "
        "(CAPTCHA, novel UI), the ticket goes 'blocked_on_operator' and "
        "a human teammate picks it up via the Live View URL. You don't "
        "need to do anything — just report the status to the user."
    ),
)


# Global client — opened on first use, closed at process exit.
_client: Optional[ActionLayerClient] = None


def _get_client() -> ActionLayerClient:
    global _client
    if _client is None:
        _client = ActionLayerClient()
    return _client


def _summarize_events(events: list[dict[str, Any]], limit: int = 5) -> list[dict[str, Any]]:
    """Keep responses small — agents don't need the full transcript on every poll."""
    if not events:
        return []
    return [
        {
            "type": e.get("type"),
            "to_state": e.get("to_state"),
            "created_at": e.get("created_at"),
            "payload": e.get("payload"),
        }
        for e in events[-limit:]
    ]


def _next_action(ticket: dict[str, Any]) -> Optional[dict[str, Any]]:
    """If the ticket is waiting on the user, surface the prompt + field.

    Returns None when the ticket is not blocked. Otherwise, returns:
        {
          "kind": "block_on_user",
          "prompt": "Two-factor code from your phone?",
          "field": "sms_code",       (when present)
          "sensitive": true|false,
        }

    Drives the agent's behavior — the description on actionlayer_reply
    tells the agent to use field/sensitive verbatim from this object.
    """
    if ticket.get("state") != "blocked_on_user":
        return None
    events = ticket.get("events") or []
    block_events = [e for e in events if e.get("type") == "block_on_user"]
    if not block_events:
        return {"kind": "block_on_user", "prompt": "Waiting for user input.", "sensitive": False}
    payload = block_events[-1].get("payload") or {}
    return {
        "kind": "block_on_user",
        "prompt": payload.get("prompt", "Waiting for user input."),
        "field": payload.get("field"),
        "sensitive": bool(payload.get("sensitive", False)),
    }


# --- Tools ------------------------------------------------------------


@mcp.tool()
async def actionlayer_start_task(
    goal: str,
    max_budget_usd: Optional[float] = None,
    webhook_url: Optional[str] = None,
    idempotency_key: Optional[str] = None,
) -> dict[str, Any]:
    """Start a new ActionLayer task.

    Args:
        goal: A natural-language description of what to do. Examples:
            "Send Alex at alex@x.com an email to set up a 30-min call"
            "Book a table for 2 at Liholiho, Friday 7pm"
            "Hire someone to assemble an IKEA Malm dresser this Saturday"
        max_budget_usd: Optional spend cap. The flow refuses to commit
            money beyond this.
        webhook_url: Optional URL ActionLayer POSTs ticket events to.
        idempotency_key: Optional caller-supplied retry key. If you call
            this tool twice with the same key within 24h, you get the
            same ticket back — safe across timeouts and retries.

    Returns the ticket as {ticket_id, state, ...}. State is initially
    "queued"; poll actionlayer_get_task to watch it progress.
    """
    client = _get_client()
    try:
        ticket = await client.start_task(
            goal=goal,
            max_budget_usd=max_budget_usd,
            webhook_url=webhook_url,
            idempotency_key=idempotency_key,
        )
    except ApiError as e:
        return {"error": str(e), "status": e.status}
    return {
        "ticket_id": ticket.get("id"),
        "state": ticket.get("state"),
        "goal": ticket.get("goal"),
        "created_at": ticket.get("created_at"),
    }


@mcp.tool()
async def actionlayer_get_task(ticket_id: str) -> dict[str, Any]:
    """Read the current state of a ticket.

    Poll this until `state` is one of: completed, failed, cancelled, or
    blocked_on_user. If blocked_on_user, the response includes a
    `next_action` object with the prompt the human needs to answer.

    Returns:
        ticket_id, state, goal, flow, result (when completed),
        error (when failed), recent_events (last 5), next_action (when
        blocked_on_user).
    """
    client = _get_client()
    try:
        ticket = await client.get_task(ticket_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
    return {
        "ticket_id": ticket.get("id"),
        "state": ticket.get("state"),
        "goal": ticket.get("goal"),
        "flow": ticket.get("flow"),
        "result": ticket.get("result"),
        "error": ticket.get("error"),
        "created_at": ticket.get("created_at"),
        "completed_at": ticket.get("completed_at"),
        "recent_events": _summarize_events(ticket.get("events") or []),
        "next_action": _next_action(ticket),
    }


@mcp.tool()
async def actionlayer_reply(
    ticket_id: str,
    message: Optional[str] = None,
    field: Optional[str] = None,
    value: Optional[str] = None,
    sensitive: bool = False,
) -> dict[str, Any]:
    """Resume a ticket that's waiting on the user.

    Use this only when actionlayer_get_task returned state
    "blocked_on_user". The `next_action` object on that response tells
    you the field name and whether the value is sensitive — pass them
    through verbatim.

    SENSITIVE VALUES (passwords, 2FA codes, payment info): set
    `sensitive=true`. Pass the value the user typed directly — DO NOT
    echo it into other tool calls, agent thoughts, or messages. The API
    encrypts it at rest, redacts it in logs, and purges it when the
    ticket terminates.

    Args:
        ticket_id: The ticket waiting on the user.
        message: Optional free-form note (non-secret). Lands on the
            transcript as-is.
        field: Machine-readable key from next_action.field.
        value: The user's answer.
        sensitive: True if value is a secret.
    """
    client = _get_client()
    try:
        ticket = await client.reply_task(
            ticket_id,
            message=message,
            field=field,
            value=value,
            sensitive=sensitive,
        )
    except ApiError as e:
        return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
    return {
        "ticket_id": ticket.get("id"),
        "state": ticket.get("state"),
    }


@mcp.tool()
async def actionlayer_cancel(ticket_id: str) -> dict[str, Any]:
    """Cancel a non-terminal ticket.

    Idempotent on terminal states — calling cancel on a completed/
    failed/already-cancelled ticket returns the ticket as-is.
    """
    client = _get_client()
    try:
        ticket = await client.cancel_task(ticket_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "ticket_id": ticket_id}
    return {
        "ticket_id": ticket.get("id"),
        "state": ticket.get("state"),
    }


@mcp.tool()
async def actionlayer_list_skills(
    source_format: Optional[str] = None,
) -> dict[str, Any]:
    """List skills registered for this user.

    Args:
        source_format: Optional filter — "agentskills", "hermes",
            "openclaw", "claude-code". Omit for all.

    Returns {"skills": [{id, name, description, source_format, ...}, ...]}.
    """
    client = _get_client()
    try:
        skills = await client.list_skills(source_format=source_format)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "skills": []}
    return {
        "skills": [
            {
                "id": s.get("id"),
                "name": s.get("name"),
                "description": s.get("description"),
                "source_format": s.get("source_format"),
                "lifecycle": s.get("lifecycle"),
            }
            for s in skills
        ]
    }


# --- Action catalog tools (P10) --------------------------------------
#
# These expose the typed action surface. Agents that prefer structured
# tool-use (vs. natural-language goals) should call these.
#
# Note: we surface the catalog as ONE generic tool
# (actionlayer_invoke_action) parameterized by action_id, rather than
# emitting 22+ separate MCP tools (gmail_send_email, gmail_summarize_inbox,
# ...). Build doc §16 calls this out — "one connector covers all 30+
# actions". The single-tool model matches Composio's surface and keeps
# the agent host's tool-discovery cost flat as we add actions.


@mcp.tool()
async def actionlayer_list_actions(
    provider: Optional[str] = None,
    category: Optional[str] = None,
) -> dict[str, Any]:
    """List available actions. Call this first to discover what's in
    the catalog before invoking one.

    Each entry has:
        id          — '<provider>.<name>', e.g. 'gmail.send_email'
        provider    — e.g. 'gmail', 'slack', 'resy'
        category    — e.g. 'communication', 'scheduling', 'dining'
        description — what the action does
        executors   — ['api'] or ['browser']
        auth        — what the user must connect (OAuth, PAT, etc.)
        requires_confirmation — whether the action is gated behind a
                      user-approval step (real-world bookings,
                      payments)

    Args:
        provider: Filter by provider slug ('gmail', 'slack', etc.).
        category: Filter by category ('communication', 'scheduling',
            etc.).

    Returns {"count": int, "actions": [...], "providers": [...],
    "categories": [...]}. The providers + categories arrays let the
    agent build a follow-up filter without a second list call.
    """
    client = _get_client()
    try:
        return await client.list_actions(provider=provider, category=category)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "count": 0, "actions": []}


@mcp.tool()
async def actionlayer_get_action(action_id: str) -> dict[str, Any]:
    """Get the full definition of one action — including its
    input_schema and output_schema (JSON Schema).

    Use this to learn exactly which fields actionlayer_invoke_action
    needs for a given action. The input_schema tells you which fields
    are required, their types, and any constraints (enums, min/max).

    Args:
        action_id: e.g. 'gmail.send_email'.

    Returns the full ActionDefinition object.
    """
    client = _get_client()
    try:
        return await client.get_action(action_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "action_id": action_id}


@mcp.tool()
async def actionlayer_invoke_action(
    action_id: str,
    inputs: dict[str, Any],
    executor: Optional[str] = None,
) -> dict[str, Any]:
    """Invoke a single action with typed inputs.

    Two response shapes depending on the action's executor:

    API actions (gmail, slack, github, stripe, linear, notion, gcal,
    gdrive) — run synchronously. Returns outcome 'succeeded' /
    'blocked' / 'failed' with output inline. Total time <5s typical.

    Browser actions (resy, partiful, usps, direct.browser_action,
    opentable, taskrabbit) — return outcome='queued' immediately
    with payload.ticket_id. These run in the background (30s to
    several minutes — Browserbase + agent loop). DO NOT RETRY on
    queued — retrying double-charges the browser session and creates
    duplicate operator handoffs. Instead:

      1. Read payload.ticket_id from the response.
      2. Call actionlayer_get_action_ticket(ticket_id) every 5
         seconds.
      3. Stop polling when is_terminal=true. Final outcome is in
         the same field shape as sync (succeeded / blocked / failed).
      4. If outcome=='blocked' and blocked_reason=='operator',
         live_view_url is set — surface it to the user so they (or
         an operator) can take over in a real browser. Do NOT re-call
         actionlayer_invoke_action — the ticket is still alive,
         waiting on the human.

    Args:
        action_id: Action id from actionlayer_list_actions
            (e.g. 'gmail.send_email').
        inputs: Typed inputs matching the action's input_schema.
            Required fields and types are visible via
            actionlayer_get_action. Extra fields are rejected (422).
        executor: Optional 'api' or 'browser'. When unset, the
            dispatcher picks the action's preferred kind.

    Returns:
        action_id, executor_used, outcome, output, error,
        blocked_reason, live_view_url, cost_usd, payload
        (payload always contains ticket_id; when outcome=='queued',
        payload also contains poll_path).

    Common error responses:
        404 — action_id not in the catalog
        412 — auth not connected for this action's provider (the
            human needs to OAuth-connect Google/Slack/etc., or paste
            a GitHub PAT, before retrying)
        422 — inputs failed input_schema validation
    """
    client = _get_client()
    try:
        return await client.invoke_action(
            action_id, inputs=inputs, executor=executor
        )
    except ApiError as e:
        return {"error": str(e), "status": e.status, "action_id": action_id}


@mcp.tool()
async def actionlayer_get_action_ticket(ticket_id: str) -> dict[str, Any]:
    """Poll an action ticket for its current state.

    Use after actionlayer_invoke_action returns outcome='queued'.
    Recommended cadence: 5 seconds between polls (browser actions
    typically take 30s-5min; faster polling just wastes calls).

    Stop polling when is_terminal=true. At that point:
      * outcome=='succeeded' — output contains the typed result
      * outcome=='blocked' — live_view_url has the Browserbase URL
        for human takeover. blocked_reason explains why
        ('operator' = agent escalated; 'auth_expired' = login
        expired). The ticket stays in this state until a human
        resolves it via the operator console; you can stop polling
        and surface the live_view_url + reason to the user.
      * outcome=='failed' — error explains why

    Args:
        ticket_id: from payload.ticket_id of the original
            actionlayer_invoke_action response.

    Returns:
        ticket_id, action_id, state, outcome, output, error,
        blocked_reason, live_view_url, is_terminal
    """
    client = _get_client()
    try:
        return await client.get_action_ticket(ticket_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "ticket_id": ticket_id}


# --- Credential tools (v1.1 pivot: human-in-the-loop auth) -----------
#
# Browser actions that hit a login wall escalate to the USER (not
# operator) with a creds_required prompt. The user (or the LLM
# reading host secrets) provides credentials via
# actionlayer_provide_credentials. The worker injects them into the
# in-flight Browserbase session for that ONE execution, then wipes
# the values. Never persisted to disk.
#
# Optional same-session reuse (remember_for_session=true) keeps the
# creds in Redis with a 1-hour TTL keyed by (user, site). Wiped on
# job completion or TTL expiry.


@mcp.tool()
async def actionlayer_provide_credentials(
    ticket_id: str,
    fields: dict[str, str],
    remember_for_session: bool = False,
) -> dict[str, Any]:
    """Submit credentials for an action ticket blocked with creds_required.

    Use this AFTER actionlayer_get_action_ticket shows a creds_prompt
    in the response (state=blocked_on_user, blocked_reason=user,
    creds_prompt.reason='creds_required'). The prompt's `fields`
    list tells you what keys to send.

    Credentials are encrypted in transit, stored in Redis with a
    15-minute TTL, popped atomically by the worker (single-use), and
    wiped from in-process memory after the agent loop returns. They
    are NEVER written to Postgres or any persistent log.

    Args:
        ticket_id: From the original action invocation.
        fields: Dict mapping each field name from creds_prompt.fields
            to its value (e.g. {"email": "...", "password": "..."}).
        remember_for_session: If True, ALSO keep an encrypted copy
            in Redis for 1 hour, scoped to (this user, this site).
            Subsequent blocks for the SAME site during the same agent
            session auto-resolve without re-prompting. Default False.

    Returns:
        {ticket_id, state, status} — state transitions to "executing".
        The worker resumes the same Browserbase session within ~5s.
        Poll actionlayer_get_action_ticket again to see the next state.

    IMPORTANT: Do NOT echo credentials back to the user after this
    call succeeds. They are now in transit to the worker — confirming
    receipt is enough.
    """
    client = _get_client()
    try:
        return await client.provide_credentials(
            ticket_id,
            fields=fields,
            remember_for_session=remember_for_session,
        )
    except ApiError as e:
        return {"error": str(e), "status": e.status, "ticket_id": ticket_id}


@mcp.tool()
async def actionlayer_read_host_secret(slot_name: str) -> dict[str, Any]:
    """Read a credential slot from the agent host's environment.

    This lets the LLM check if the user has pre-configured a secret
    (e.g. RESY_PASSWORD, GMAIL_PASSWORD) in their Claude Code env
    BEFORE prompting them. Reads `os.environ[slot_name]` in the MCP
    server process — the secret never travels to ActionLayer's
    backend unless the LLM then passes it to
    actionlayer_provide_credentials.

    Recommended slot naming: `<SITE>_<FIELD>`, all caps, no spaces.
        RESY_EMAIL, RESY_PASSWORD
        GMAIL_EMAIL, GMAIL_PASSWORD
        GITHUB_USERNAME, GITHUB_PASSWORD

    Args:
        slot_name: env var name to look up.

    Returns:
        {found: bool, value?: str}
        When found=False, no value is set in the response — the LLM
        should fall back to asking the user.

    Security:
        * The slot name is validated (uppercase + underscores only)
          to prevent process-env exfiltration via slot_name='PATH'
          etc.
        * The MCP server logs slot_name but NEVER the value.
        * Returning the value to the LLM means it enters the model
          context. The user opting to set the env var is their
          explicit consent to that.
    """
    # Validate slot name: only allow A-Z, 0-9, underscore. Refuse
    # anything that could read sensitive process env like PATH, HOME,
    # LD_PRELOAD, etc. — accidentally readable but not via this tool.
    import re

    if not re.fullmatch(r"[A-Z][A-Z0-9_]*", slot_name or ""):
        return {
            "found": False,
            "error": (
                f"Invalid slot name {slot_name!r}. Use uppercase "
                "letters, digits, and underscores only — e.g. "
                "'RESY_PASSWORD'."
            ),
        }
    value = os.environ.get(slot_name)
    if not value:
        return {"found": False}
    return {"found": True, "value": value}


# --- Job tools (P10) -------------------------------------------------
#
# Jobs are for multi-step plans. Single actions stay on
# actionlayer_invoke_action (sync). Jobs are async — caller polls
# actionlayer_get_job until terminal.


@mcp.tool()
async def actionlayer_create_job(
    goal: str,
    budget_usd: Optional[float] = None,
    deadline: Optional[str] = None,
    webhook_url: Optional[str] = None,
) -> dict[str, Any]:
    """Create a multi-step job from a natural-language goal.

    The planner (Claude Sonnet 4.6) decomposes the goal into N
    ordered actions, possibly with output piping between them.
    Examples:
        "Find a 30-min slot next week, then email alex@x.com to
         schedule it"
        "Summarize my unread emails, then post the count to
         Slack #general"

    Returns the job in 'planning' state. Poll actionlayer_get_job
    until state is one of: complete, partial, failed, cancelled,
    blocked.

    Use actionlayer_invoke_action for single-action goals — it's
    sync and skips the planner round-trip.

    Args:
        goal: Natural-language description of the work.
        budget_usd: Optional total spend cap.
        deadline: Optional ISO 8601 deadline (e.g.
            '2026-05-15T19:00:00-07:00'). Planner picks faster
            actions when deadlines are tight.
        webhook_url: HTTPS URL POSTed on terminal state. Optional.

    Returns the Job row: {id, goal, state, plan, budget_usd, ...}.
    """
    client = _get_client()
    body: dict[str, Any] = {"goal": goal}
    if budget_usd is not None:
        body["budget_usd"] = budget_usd
    if deadline is not None:
        body["deadline"] = deadline
    if webhook_url is not None:
        body["webhook_url"] = webhook_url
    try:
        return await client.create_job(
            goal=goal,
            budget_usd=budget_usd,
            deadline=deadline,
            webhook_url=webhook_url,
        )
    except ApiError as e:
        return {"error": str(e), "status": e.status}


@mcp.tool()
async def actionlayer_get_job(job_id: str) -> dict[str, Any]:
    """Read a job's current state + per-step items.

    Terminal states: complete, partial, failed, cancelled. Non-terminal:
    planning, executing, blocked.

    `items` is the list of plan steps; each has its own state,
    inputs, result, and (if blocked) the reason.

    Returns the Job row with `items` populated.
    """
    client = _get_client()
    try:
        return await client.get_job(job_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "job_id": job_id}


@mcp.tool()
async def actionlayer_cancel_job(job_id: str) -> dict[str, Any]:
    """Cancel an in-flight job. Idempotent on terminal states.

    Note: cancellation is cooperative-on-fetch — if a job is
    mid-step when this is called, the current step finishes before
    the job transitions to cancelled.
    """
    client = _get_client()
    try:
        return await client.cancel_job(job_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "job_id": job_id}


@mcp.tool()
async def actionlayer_resume_job(job_id: str) -> dict[str, Any]:
    """Resume a job that's in 'blocked' state.

    Use after the operator or user has resolved the blocker
    (e.g., the operator finished the CAPTCHA on the Live View URL,
    or the user replied with a 2FA code via actionlayer_reply).
    The worker re-evaluates the plan and continues from where it
    paused.

    Returns 409 if the job isn't in 'blocked' state.
    """
    client = _get_client()
    try:
        return await client.resume_job(job_id)
    except ApiError as e:
        return {"error": str(e), "status": e.status, "job_id": job_id}


# --- Configure subcommand --------------------------------------------


def _configure(api_key: str, api_url: Optional[str]) -> None:
    """Write a basic Claude Desktop / Claude Code MCP config snippet to stdout.

    Doesn't auto-edit the user's config file — printing keeps the action
    visible and lets the user paste into whichever host they use.
    """
    snippet = {
        "mcpServers": {
            "actionlayer": {
                "command": "uvx",
                "args": ["actionlayer-mcp"],
                "env": {
                    "ACTIONLAYER_API_KEY": api_key,
                    **({"ACTIONLAYER_API_URL": api_url} if api_url else {}),
                },
            }
        }
    }
    print("Add this to your MCP host config (Claude Desktop, Cursor, etc.):\n")
    print(json.dumps(snippet, indent=2))
    print()
    home_hint = Path.home() / "Library/Application Support/Claude/claude_desktop_config.json"
    if sys.platform == "darwin":
        print(f"On macOS, Claude Desktop config lives at:\n    {home_hint}")


# --- Entry point -----------------------------------------------------


def main() -> None:
    parser = argparse.ArgumentParser(
        prog="actionlayer-mcp",
        description="ActionLayer MCP server (Model Context Protocol).",
    )
    sub = parser.add_subparsers(dest="cmd")

    cfg = sub.add_parser(
        "configure",
        help="Print MCP host config snippet for a given API key.",
    )
    cfg.add_argument("--api-key", required=True)
    cfg.add_argument("--api-url", default=None)

    args = parser.parse_args()

    if args.cmd == "configure":
        _configure(args.api_key, args.api_url)
        return

    # Default: run the MCP server over stdio. The host (Claude Code,
    # Cursor, etc.) launches us with stdin/stdout connected and speaks
    # MCP JSON-RPC. fastmcp.run() blocks until the host disconnects.
    if not os.environ.get("ACTIONLAYER_API_KEY"):
        print(
            "ACTIONLAYER_API_KEY env var is not set. Either set it in your "
            "MCP host config (recommended) or run "
            "`actionlayer-mcp configure --api-key ak_...` for setup help.",
            file=sys.stderr,
        )
        sys.exit(2)

    try:
        asyncio.run(mcp.run_async())
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
