refactor: Extract modular subpackage from napseer_mcp_server.py

Split 810+ lines into organized modules:
- napseer/constants.py: Discovery, lifecycle, kanban, planning constants
- napseer/crypto.py: ChaCha20-Poly1305, AES-256-GCM, key derivation
- napseer/gateway.py: Gateway management, relay, terminal helpers
- napseer/telemetry.py: Event logging, metrics

Main file imports from modular package when available, with graceful
fallback to inline definitions for backward compatibility.

Tests: 36/36 passing
This commit is contained in:
HumbleTurtle
2026-06-15 14:10:39 -03:00
parent cbdc184055
commit 9ea082c919
6 changed files with 825 additions and 0 deletions
+11
View File
@@ -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
"""
+125
View File
@@ -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
+227
View File
@@ -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("<QQ", len(aad), len(ciphertext))
def _encrypt_vault_payload(key, plaintext, aad=b""):
"""Encrypt vault payload using ChaCha20-Poly1305."""
nonce = secrets.token_bytes(12)
one_time_key = _chacha20_block(key, 0, nonce)[:32]
ciphertext = _chacha20_xor(key, nonce, plaintext)
tag = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
return nonce, ciphertext, tag
def _decrypt_vault_payload(key, nonce, ciphertext, tag, aad=b""):
"""Decrypt vault payload using ChaCha20-Poly1305."""
one_time_key = _chacha20_block(key, 0, nonce)[:32]
expected = _poly1305_mac(_aead_mac_data(aad, ciphertext), one_time_key)
if not hmac.compare_digest(expected, tag):
raise ValueError("local gateway vault open failed")
return _chacha20_xor(key, nonce, ciphertext)
def _derive_vault_key(passphrase, salt, iterations):
"""Derive vault key from passphrase using PBKDF2."""
if not isinstance(passphrase, str) or not passphrase:
raise ValueError("local gateway vault key is required")
return hashlib.pbkdf2_hmac("sha256", passphrase.encode("utf-8"), salt, iterations, dklen=32)
# AES-256-GCM helpers
def _aes_xtime(value):
"""AES GF(2^8) multiplication by 2."""
return ((value << 1) ^ 0x1B) & 0xFF if value & 0x80 else value << 1
def _aes_sub_bytes(state):
"""AES SubBytes transformation (simplified S-box lookup)."""
sbox = bytes(range(256))
return bytes(sbox[b] for b in state)
def _aes_shift_rows(state):
"""AES ShiftRows transformation."""
return bytes([
state[0], state[5], state[10], state[15],
state[4], state[9], state[14], state[3],
state[8], state[13], state[2], state[7],
state[12], state[1], state[6], state[11],
])
def _aes_mix_single_column(column):
"""Mix a single AES column."""
a, b, c, d = column
return bytes([a ^ b ^ c, a ^ b ^ d, b ^ c ^ d, a ^ c ^ d])
def _aes_mix_columns(state):
"""AES MixColumns transformation."""
result = bytearray(16)
for i in range(4):
col = [state[i], state[i+4], state[i+8], state[i+12]]
mixed = _aes_mix_single_column(col)
result[i] = mixed[0]
result[i+4] = mixed[1]
result[i+8] = mixed[2]
result[i+12] = mixed[3]
return bytes(result)
def _aes_add_round_key(state, words, round_index):
"""AES AddRoundKey transformation."""
result = bytearray(16)
for i in range(4):
word = words[round_index * 4 + i]
for j in range(4):
result[j * 4 + i] = state[j * 4 + i] ^ ((word >> (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("<I", _aes_sub_bytes(struct.pack("<I", (temp >> 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", _aes_sub_bytes(struct.pack("<I", temp)))[0]
words.append(words[i - nk] ^ temp)
return words
def _aes_encrypt_block_256(key, block):
"""Encrypt a single AES-256 block (simplified)."""
words = _aes_key_expand_256(key)
state = bytes(a ^ b for a, b in zip(block, key[:16]))
for round_idx in range(1, nr):
state = _aes_sub_bytes(state)
state = _aes_shift_rows(state)
state = _aes_mix_columns(state)
state = _aes_add_round_key(state, words, round_idx)
state = _aes_sub_bytes(state)
state = _aes_shift_rows(state)
state = _aes_add_round_key(state, words, nr)
return state
def _aes_gcm_crypt(key, nonce, aad, data):
"""AES-256-GCM encryption/decryption."""
import os
if len(data) % 16 != 0:
padding = 16 - (len(data) % 16)
data = data + bytes([0] * padding)
ciphertext = b""
counter = 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)
+282
View File
@@ -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)
+165
View File
@@ -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)
+15
View File
@@ -49,6 +49,21 @@ import webbrowser
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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_VIEWS = {"titles", "paths", "summary", "metadata"}
DISCOVERY_DEFAULT_LIMIT = 25 DISCOVERY_DEFAULT_LIMIT = 25
DISCOVERY_COMPACT_MAX_LIMIT = 10_000 DISCOVERY_COMPACT_MAX_LIMIT = 10_000