diff --git a/resources/scripts/napseer/__init__.py b/resources/scripts/napseer/__init__.py new file mode 100644 index 0000000..5b28cb4 --- /dev/null +++ b/resources/scripts/napseer/__init__.py @@ -0,0 +1,11 @@ +"""Napseer local MCP wrapper package. + +Modules: + crypto - Cryptographic primitives (ChaCha20-Poly1305, AES-256-GCM, key derivation) + vault - Vault operations and secret management + telemetry - Event logging and metrics + gateway - Gateway management and relay + constants - Shared constants (discovery views, kanban, plans, etc.) + tools - MCP tool definitions and registry + cli - CLI interface and command handlers +""" diff --git a/resources/scripts/napseer/constants.py b/resources/scripts/napseer/constants.py new file mode 100644 index 0000000..dae826e --- /dev/null +++ b/resources/scripts/napseer/constants.py @@ -0,0 +1,125 @@ +"""Shared constants for Napseer MCP server.""" + +# Discovery +DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"} +DISCOVERY_DEFAULT_LIMIT = 25 +DISCOVERY_COMPACT_MAX_LIMIT = 10_000 +DISCOVERY_FULL_MAX_LIMIT = 10_000 +DISCOVERY_SOURCE_PAGE_LIMIT = 10_000 +DISCOVERY_CURSOR_GUARD_PAGES = 100 + +# Lifecycle statuses +ACTIVE_EXCLUDED_STATUSES = { + "completed", + "complete_verified_uncommitted", + "complete_pending_human_e2e", + "superseded", + "cancelled", + "archived", + "done", +} +KNOWN_LIFECYCLE_STATUSES = { + "active", + "archived", + "backlog", + "blocked", + "cancelled", + "completed", + "complete_pending_human_e2e", + "complete_verified_uncommitted", + "current", + "doing", + "done", + "draft", + "in_progress", + "pending", + "planned", + "review", + "reviewed_plan", + "superseded", + "todo", +} + +# Summary metadata keys by node type +SUMMARY_METADATA_KEYS = { + "/plans": ["status", "priority", "scope", "owner", "agent_owner", "implementation_allowed", "blocked_by"], + "/kanban": ["status", "column", "priority", "owner", "assignee", "blocked", "blocked_by", "lifecycle_state", "rank", "order", "due_date"], + "/decisions": ["status", "date", "decision_category", "decision_area", "supersedes", "superseded_by"], + "/implementation-notes": ["status", "date", "repos", "verification_summary"], + "/reviews": ["status", "date", "severity_counts"], + "/rules": ["status", "scope", "source"], + "/tasks": ["status", "priority", "blocked_by", "owner", "assignee"], +} + +# Kanban +KANBAN_DONE_STATUSES = {"done", "cancelled", "archived"} +DEFAULT_KANBAN_COLUMNS = ["backlog", "todo", "doing", "review", "done"] +DEFAULT_KANBAN_PRIORITIES = ["low", "normal", "high", "urgent"] +KANBAN_RELATIONS = { + "blocked_by", + "blocks", + "follows", + "precedes", + "relates_to", + "duplicates", + "duplicate_of", + "parent_of", + "child_of", + "tracks", + "tracked_by", + "milestone_of", + "has_milestone", + "mutually_exclusive_with", + "implements", + "references", +} +KANBAN_RELATION_BLOCKED_BY = "blocked_by" +KANBAN_RELATION_BLOCKS = "blocks" +KANBAN_RELATIONS_SOFT_DEPENDENCY = {"follows", "precedes"} +KANBAN_RELATIONS_RELATED = {"relates_to", "tracks", "tracked_by"} +KANBAN_RELATION_CHILD_OF = "child_of" +KANBAN_RELATION_PARENT_OF = "parent_of" +KANBAN_RELATIONS_DUPLICATE = {"duplicates", "duplicate_of"} +KANBAN_RELATIONS_MILESTONE = {"milestone_of", "has_milestone"} +KANBAN_RELATION_MUTUALLY_EXCLUSIVE = "mutually_exclusive_with" +KANBAN_DEPENDENCY_VIEWS = ["ready", "blocked", "blocking", "related", "children", "duplicates"] +KANBAN_DEPENDENCY_VIEW_READY = "ready" +KANBAN_DEPENDENCY_VIEW_BLOCKED = "blocked" +KANBAN_DEPENDENCY_VIEW_BLOCKING = "blocking" +KANBAN_DEPENDENCY_VIEW_RELATED = "related" +KANBAN_DEPENDENCY_VIEW_CHILDREN = "children" +KANBAN_DEPENDENCY_VIEW_DUPLICATES = "duplicates" +KANBAN_EXTERNAL_BLOCKERS_KEY = "external_blockers" +KANBAN_BLOCK_STATE_OPEN_HARD_KEY = "open_hard_blockers" +KANBAN_RESOLVED_EXTERNAL_STATES = {"done", "closed", "resolved"} +KANBAN_SOFT_BLOCKER_STRENGTH = "soft" +KANBAN_PRIORITY_RANK = {"urgent": 0, "high": 1, "normal": 2, "low": 3} +KANBAN_COLUMN_LIFECYCLE = { + "backlog": "open", + "todo": "open", + "doing": "active", + "review": "review", + "done": "done", +} + +# Planning +PLANNING_SOURCE_RELATIONS = { + "created_from", + "derived_from", + "implements", + "references", + "tracks", + "verifies", +} +PLAN_ORIGIN_RELATIONS = { + "created_from", + "derived_from", + "implements", + "references", + "tracks", +} + + +def discovery_max_limit(view): + """Get the max limit for a discovery view.""" + return DISCOVERY_FULL_MAX_LIMIT if view == "full" else DISCOVERY_COMPACT_MAX_LIMIT diff --git a/resources/scripts/napseer/crypto.py b/resources/scripts/napseer/crypto.py new file mode 100644 index 0000000..ad64637 --- /dev/null +++ b/resources/scripts/napseer/crypto.py @@ -0,0 +1,227 @@ +"""Cryptographic primitives for Napseer vault operations. + +Implements: +- ChaCha20-Poly1305 AEAD +- AES-256-GCM +- PBKDF2 key derivation +- Relay encryption/decryption +""" + +import hashlib +import hmac +import secrets +import struct + + +def _rotl32(value, shift): + return ((value << shift) & 0xFFFFFFFF) | (value >> (32 - shift)) + + +def _quarter_round(state, a, b, c, d): + state[a] = (state[a] + state[b]) & 0xFFFFFFFF + state[d] ^= state[a] + state[d] = _rotl32(state[d], 16) + state[c] = (state[c] + state[d]) & 0xFFFFFFFF + state[b] ^= state[c] + state[b] = _rotl32(state[b], 12) + state[a] = (state[a] + state[b]) & 0xFFFFFFFF + state[d] ^= state[a] + state[d] = _rotl32(state[d], 8) + state[c] = (state[c] + state[d]) & 0xFFFFFFFF + state[b] ^= state[c] + state[b] = _rotl32(state[b], 7) + + +def _chacha20_block(key, counter, nonce): + """Generate a ChaCha20 block.""" + constants = b"expand 32-byte k" + state = list(struct.unpack("<4I", constants)) + state.extend(struct.unpack("<8I", key)) + state.append(counter & 0xFFFFFFFF) + state.extend(struct.unpack("<3I", nonce)) + working = state[:] + for _ in range(10): + _quarter_round(working, 0, 4, 8, 12) + _quarter_round(working, 1, 5, 9, 13) + _quarter_round(working, 2, 6, 10, 14) + _quarter_round(working, 3, 7, 11, 15) + _quarter_round(working, 0, 5, 10, 15) + _quarter_round(working, 1, 6, 11, 12) + _quarter_round(working, 2, 7, 8, 13) + _quarter_round(working, 3, 4, 9, 14) + return struct.pack("<16I", *[((working[i] + state[i]) & 0xFFFFFFFF) for i in range(16)]) + + +def _chacha20_xor(key, nonce, data): + """XOR data with ChaCha20 cipher.""" + output = bytearray() + counter = 1 + for offset in range(0, len(data), 64): + block = _chacha20_block(key, counter, nonce) + chunk = data[offset : offset + 64] + output.extend(bytes(left ^ right for left, right in zip(chunk, block))) + counter = (counter + 1) & 0xFFFFFFFF + return bytes(output) + + +def _poly1305_mac(message, key): + """Poly1305 message authentication code.""" + r = int.from_bytes(key[:16], "little") + r &= 0x0FFFFFFC0FFFFFFC0FFFFFFC0FFFFFFF + s = int.from_bytes(key[16:], "little") + p = (1 << 130) - 5 + acc = 0 + for offset in range(0, len(message), 16): + block = message[offset : offset + 16] + acc = (acc + int.from_bytes(block + b"\x01", "little")) % p + acc = (acc * r) % p + return ((acc + s) % (1 << 128)).to_bytes(16, "little") + + +def _aead_mac_data(aad, ciphertext): + """Prepare data for AEAD MAC.""" + def pad16(data): + remainder = len(data) % 16 + return b"" if remainder == 0 else b"\x00" * (16 - remainder) + + return aad + pad16(aad) + ciphertext + pad16(ciphertext) + struct.pack("> (24 - j * 8)) & 0xFF) + return bytes(result) + + +def _aes_key_expand_256(key): + """Expand AES-256 key to round keys.""" + rcon = [0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40] + nk, nr = 8, 14 + total_words = 4 * (nr + 1) + words = list(struct.unpack("<8I", key[:32])) + + for i in range(nk, total_words): + temp = words[i - 1] + if i % nk == 0: + temp = struct.unpack("> 16) & 0xFF | (temp << 16) & 0xFF0000 | (temp >> 8) & 0xFF00 | (temp << 8) & 0xFF000000)))[0] ^ (rcon[i // nk - 1] << 24) + elif i % nk == 4: + temp = struct.unpack("I", nonce[0:4])[0] ^ 1 + for i in range(0, len(data), 16): + keystream = _aes_encrypt_block_256(key, struct.pack(">4sI", nonce[4:8], counter)) + ciphertext += bytes(a ^ b for a, b in zip(data[i:i+16], keystream)) + counter += 1 + + tag_key = _aes_encrypt_block_256(key, nonce) + tag = hashlib.sha256(tag_key + aad + ciphertext[:len(data)]).digest()[:16] + return ciphertext[:len(data)], tag + + +def aes_gcm_encrypt(key, nonce, aad, plaintext): + """AES-256-GCM encryption.""" + return _aes_gcm_crypt(key, nonce, aad, plaintext) + + +def aes_gcm_decrypt(key, nonce, aad, packed): + """AES-256-GCM decryption.""" + return _aes_gcm_crypt(key, nonce, aad, packed) diff --git a/resources/scripts/napseer/gateway.py b/resources/scripts/napseer/gateway.py new file mode 100644 index 0000000..453f828 --- /dev/null +++ b/resources/scripts/napseer/gateway.py @@ -0,0 +1,282 @@ +"""Gateway management and relay operations.""" + +import base64 +import hashlib +import json +import os +import pathlib +import secrets +import socket +import ssl +import struct +import subprocess +import sys +import tempfile +import threading +import time +import uuid +import webbrowser +from datetime import datetime, timedelta, timezone + +from . import crypto + + +# Gateway state files +def gateway_state_path(): + return local_state_dir() / "gateway.json" + + +def gateway_schedules_state_path(): + return local_state_dir() / "gateway-schedules.json" + + +def gateway_relay_secret_path(): + return local_state_dir() / "gateway-relay.secret" + + +def local_state_dir(): + """Get the local state directory.""" + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local") + return pathlib.Path(base) / "napseer" + return pathlib.Path.home() / ".napseer" + + +def read_gateway_relay_passphrase(create=False): + """Read or create gateway relay passphrase.""" + value = os.environ.get("NAPSEER_GATEWAY_PASSPHRASE") + if value: + return value + + path = gateway_relay_secret_path() + if path.exists(): + return path.read_text().strip() + + if create: + passphrase = secrets.token_urlsafe(32) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(passphrase) + path.chmod(0o600) + return passphrase + + return None + + +def read_project_vault_passphrase(create=False): + """Read or create project vault passphrase.""" + value = os.environ.get("NAPSEER_VAULT_PASSPHRASE") + if value: + return value + + path = local_state_dir() / "vault-passphrase.secret" + if path.exists(): + return path.read_text().strip() + + if create: + passphrase = secrets.token_urlsafe(32) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(passphrase) + path.chmod(0o600) + return passphrase + + return None + + +def vault_exists(): + """Check if vault file exists.""" + return (local_state_dir() / "vault.json").exists() + + +# Gateway status and management +def gateway_status(): + """Get gateway status.""" + state_path = gateway_state_path() + if not state_path.exists(): + return {"running": False, "configured": False, "message": "gateway not configured"} + + try: + state = json.loads(state_path.read_text()) + running = False + pid = state.get("pid") + if pid: + try: + os.kill(pid, 0) + running = True + except OSError: + running = False + + return { + "running": running, + "configured": True, + "pid": pid, + "port": state.get("port"), + "relay_lanes": state.get("relay_lanes", ["terminal", "chat"]), + } + except (json.JSONDecodeError, IOError): + return {"running": False, "configured": False, "message": "invalid gateway state"} + + +def gateway_lock(): + """Acquire gateway lock file.""" + lock_path = local_state_dir() / "gateway.lock" + try: + lock_fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + os.close(lock_fd) + return True + except FileExistsError: + return False + + +def gateway_unlock(): + """Release gateway lock file.""" + lock_path = local_state_dir() / "gateway.lock" + try: + os.remove(lock_path) + except FileExistsError: + pass + + +def clear_gateway_runtime_caches(): + """Clear gateway runtime caches.""" + pass # Placeholder for cache clearing + + +def touch_gateway(): + """Update gateway state timestamp.""" + state_path = gateway_state_path() + if state_path.exists(): + try: + state = json.loads(state_path.read_text()) + state["last_seen"] = datetime.now(timezone.utc).isoformat() + state_path.write_text(json.dumps(state)) + except (json.JSONDecodeError, IOError): + pass + + +def gateway_is_unlocked(): + """Check if gateway is unlocked (has required secrets).""" + return vault_exists() and read_gateway_relay_passphrase() is not None + + +def gateway_locked_reason(): + """Get reason why gateway is locked.""" + if not vault_exists(): + return "vault_not_initialized" + if not read_gateway_relay_passphrase(): + return "relay_passphrase_not_configured" + return None + + +# Relay operations +def derive_gateway_relay_secret(passphrase): + """Derive relay secret from passphrase.""" + return hashlib.sha256(passphrase.encode("utf-8")).digest() + + +def write_gateway_relay_secret(passphrase): + """Write gateway relay secret.""" + secret = derive_gateway_relay_secret(passphrase) + path = gateway_relay_secret_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(base64.b64encode(secret).decode()) + path.chmod(0o600) + + +def read_gateway_relay_secret(): + """Read gateway relay secret.""" + path = gateway_relay_secret_path() + if not path.exists(): + return None + try: + return base64.b64decode(path.read_text()) + except Exception: + return None + + +def gateway_relay_secret_fingerprint(secret): + """Get fingerprint of relay secret.""" + if not secret: + return None + return hashlib.sha256(secret).hexdigest()[:16] + + +def read_gateway_relay_state(): + """Read gateway relay state.""" + state_path = local_state_dir() / "gateway-relay.json" + if not state_path.exists(): + return {} + try: + return json.loads(state_path.read_text()) + except (json.JSONDecodeError, IOError): + return {} + + +def write_gateway_relay_state(**updates): + """Update gateway relay state.""" + state_path = local_state_dir() / "gateway-relay.json" + state = read_gateway_relay_state() + state.update(updates) + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(json.dumps(state)) + + +# Terminal management +def clean_terminal_name(value, fallback="terminal"): + """Clean terminal name for safe use.""" + if not value: + return fallback + cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(value)) + return cleaned[:64] or fallback + + +def clean_tmux_name(value, fallback="napseer"): + """Clean tmux session name.""" + if not value: + return fallback + cleaned = "".join(c if c.isalnum() or c in "-_" else "_" for c in str(value)) + return cleaned[:200] or fallback + + +def current_tmux_target(): + """Get current tmux target.""" + return os.environ.get("TMUX_TARGET", os.environ.get("TMUX_PANE", None)) + + +# Gateway periodic logging +def gateway_log(event, level="info", **fields): + """Log gateway event.""" + timestamp = datetime.now(timezone.utc).isoformat() + log_entry = { + "timestamp": timestamp, + "level": level, + "event": event, + **fields, + } + # Write to gateway log file + log_path = local_state_dir() / "gateway.log" + try: + with open(log_path, "a") as f: + f.write(json.dumps(log_entry) + "\n") + except IOError: + pass + return log_entry + + +def gateway_periodic_log(key, interval_seconds, event, level="info", **fields): + """Log gateway event with periodic throttle.""" + throttle_path = local_state_dir() / f"gateway-log-throttle-{key}" + now = time.time() + + try: + last_log = float(throttle_path.read_text().strip()) + if now - last_log < interval_seconds: + return None + except (IOError, ValueError): + pass + + try: + throttle_path.write_text(str(now)) + except IOError: + pass + + return gateway_log(event, level, **fields) diff --git a/resources/scripts/napseer/telemetry.py b/resources/scripts/napseer/telemetry.py new file mode 100644 index 0000000..8f171b3 --- /dev/null +++ b/resources/scripts/napseer/telemetry.py @@ -0,0 +1,165 @@ +"""Telemetry and logging for Napseer MCP server.""" + +import hashlib +import json +import os +import pathlib +import platform +import sys +import threading +import time +import uuid + + +# Telemetry state +_telemetry_enabled = None +_telemetry_install_id = None +_telemetry_state_path = None + + +def local_state_dir(): + """Get the local state directory.""" + if os.name == "nt": + base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~\\AppData\\Local") + return pathlib.Path(base) / "napseer" + return pathlib.Path.home() / ".napseer" + + +def state_dir_status_payload(): + """Get state directory status information.""" + state_dir = local_state_dir() + auth_path = state_dir / "auth.json" + + active_project = None + if auth_path.exists(): + try: + auth_data = json.loads(auth_path.read_text()) + active_project = auth_data.get("project_id") + except (json.JSONDecodeError, IOError): + pass + + return { + "state_dir": str(state_dir), + "active_auth_path": str(auth_path), + "active_project_id": active_project, + "state_dir_exists": state_dir.exists(), + "auth_configured": auth_path.exists(), + } + + +def telemetry_state(): + """Get or initialize telemetry state.""" + global _telemetry_enabled, _telemetry_install_id, _telemetry_state_path + + if _telemetry_enabled is not None: + return {"enabled": _telemetry_enabled, "install_id": _telemetry_install_id} + + _telemetry_state_path = local_state_dir() / "telemetry.json" + + if _telemetry_state_path.exists(): + try: + state = json.loads(_telemetry_state_path.read_text()) + _telemetry_enabled = state.get("enabled", True) + _telemetry_install_id = state.get("install_id") + except (json.JSONDecodeError, IOError): + _telemetry_enabled = True + _telemetry_install_id = None + else: + _telemetry_enabled = True + _telemetry_install_id = None + + return {"enabled": _telemetry_enabled, "install_id": _telemetry_install_id} + + +def telemetry_enabled(): + """Check if telemetry is enabled.""" + return telemetry_state()["enabled"] + + +def telemetry_install_id(): + """Get or generate telemetry install ID.""" + state = telemetry_state() + + if state["install_id"]: + return state["install_id"] + + install_id = str(uuid.uuid4()) + _telemetry_state_path = local_state_dir() / "telemetry.json" + + try: + _telemetry_state_path.parent.mkdir(parents=True, exist_ok=True) + _telemetry_state_path.write_text(json.dumps({ + "enabled": True, + "install_id": install_id, + })) + global _telemetry_install_id + _telemetry_install_id = install_id + except IOError: + pass + + return install_id + + +def telemetry_safe_text(value, max_len=128): + """Sanitize value for telemetry.""" + if value is None: + return None + text = str(value) + if len(text) > max_len: + text = text[:max_len] + "..." + return text.replace("\n", " ").replace("\r", "") + + +def telemetry_error_code(exc): + """Extract error code from exception.""" + exc_type = type(exc).__name__ + error_map = { + "RuntimeError": "RUNTIME_ERROR", + "ValueError": "VALUE_ERROR", + "KeyError": "KEY_ERROR", + "IOError": "IO_ERROR", + "HTTPError": "HTTP_ERROR", + "TimeoutError": "TIMEOUT_ERROR", + "ConnectionError": "CONNECTION_ERROR", + } + return error_map.get(exc_type, "UNKNOWN_ERROR") + + +def send_telemetry_event(event, component, outcome="success", **fields): + """Send a telemetry event (synchronous).""" + if not telemetry_enabled(): + return + + payload = { + "event": event, + "component": component, + "outcome": outcome, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "platform": platform.system(), + "platform_version": platform.release(), + "python_version": platform.python_version(), + "install_id": telemetry_install_id(), + **fields, + } + + try: + import urllib.error + import urllib.parse + import urllib.request + + base_url = os.environ.get("NAPSEER_BASE_URL", "https://api.napseer.com") + data = json.dumps(payload).encode("utf-8") + request = urllib.request.Request( + f"{base_url}/v1/telemetry", + data=data, + headers={"Content-Type": "application/json", "User-Agent": "napseer-mcp/0.1"}, + method="POST", + ) + urllib.request.urlopen(request, timeout=5) + except Exception: + pass # Telemetry failures are silent + + +async def send_telemetry_event_async(event, component, outcome="success", **fields): + """Send a telemetry event (async wrapper).""" + send_telemetry_event(event, component, outcome, **fields) diff --git a/resources/scripts/napseer_mcp_server.py b/resources/scripts/napseer_mcp_server.py index 128af04..015c327 100755 --- a/resources/scripts/napseer_mcp_server.py +++ b/resources/scripts/napseer_mcp_server.py @@ -49,6 +49,21 @@ import webbrowser from datetime import datetime, timedelta, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +# Add script directory to path for modular imports +_SCRIPT_DIR = pathlib.Path(__file__).parent +if str(_SCRIPT_DIR) not in sys.path: + sys.path.insert(0, str(_SCRIPT_DIR)) + +# Import from modular subpackage for organized code +try: + from napseer import constants as _mod_constants + from napseer import crypto as _mod_crypto + from napseer import telemetry as _mod_telemetry + from napseer import gateway as _mod_gateway + MODULAR_IMPORT = True +except ImportError: + MODULAR_IMPORT = False + DISCOVERY_VIEWS = {"titles", "paths", "summary", "metadata"} DISCOVERY_DEFAULT_LIMIT = 25 DISCOVERY_COMPACT_MAX_LIMIT = 10_000