#!/usr/bin/env python3
"""Read-only Upkeep MCP diagnostic; Python 3 standard library, no jq required.

Run: python3 upkeep-mcp-read.py --query fridge
Use the local hidden credential prompt, or UPKEEP_ACCESS_TOKEN from a secure
credential store. Never paste a token in chat or pass it on the command line.
This example does not implement OAuth authorization, refresh, or any write.
"""

import argparse
import getpass
import json
import os
import sys
import shlex
import subprocess
from urllib import error, parse, request

ENDPOINT = "https://apps.sharshi.com/api/v1/upkeep/mcp"
MAX_RESPONSE_BYTES = 3 * 1024 * 1024


class McpError(Exception):
    """A failed transport/protocol/tool response, never an empty inventory."""


def decode_response(status, content_type, body, request_id):
    if not 200 <= status < 300:
        explanations = {401: "Reconnect: the credential is invalid or expired.",
                        402: "Active membership is required.",
                        403: "The credential lacks the required scope; check WWW-Authenticate.",
                        406: "Send Accept: application/json, text/event-stream."}
        raise McpError(f"HTTP {status}. {explanations.get(status, 'Request failed.')}")
    if request_id is None and status in (202, 204) and not body.strip():
        return None  # Accepted initialized notification, not an inventory result.
    try:
        if content_type.split(";", 1)[0].strip() == "text/event-stream":
            messages, data = [], []
            # SSE may contain comments, notifications, and multiline data fields.
            for line in body.replace("\r\n", "\n").split("\n") + [""]:
                if not line:
                    if data:
                        messages.append(json.loads("\n".join(data)))
                        data = []
                elif line.startswith("data:"):
                    data.append(line[5:].removeprefix(" "))
        elif content_type.split(";", 1)[0].strip() == "application/json":
            messages = [json.loads(body)]
        else:
            raise McpError("Expected JSON or SSE, not an HTML page or another content type.")
    except (ValueError, TypeError) as exc:
        raise McpError("Unreadable JSON/SSE response; no inventory result was obtained.") from exc

    for message in messages:
        if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
            raise McpError("Invalid JSON-RPC response.")
        if message.get("error") is not None:
            rpc_error = message["error"]
            code = rpc_error.get("code") if isinstance(rpc_error, dict) else "unknown"
            raise McpError(f"JSON-RPC error {code}; check the method, discovered tool and input schema.")
        if message.get("id") != request_id:
            continue
        result = message.get("result")
        if not isinstance(result, dict):
            raise McpError("Missing JSON-RPC result; do not substitute an empty list.")
        if result.get("isError") is True:
            raise McpError("Tool execution failed (isError=true); no successful inventory result.")
        return result
    raise McpError("No completed response with the matching request ID.")


def tool_data(result):
    if "structuredContent" in result:
        value = result["structuredContent"]
        if not isinstance(value, dict):
            raise McpError("Invalid structuredContent; expected a JSON object.")
        return value
    # Older clients/servers expose the same object as serialized JSON text.
    values = []
    contents = result.get("content", [])
    if not isinstance(contents, list):
        raise McpError("Invalid text content; expected an array.")
    for content in contents:
        if isinstance(content, dict) and content.get("type") == "text":
            try:
                value = json.loads(content.get("text", ""))
            except (ValueError, TypeError):
                continue
            if isinstance(value, dict):
                values.append(value)
    if len(values) != 1:
        raise McpError("Expected one JSON text object; no valid tool data was obtained.")
    return values[0]


def search_inventory(client, tools, query, limit=25):
    legacy = next((tool for tool in tools if tool.get("name") == "upkeep.search_assets"), None)
    if legacy:
        return tool_data(client.call("tools/call", {"name": legacy["name"], "arguments": {"query": query, "limit": limit}}))
    workflow = next((tool for tool in tools if tool.get("name") == "morrowfield.assets"), None)
    if not workflow:
        raise McpError("Inventory workflow is absent; no action name will be guessed.")
    discovered = tool_data(client.call("tools/call", {"name": workflow["name"], "arguments": {}}))
    operation = next((action for action in discovered.get("actions", []) if action.get("operation") == "upkeep.search_assets"), None)
    if not operation:
        raise McpError("Inventory search is not available in this connection.")
    data = tool_data(client.call("tools/call", {"name": workflow["name"], "arguments": {"operation": operation["operation"], "arguments": {"query": query, "limit": limit}}}))
    if not isinstance(data.get("result"), dict):
        raise McpError("Inventory workflow did not return a completed result.")
    return data["result"]


def asset_rows(data):
    rows = data.get("items")
    if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows):
        raise McpError("Search result has no valid items array; this is not empty inventory.")
    if any(not isinstance(row.get("id"), str) or not isinstance(row.get("name"), str) for row in rows):
        raise McpError("Search rows must contain opaque string IDs and names.")
    if type(data.get("count")) is not int or data["count"] != len(rows):
        raise McpError("Search result count does not match the returned items.")
    return rows


class NoRedirect(request.HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        # Never forward the Bearer credential to a redirect destination.
        raise McpError("Endpoint redirected; verify the canonical server URL before reconnecting.")


class Client:
    def __init__(self, endpoint, token):
        url = parse.urlsplit(endpoint)
        if url.username or url.password or url.query or url.fragment or not url.hostname:
            raise McpError("Use a server URL without credentials, query parameters or fragments.")
        if url.scheme != "https" and not (url.scheme == "http" and url.hostname in ("localhost", "127.0.0.1", "::1")):
            raise McpError("HTTPS is required outside loopback development.")
        self.endpoint, self.token = endpoint, token
        self.protocol_version, self.next_id = None, 1
        self.opener = request.build_opener(NoRedirect())

    def call(self, method, params, notification=False):
        request_id = None if notification else self.next_id
        if not notification:
            self.next_id += 1
        payload = {"jsonrpc": "2.0", "method": method, "params": params}
        if request_id is not None:
            payload["id"] = request_id  # Integers also avoid this SDK's string-ID restriction.
        headers = {"Authorization": f"Bearer {self.token}", "Content-Type": "application/json",
                   "Accept": "application/json, text/event-stream", "X-Agent-Name": "upkeep-read-diagnostic"}
        if self.protocol_version:
            headers["MCP-Protocol-Version"] = self.protocol_version
        req = request.Request(self.endpoint, data=json.dumps(payload).encode(), headers=headers, method="POST")
        try:
            with self.opener.open(req, timeout=30) as response:
                raw = response.read(MAX_RESPONSE_BYTES + 1)
                if len(raw) > MAX_RESPONSE_BYTES:
                    raise McpError("Response exceeds the diagnostic's size limit; use a narrower query.")
                return decode_response(response.status, response.headers.get("Content-Type", ""), raw.decode("utf-8"), request_id)
        except error.HTTPError as exc:
            # Report the status, never echo a body or token into diagnostics/logs.
            return decode_response(exc.code, "", "", request_id)
        except (error.URLError, TimeoutError, UnicodeError) as exc:
            raise McpError("Connection or decoding failed; this says nothing about saved inventory.") from exc

    def connect(self):
        result = self.call("initialize", {"protocolVersion": "2025-06-18", "capabilities": {},
                           "clientInfo": {"name": "upkeep-read-diagnostic", "version": "1.0"}})
        self.protocol_version = result.get("protocolVersion")
        if not isinstance(self.protocol_version, str):
            raise McpError("Initialization did not negotiate a protocol version.")
        self.call("notifications/initialized", {}, notification=True)
        tools, cursor = [], None
        for _ in range(10):
            page = self.call("tools/list", {"cursor": cursor} if cursor else {})
            if not isinstance(page.get("tools"), list) or any(not isinstance(tool, dict) for tool in page["tools"]):
                raise McpError("Invalid tool catalog.")
            tools.extend(page["tools"])
            cursor = page.get("nextCursor")
            if not cursor:
                return tools
        raise McpError("Tool catalog pagination limit reached.")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--query", default="", help="Literal inventory query; blank returns one page of up to 25 assets.")
    parser.add_argument("--endpoint", default=ENDPOINT)
    parser.add_argument("--credential-command", help="Local trusted credential-provider command; stdout must contain the token. Runs without a shell.")
    args = parser.parse_args()
    try:
        token = load_credential(args.credential_command)
        if not token.strip():
            raise McpError("A local credential is required.")
        client = Client(args.endpoint, token.strip())
        tools = client.connect()
        data = search_inventory(client, tools, args.query)
        rows = asset_rows(data)
        print(f"Connected. Discovered {len(tools)} tools. Search returned {len(rows)} asset(s).")
        for row in rows:
            # JSON encoding prevents record names from injecting terminal controls.
            print(json.dumps({"id": row["id"], "name": row["name"]}, ensure_ascii=True))
        if data.get("search", {}).get("status") == "pending":
            print("AI ranking is pending. Retry this query; do not conclude inventory is empty.")
        elif not rows:
            print("Successful empty result for this query and account. Try a blank query; this is not proof all inventory is empty.")
        if data.get("meta", {}).get("next_cursor"):
            print("More assets exist. Follow meta.next_cursor with the same search, or use the paginated REST inventory.")
        print("This diagnostic is read-only. MCP deletion requires propose_asset_deletion, explicit approval, then delete_asset with write scope.")
    except McpError as exc:
        print(f"Check failed: {exc}", file=sys.stderr)
        return 1
    return 0


def load_credential(command=None):
    if command:
        try:
            result = subprocess.run(shlex.split(command), capture_output=True, text=True, timeout=10, check=True)
        except (subprocess.SubprocessError, OSError, ValueError):
            raise McpError("The local credential provider failed. Check its secure-store configuration.") from None
        token = result.stdout.strip()
        if not token or len(token) > 16384 or any(c.isspace() for c in token):
            raise McpError("The credential provider must return one token, without other output.")
        return token
    return os.environ.get("UPKEEP_ACCESS_TOKEN") or getpass.getpass("Upkeep bearer token (local, hidden): ")


if __name__ == "__main__":
    sys.exit(main())
