#!/usr/bin/env python3
"""SekolahOS Partner MCP bridge.

Runs as a local MCP server (stdio) for Claude Desktop, Claude Code, Cursor and any other MCP client. It forwards each
JSON-RPC message to the SekolahOS partner endpoint and signs the request with your API key (HMAC-SHA256), so the
secret never leaves this machine. Python 3.8+, standard library only.

Environment:
  EDUCORE_API_BASE    https://<host>/api/v1/        (shown on the API documentation page)
  EDUCORE_KEY_ID      ak_live_...  or  ak_test_...
  EDUCORE_KEY_SECRET  the secret shown once when the key was issued
"""
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import urlparse


LOOPBACK_HOSTS = ('localhost', '127.0.0.1', '::1')
TIMEOUT_SECONDS = 30
MAX_RESPONSE_BYTES = 2 * 1024 * 1024  # a tool result is at most a few hundred KB; refuse to buffer more
MAX_ATTEMPTS = 3                      # one try plus two retries after HTTP 429
MAX_RETRY_WAIT_SECONDS = 10


class _NoRedirect(urllib.request.HTTPRedirectHandler):
    """Never follow a redirect: it would carry the signature headers to whatever host answered."""

    def redirect_request(self, *args, **kwargs):
        return None


_opener = urllib.request.build_opener(_NoRedirect)


def die(message):
    sys.stderr.write(f'educore-mcp-bridge: {message}\n')
    sys.exit(2)


def settings():
    base = os.environ.get('EDUCORE_API_BASE', '').rstrip('/')
    key_id = os.environ.get('EDUCORE_KEY_ID', '')
    secret = os.environ.get('EDUCORE_KEY_SECRET', '')
    if not (base and key_id and secret):
        die('set EDUCORE_API_BASE, EDUCORE_KEY_ID and EDUCORE_KEY_SECRET.')
    parts = urlparse(base)
    if not (parts.scheme == 'https' or (parts.scheme == 'http' and parts.hostname in LOOPBACK_HOSTS)):
        die('EDUCORE_API_BASE must be https (plain http only for localhost).')
    return base + '/partner/mcp', key_id, secret


def sign(secret, ts, path, body):
    signed = '{}:POST:{}:'.format(ts, path).encode() + body
    return hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()


def post(url, key_id, secret, body):
    """POST one signed message. HTTP 429 is retried after its Retry-After (re-signed: the timestamp has aged)."""
    for attempt in range(1, MAX_ATTEMPTS + 1):
        ts = str(int(time.time()))
        digest = sign(secret, ts, urlparse(url).path, body)
        req = urllib.request.Request(url, data=body, method='POST', headers={
            'Content-Type': 'application/json',
            'Accept': 'application/json',
            'X-EduCore-Key-Id': key_id,
            'X-EduCore-Signature': f't={ts},v1={digest}',
        })
        try:
            with _opener.open(req, timeout=TIMEOUT_SECONDS) as resp:
                return resp.status, resp.read(MAX_RESPONSE_BYTES + 1)[:MAX_RESPONSE_BYTES]
        except urllib.error.HTTPError as exc:
            if exc.code == 429 and attempt < MAX_ATTEMPTS:
                try:
                    wait = int(exc.headers.get('Retry-After', 1))
                except (TypeError, ValueError):
                    wait = 1
                time.sleep(min(max(wait, 1), MAX_RETRY_WAIT_SECONDS))
                continue
            return exc.code, exc.read(MAX_RESPONSE_BYTES)


def reply(msg_id, code, message):
    sys.stdout.write(json.dumps({'jsonrpc': '2.0', 'id': msg_id, 'error': {'code': code, 'message': message}}) + '\n')
    sys.stdout.flush()


def main():
    url, key_id, secret = settings()
    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            message = json.loads(line)
        except ValueError:
            reply(None, -32700, 'Parse error: each line must be one JSON-RPC message.')  # never leave the client waiting
            continue
        msg_id = message.get('id') if isinstance(message, dict) else None
        try:
            status, payload = post(url, key_id, secret, line.encode())
        except OSError as exc:
            status, payload = 0, str(exc).encode()
        if status == 202 or not payload:
            continue  # notification accepted
        if len(payload) >= MAX_RESPONSE_BYTES:
            if msg_id is not None:
                reply(msg_id, -32000, 'Response too large; lower limit or add a filter.')
            continue
        if status != 200:
            if msg_id is None:
                continue
            try:
                problem = json.loads(payload)
                detail = '{}: {}'.format(problem.get('code', status), problem.get('detail', ''))
            except (ValueError, AttributeError):
                detail = 'HTTP {} {}'.format(status, payload[:200].decode('utf-8', 'replace'))
            reply(msg_id, -32000, detail)
            continue
        sys.stdout.write(payload.decode('utf-8').replace('\n', ' ') + '\n')
        sys.stdout.flush()


if __name__ == '__main__':
    main()
