From cc64e8d41e40894290360c2e51eab21524022816 Mon Sep 17 00:00:00 2001 From: AuroraCrimsonRose Date: Wed, 27 May 2026 15:07:22 -0500 Subject: [PATCH] Initial commit --- .gitignore | 149 +++++++++++++++++++++++++ MCP Server.code-workspace | 8 ++ api/server.py | 174 +++++++++++++++++++++++++++++ core/config.py | 69 ++++++++++++ core/event_store.py | 108 ++++++++++++++++++ core/events.py | 147 +++++++++++++++++++++++++ core/exception.py | 85 ++++++++++++++ core/executor.py | 62 +++++++++++ core/logging_core.py | 125 +++++++++++++++++++++ core/metrics.py | 72 ++++++++++++ core/safety.py | 181 ++++++++++++++++++++++++++++++ core/subprocess.py | 211 +++++++++++++++++++++++++++++++++++ core/tools/base.py | 93 ++++++++++++++++ core/tools/registry.py | 124 +++++++++++++++++++++ main.py | 196 +++++++++++++++++++++++++++++++++ tests/conftest.py | 8 ++ tests/profiles.json | 95 ++++++++++++++++ tests/tool_types.json | 23 ++++ tools/__init__.py | 16 +++ tools/bochs.py | 0 tools/cmake.py | 0 tools/discovery.py | 22 ++++ tools/docker.py | 0 tools/ffmpeg.py | 0 tools/filesystem.py | 225 ++++++++++++++++++++++++++++++++++++++ tools/git.py | 0 tools/gitea.py | 50 +++++++++ tools/ping.py | 45 ++++++++ tools/qemu.py | 0 tools/subprocess.py | 66 +++++++++++ tools/venv.py | 0 31 files changed, 2354 insertions(+) create mode 100644 .gitignore create mode 100644 MCP Server.code-workspace create mode 100644 api/server.py create mode 100644 core/config.py create mode 100644 core/event_store.py create mode 100644 core/events.py create mode 100644 core/exception.py create mode 100644 core/executor.py create mode 100644 core/logging_core.py create mode 100644 core/metrics.py create mode 100644 core/safety.py create mode 100644 core/subprocess.py create mode 100644 core/tools/base.py create mode 100644 core/tools/registry.py create mode 100644 main.py create mode 100644 tests/conftest.py create mode 100644 tests/profiles.json create mode 100644 tests/tool_types.json create mode 100644 tools/__init__.py create mode 100644 tools/bochs.py create mode 100644 tools/cmake.py create mode 100644 tools/discovery.py create mode 100644 tools/docker.py create mode 100644 tools/ffmpeg.py create mode 100644 tools/filesystem.py create mode 100644 tools/git.py create mode 100644 tools/gitea.py create mode 100644 tools/ping.py create mode 100644 tools/qemu.py create mode 100644 tools/subprocess.py create mode 100644 tools/venv.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..548fb8e --- /dev/null +++ b/.gitignore @@ -0,0 +1,149 @@ + +# Byte-compiled / Python cache +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + + +# Build / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Virtual environments +.env +.envrc +.venv/ +venv/ +ENV/ +env/ +env.bak/ +venv.bak/ +.python-version + +# UV / Poetry / PDM / Pixi +uv.lock +poetry.lock +poetry.toml +pdm.lock +pdm.toml +.pdm-python +.pdm-build/ +pixi.lock +.pixi/ + +# Logs / runtime / temp +logs/ +*.log +runtime/ +tmp/ +tempCodeRunnerFile.py + +# Testing / coverage +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.pytest_cache/ +.hypothesis/ +cover/ + +# Jupyter / IPython +.ipynb_checkpoints/ +profile_default/ +ipython_config.py + +# Databases / local state +db.sqlite3 +db.sqlite3-journal + +# Redis / brokers +*.rdb +*.aof +*.pid +mnesia/ +rabbitmq/ +rabbitmq-data/ +activemq-data/ + +# Framework junk +instance/ +.webassets-cache/ +.scrapy/ +.pybuilder/ +target/ +docs/_build/ +site/ +celerybeat-schedule +celerybeat.pid +.scrapy/ +.abstra/ +.streamlit/secrets.toml + + +# Type checkers / linters +.mypy_cache/ +.dmypy.json +dmypy.json +.pyre/ +.pytype/ +.ruff_cache/ + + +# Editors / IDEs +.vscode/ +.idea/ +.spyderproject/ +.spyproject/ +.ropeproject/ + +# OS junk +.DS_Store +Thumbs.db + + +# Pip / packaging artifacts +pip-log.txt +pip-delete-this-directory.txt +.pypirc + +# Security / env files +.env +.env.* +*.env + +# IMPORTANT: ignore ALL dot-folders by default +.* +!.gitignore +!.github/ +!.vscode/ # (comment this out if you want vscode tracked configs later) + +# Exceptions (keep important hidden project dirs if you add them later) +!.env.example \ No newline at end of file diff --git a/MCP Server.code-workspace b/MCP Server.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/MCP Server.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/api/server.py b/api/server.py new file mode 100644 index 0000000..11157a6 --- /dev/null +++ b/api/server.py @@ -0,0 +1,174 @@ +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from typing import List +from pydantic import BaseModel + +from core.metrics import metrics +from core.events import bus, Event +from core.config import API_HOST, API_PORT +from core.event_store import event_store + +app = FastAPI(title="CXOS MCP API", version="1.0") + +# ========================= +# MODELS +# ========================= + +class ReplayRequest(BaseModel): + source: str | None = None + level: str | None = None + name: str | None = None + + +# ========================= +# WEBSOCKET STATE +# ========================= + +connected_clients: List[WebSocket] = [] + + +# ========================= +# EVENT BROADCAST +# ========================= + +async def broadcast(event: Event): + dead_clients = [] + + payload = { + "name": event.name, + "source": event.source, + "level": event.level, + "data": event.data, + "timestamp": event.timestamp, + } + + for ws in connected_clients: + try: + await ws.send_json(payload) + except Exception: + dead_clients.append(ws) + + for ws in dead_clients: + if ws in connected_clients: + connected_clients.remove(ws) + + +# subscribe once +bus.subscribe_async(broadcast) + + +# ========================= +# HEALTH / METRICS +# ========================= + +@app.get("/health") +def health(): + return { + "status": "ok", + "service": "cxos-mcp" + } + + +@app.get("/metrics") +def get_metrics(): + return metrics.snapshot() + + +@app.get("/metrics/prometheus") +def prometheus_metrics(): + return metrics.prometheus_format() + + +# ========================= +# LIVE EVENT STREAM +# ========================= + +@app.websocket("/ws/events") +async def event_stream(ws: WebSocket): + await ws.accept() + connected_clients.append(ws) + + bus.emit(Event( + name="client_connected", + source="API", + level="INFO", + data={"clients": len(connected_clients)} + )) + + try: + while True: + # lightweight keep-alive loop + await ws.receive_text() + + except WebSocketDisconnect: + if ws in connected_clients: + connected_clients.remove(ws) + + bus.emit(Event( + name="client_disconnected", + source="API", + level="INFO", + data={"clients": len(connected_clients)} + )) + + +# ========================= +# EVENT REPLAY (READ ONLY) +# ========================= + +@app.get("/events/replay") +def replay_events( + source: str | None = None, + level: str | None = None, + name: str | None = None +): + events = event_store.query(source, level, name) + + return { + "count": len(events), + "events": [ + { + "name": e.name, + "source": e.source, + "level": e.level, + "data": e.data, + "timestamp": e.timestamp, + } + for e in events + ] + } + + +# ========================= +# EVENT REPLAY (EXECUTION) +# ========================= + +@app.post("/events/replay/run") +def replay_run(req: ReplayRequest): + + def printer(event): + print(f"[REPLAY] {event.source} | {event.name}") + + event_store.replay( + source=req.source, + level=req.level, + name=req.name, + handler=printer + ) + + return {"status": "replayed"} + + +# ========================= +# SERVER START +# ========================= + +def run_api(): + import uvicorn + + uvicorn.run( + "api.server:app", + host=API_HOST, + port=API_PORT, + reload=False, + log_level="info" + ) \ No newline at end of file diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000..db85d4f --- /dev/null +++ b/core/config.py @@ -0,0 +1,69 @@ +from pathlib import Path +import os +from dotenv import load_dotenv + +load_dotenv() + + +# ========================= +# CORE PATHS +# ========================= + +WORKSPACE_ROOT = Path(os.getenv("WORKSPACE_ROOT", Path.cwd())).resolve() + +LOG_DIR = WORKSPACE_ROOT / "logs" +TMP_DIR = WORKSPACE_ROOT / "tmp" +CONFIG_DIR = WORKSPACE_ROOT / "config" + +LOG_DIR.mkdir(parents=True, exist_ok=True) +TMP_DIR.mkdir(parents=True, exist_ok=True) +CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + +# ========================= +# SYSTEM LIMITS +# ========================= + +MAX_FILE_SIZE = int(os.getenv("MAX_FILE_SIZE", str(10 * 1024 * 1024))) + +IGNORE_DIRS = set( + os.getenv( + "IGNORE_DIRS", + ".git,__pycache__,node_modules,.venv,venv,dist,build" + ).split(",") +) + + +# ========================= +# EVENT / DEBUG FLAGS +# ========================= + +DEBUG = os.getenv("DEBUG", "false").lower() == "true" +DRY_RUN_DEFAULT = os.getenv("DRY_RUN", "false").lower() == "true" + + +# ========================= +# GITEA CONFIG +# ========================= + +GITEA_URL = os.getenv("GITEA_URL", "http://localhost:3000") +GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") +GITEA_USER = os.getenv("GITEA_USER", "admin") +GITEA_API_BASE = f"{GITEA_URL}/api/v1" + + +# ========================= +# GIT CONFIG +# ========================= + +GIT_AUTHOR_NAME = os.getenv("GIT_AUTHOR_NAME", "CXOS") +GIT_AUTHOR_EMAIL = os.getenv("GIT_AUTHOR_EMAIL", "cxos@local") + + +# ========================= +# MCP / SERVER CONFIG +# ========================= + +SERVER_NAME = os.getenv("SERVER_NAME", "CXOS-MCP") +API_HOST = os.getenv("API_HOST", "0.0.0.0") +API_PORT = int(os.getenv("API_PORT", "5432")) \ No newline at end of file diff --git a/core/event_store.py b/core/event_store.py new file mode 100644 index 0000000..ccc27e0 --- /dev/null +++ b/core/event_store.py @@ -0,0 +1,108 @@ +from collections import deque +from typing import Optional, Any, Callable, Iterator + +from core.events import Event, bus + + +class EventStore: + def __init__(self, max_size: int = 10_000): + self.buffer: deque[Event] = deque(maxlen=max_size) + self._cursor: int = 0 + + bus.subscribe(self._capture) + + # ========================= + # CAPTURE + # ========================= + + def _capture(self, event: Event): + event.meta["cursor"] = self._cursor + + self.buffer.append(event) + self._cursor += 1 + + # ========================= + # QUERY + # ========================= + + def query( + self, + source: Optional[str] = None, + level: Optional[str] = None, + name: Optional[str] = None, + start_cursor: Optional[int] = None, + end_cursor: Optional[int] = None, + ) -> list[Event]: + + events = list(self.buffer) + + if source is not None: + events = [e for e in events if e.source == source] + + if level is not None: + events = [e for e in events if e.level == level] + + if name is not None: + events = [e for e in events if e.name == name] + + def cursor_of(e: Event) -> int: + return e.meta.get("cursor", -1) + + if start_cursor is not None: + events = [e for e in events if cursor_of(e) >= start_cursor] + + if end_cursor is not None: + events = [e for e in events if cursor_of(e) <= end_cursor] + + return events + + # ========================= + # PAGINATION + # ========================= + + def page(self, cursor: int = 0, limit: int = 100) -> dict[str, Any]: + events = list(self.buffer) + sliced = events[cursor:cursor + limit] + + return { + "cursor": cursor, + "next_cursor": cursor + len(sliced), + "count": len(sliced), + "events": sliced + } + + # ========================= + # REPLAY + # ========================= + + def replay( + self, + source: Optional[str] = None, + level: Optional[str] = None, + name: Optional[str] = None, + handler: Optional[Callable[[Event], Any]] = None, + ): + for event in self.query(source, level, name): + if handler: + handler(event) + else: + bus.emit(event) + + # ========================= + # REVERSE DEBUG + # ========================= + + def replay_reverse(self, handler: Callable[[Event], Any]): + for event in reversed(self.buffer): + handler(event) + + # ========================= + # STREAM TAIL + # ========================= + + def tail(self, last_n: int = 50) -> Iterator[Event]: + yield from list(self.buffer)[-last_n:] + + +# singleton +event_store = EventStore() \ No newline at end of file diff --git a/core/events.py b/core/events.py new file mode 100644 index 0000000..bce8355 --- /dev/null +++ b/core/events.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Callable +from collections.abc import Awaitable +from datetime import datetime +import asyncio +import uuid + + +# ========================= +# EVENT MODEL (GRAPH-AWARE) +# ========================= + +@dataclass +class Event: + name: str + source: str + level: str = "INFO" + data: dict[str, Any] | None = None + + event_id: str = field(default_factory=lambda: str(uuid.uuid4())) + trace_id: str = field(default_factory=lambda: str(uuid.uuid4())) + parent_event_id: str | None = None + + meta: dict[str, Any] = field(default_factory=dict) + + timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat()) + + +# ========================= +# EVENT BUS +# ========================= + +class EventBus: + def __init__(self): + self._subscribers: list[Callable[[Event], Any]] = [] + self._async_subscribers: list[Callable[[Event], Awaitable[Any]]] = [] + + self._current_trace: str | None = None + self._current_parent: str | None = None + + # ------------------------- + # SUBSCRIBE + # ------------------------- + + def subscribe(self, fn: Callable[[Event], Any]): + self._subscribers.append(fn) + + def subscribe_async(self, fn: Callable[[Event], Awaitable[Any]]): + self._async_subscribers.append(fn) + + # ------------------------- + # TRACE CONTROL + # ------------------------- + + def start_trace(self) -> str: + trace_id = str(uuid.uuid4()) + self._current_trace = trace_id + self._current_parent = None + return trace_id + + def set_parent(self, event_id: str | None): + self._current_parent = event_id + + # ------------------------- + # EMIT (CORE SAFE PATH) + # ------------------------- + + def emit(self, event: Event): + # inject trace + if not event.trace_id: + event.trace_id = self._current_trace or event.trace_id + + event.parent_event_id = event.parent_event_id or self._current_parent + + # advance causal chain + self._current_parent = event.event_id + + # sync subscribers + for fn in self._subscribers: + try: + fn(event) + except Exception: + pass + + # async subscribers (SAFE FIX) + if self._async_subscribers: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # no loop available + + for fn in self._async_subscribers: + try: + coro = fn(event) + + # IMPORTANT FIX: + # ensure it's actually awaitable before scheduling + if asyncio.iscoroutine(coro): + loop.create_task(coro) + + except Exception: + pass + + # ------------------------- + # STRICT ASYNC EMIT + # ------------------------- + + async def emit_async(self, event: Event): + for fn in self._subscribers: + try: + fn(event) + except Exception: + pass + + await asyncio.gather( + *(fn(event) for fn in self._async_subscribers), + return_exceptions=True + ) + + # ------------------------- + # LOG CONVENIENCE + # ------------------------- + + def log( + self, + source: str, + name: str, + level: str = "INFO", + data: dict[str, Any] | None = None + ): + self.emit( + Event( + name=name, + source=source, + level=level, + data=data or {} + ) + ) + + +# ========================= +# SINGLETON +# ========================= + +bus = EventBus() \ No newline at end of file diff --git a/core/exception.py b/core/exception.py new file mode 100644 index 0000000..04b7a74 --- /dev/null +++ b/core/exception.py @@ -0,0 +1,85 @@ +from dataclasses import dataclass +from typing import Any, Optional +from core.events import bus, Event + + +# ------------------------- +# BASE ERROR +# ------------------------- + +@dataclass +class CXError(Exception): + code: str + message: str + module: str = "CORE" + status: int = 500 + context: Optional[Any] = None + + # ------------------------- + # SERIALIZATION + # ------------------------- + + def to_dict(self): + return { + "error": True, + "code": self.code, + "message": self.message, + "module": self.module, + "status": self.status, + "context": self.context, + } + + # ------------------------- + # EVENT EMISSION + # ------------------------- + + def emit(self): + bus.emit(Event( + name="exception", + source=self.module, + level="ERROR", + data={ + "code": self.code, + "message": self.message, + "status": self.status, + "context": self.context, + } + )) + + +# ------------------------- +# 4xx CLIENT / AI ERRORS +# ------------------------- + +class CXBadRequest(CXError): + def __init__(self, msg, module="CORE"): + super().__init__("CX400", msg, module, 400) + + +class CXNotFound(CXError): + def __init__(self, msg, module="FILESYSTEM"): + super().__init__("CX404", msg, module, 404) + + +class CXForbidden(CXError): + def __init__(self, msg, module="SECURITY"): + super().__init__("CX403", msg, module, 403) + + +class CXInvalidOperation(CXError): + def __init__(self, msg, module="CORE"): + super().__init__("CX422", msg, module, 422) + + +# ------------------------- +# 5xx SYSTEM ERRORS +# ------------------------- + +class CXInternalError(CXError): + def __init__(self, msg, module="CORE"): + super().__init__("CX500", msg, module, 500) + + +class CXToolFailure(CXError): + def __init__(self, msg, module="TOOLS"): + super().__init__("CX502", msg, module, 502) \ No newline at end of file diff --git a/core/executor.py b/core/executor.py new file mode 100644 index 0000000..cde4b6f --- /dev/null +++ b/core/executor.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +import asyncio +import threading +from typing import Any, TypeVar, Coroutine + +T = TypeVar("T") + + +class LoopExecutor: + def __init__(self): + self._loop: asyncio.AbstractEventLoop | None = None + self._thread: threading.Thread | None = None + + # ------------------------- + # START LOOP + # ------------------------- + + def start(self): + if self._loop: + return + + def runner(): + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + self._loop.run_forever() + + self._thread = threading.Thread(target=runner, daemon=True) + self._thread.start() + + while self._loop is None: + pass + + # ------------------------- + # SUBMIT COROUTINE + # ------------------------- + + def submit(self, coro: Coroutine[Any, Any, T]): + if not self._loop: + raise RuntimeError("Executor not started") + + return asyncio.run_coroutine_threadsafe(coro, self._loop) + + # ------------------------- + # SYNC BRIDGE + # ------------------------- + + def run_sync(self, coro: Coroutine[Any, Any, T]) -> T: + future = self.submit(coro) + return future.result() + + # ------------------------- + # STOP + # ------------------------- + + def stop(self): + if self._loop: + self._loop.call_soon_threadsafe(self._loop.stop) + self._loop = None + + +executor = LoopExecutor() \ No newline at end of file diff --git a/core/logging_core.py b/core/logging_core.py new file mode 100644 index 0000000..b4fe7db --- /dev/null +++ b/core/logging_core.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import sys +from datetime import datetime + +from loguru import logger + +from core.config import LOG_DIR +from core.events import Event, bus + + +class CXLoggerSink: + def __init__(self): + logger.remove() + + # ========================= + # SESSION LOG FILE + # ========================= + + timestamp = datetime.utcnow().strftime( + "%Y-%m-%d_%H-%M-%S" + ) + + self.log_path = LOG_DIR / ( + f"MCP-{timestamp}.log" + ) + + self.log_path.parent.mkdir( + parents=True, + exist_ok=True + ) + + # ========================= + # CONSOLE LOGGER + # ========================= + + logger.add( + sys.stderr, + level="DEBUG", + colorize=True, + backtrace=False, + diagnose=False, + format=( + "{level:<8} | " + "{extra[module]:<12} | " + "{time:YYYY-MM-DD HH:mm:ss} | " + "{message}" + ) + ) + + # ========================= + # FILE LOGGER + # ========================= + + logger.add( + str(self.log_path), + level="DEBUG", + enqueue=True, + retention="30 days", + compression="zip", + backtrace=False, + diagnose=False, + format=( + "{time:YYYY-MM-DD HH:mm:ss} | " + "{level:<8} | " + "{extra[module]:<12} | " + "{message}" + ) + ) + + logger.bind(module="LOGGER").info( + f"Session log created: {self.log_path.name}" + ) + + # ========================= + # EVENT HANDLER + # ========================= + + def handle( + self, + event: Event + ): + module = event.source.upper() + level = event.level.lower() + + message = event.name + + if event.data: + message += ( + f" | {event.data}" + ) + + with logger.contextualize( + module=module + ): + log_method = getattr( + logger, + level, + logger.info + ) + + log_method(message) + + # ========================= + # OPTIONAL DIRECT ACCESS + # ========================= + + def get_log_path(self) -> str: + return str(self.log_path) + + +# ========================= +# SINGLETON +# ========================= + +cxlog_sink = CXLoggerSink() + + +# ========================= +# REGISTER TO EVENT BUS +# ========================= + +bus.subscribe( + cxlog_sink.handle +) \ No newline at end of file diff --git a/core/metrics.py b/core/metrics.py new file mode 100644 index 0000000..0d84da3 --- /dev/null +++ b/core/metrics.py @@ -0,0 +1,72 @@ +from collections import defaultdict +from typing import Any, Optional +from datetime import datetime +from core.events import bus, Event + + +class CXMetrics: + def __init__(self): + self.counters = defaultdict(int) + self.events: list[dict[str, Any]] = [] + self.max_events = 5000 + + # attach to event bus + bus.subscribe(self._on_event) + + # ------------------------- + # EVENT HOOK + # ------------------------- + + def _on_event(self, event: Event): + self.counters[f"event_{event.name}"] += 1 + self.counters[f"level_{event.level}"] += 1 + + self.events.append({ + "name": event.name, + "source": event.source, + "level": event.level, + "data": event.data, + "timestamp": event.timestamp + }) + + if len(self.events) > self.max_events: + self.events = self.events[-self.max_events:] + + # ------------------------- + # MANUAL COUNTERS + # ------------------------- + + def inc(self, key: str, value: int = 1): + self.counters[key] += value + + def event(self, name: str, data: Optional[dict] = None): + bus.emit(Event( + name=name, + source="metrics", + level="INFO", + data=data or {} + )) + + # ------------------------- + # SNAPSHOT (Prometheus / API) + # ------------------------- + + def snapshot(self) -> dict: + return { + "counters": dict(self.counters), + "recent_events": self.events[-100:], + "timestamp": datetime.utcnow().isoformat() + } + + # ------------------------- + # SIMPLE EXPORT FORMAT (future Prometheus hook) + # ------------------------- + + def prometheus_format(self) -> str: + lines = [] + for k, v in self.counters.items(): + lines.append(f"{k} {v}") + return "\n".join(lines) + + +metrics = CXMetrics() \ No newline at end of file diff --git a/core/safety.py b/core/safety.py new file mode 100644 index 0000000..1034a00 --- /dev/null +++ b/core/safety.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Awaitable, Callable + +import asyncio + +from core.config import ( + WORKSPACE_ROOT, + MAX_FILE_SIZE, + IGNORE_DIRS, + DRY_RUN_DEFAULT, +) + +from core.events import bus, Event +from core.metrics import metrics + + +# ========================= +# SAFETY ERROR +# ========================= + +class SafetyError(Exception): + def __init__(self, code: str, message: str): + super().__init__(message) + self.code = code + self.message = message + + +# ========================= +# SAFETY ENGINE +# ========================= + +class SafetyEngine: + def __init__(self): + self.dry_run = DRY_RUN_DEFAULT + self.block_destructive = True + + # ------------------------- + # MODE CONTROL + # ------------------------- + + def set_dry_run(self, value: bool): + self.dry_run = value + bus.emit(Event( + name="safety_dry_run", + source="SAFETY", + level="INFO", + data={"enabled": value} + )) + + # ------------------------- + # PATH SANDBOXING + # ------------------------- + + def validate_path(self, path: str) -> Path: + target = (WORKSPACE_ROOT / path).resolve() + + if not str(target).startswith(str(WORKSPACE_ROOT)): + self._violation("PATH_ESCAPE", path) + raise SafetyError("PATH_ESCAPE", "Outside workspace") + + for part in target.parts: + if part in IGNORE_DIRS: + self._violation("IGNORED_PATH", path) + raise SafetyError("IGNORED_PATH", f"Blocked directory: {part}") + + return target + + # ------------------------- + # FILE SIZE GUARD + # ------------------------- + + def check_file_write(self, path: Path, content: str): + if len(content.encode("utf-8")) > MAX_FILE_SIZE: + self._violation("FILE_TOO_LARGE", str(path)) + raise SafetyError("FILE_TOO_LARGE", "Exceeded limit") + + # ------------------------- + # DESTRUCTIVE OPS + # ------------------------- + + def allow_action(self, action: str): + blocked = { + "delete_file", + "rm_rf", + "format", + "wipe", + "drop_db", + } + + if self.block_destructive and action in blocked: + self._violation("DESTRUCTIVE_BLOCK", action) + raise SafetyError("DESTRUCTIVE_BLOCK", f"Blocked: {action}") + + # ========================================================= + # ASYNC SAFE WRAPPER (NEW CORE ENTRYPOINT) + # ========================================================= + + async def wrap_async( + self, + tool_name: str, + fn: Callable[..., Any | Awaitable[Any]], + *args: Any, + **kwargs: Any, + ) -> Any: + + bus.emit(Event( + name="tool_start", + source="SAFETY", + level="INFO", + data={"tool": tool_name} + )) + + if self.dry_run: + return { + "dry_run": True, + "tool": tool_name, + "args": args, + "kwargs": kwargs, + } + + try: + result = fn(*args, **kwargs) + + if asyncio.iscoroutine(result): + result = await result + + metrics.inc(f"tool_{tool_name}") + metrics.inc("tools_executed") + + bus.emit(Event( + name="tool_success", + source="SAFETY", + level="SUCCESS", + data={"tool": tool_name} + )) + + return result + + except SafetyError as e: + bus.emit(Event( + name="tool_blocked", + source="SAFETY", + level="ERROR", + data={"code": e.code, "message": e.message} + )) + + metrics.inc("safety_blocks") + + return {"error": True, "code": e.code, "message": e.message} + + except Exception as e: + bus.emit(Event( + name="tool_crash", + source="SAFETY", + level="ERROR", + data={"tool": tool_name, "error": str(e)} + )) + + metrics.inc("tool_errors") + + return {"error": True, "code": "UNEXPECTED", "message": str(e)} + + # ------------------------- + # INTERNAL VIOLATION LOGGER + # ------------------------- + + def _violation(self, code: str, context: str): + bus.emit(Event( + name="safety_violation", + source="SAFETY", + level="ERROR", + data={"code": code, "context": context} + )) + + metrics.inc(f"safety_{code}") + + +# singleton +safety = SafetyEngine() \ No newline at end of file diff --git a/core/subprocess.py b/core/subprocess.py new file mode 100644 index 0000000..e0f52a0 --- /dev/null +++ b/core/subprocess.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +import subprocess +from pathlib import Path +from typing import Any + +from core.events import bus, Event +from core.metrics import metrics +from core.safety import safety + + +# ========================= +# PROCESS RUNNER +# ========================= + +def run_command( + cmd: list[str], + cwd: str | None = None, + timeout: int = 60 +) -> dict[str, Any]: + + # ------------------------- + # VALIDATION + # ------------------------- + + if not cmd: + raise ValueError( + "Command list cannot be empty" + ) + + for item in cmd: + if not isinstance(item, str): + raise ValueError( + "cmd must contain only strings" + ) + + executable = cmd[0].lower() + + # ------------------------- + # SAFETY CHECKS + # ------------------------- + + blocked_commands = { + "rm", + "rmdir", + "del", + "erase", + "shutdown", + "reboot", + "mkfs", + "format", + "diskpart" + } + + if executable in blocked_commands: + bus.emit(Event( + name="process_blocked", + source="SUBPROCESS", + level="ERROR", + data={ + "cmd": cmd, + "reason": "blocked_command" + } + )) + + metrics.inc( + "subprocess_blocked" + ) + + return { + "ok": False, + "error": ( + f"Blocked command: " + f"{executable}" + ), + "code": -1 + } + + resolved_cwd: Path | None = None + + if cwd is not None: + resolved_cwd = ( + safety.validate_path(cwd) + ) + + # ------------------------- + # START EVENT + # ------------------------- + + bus.emit(Event( + name="process_start", + source="SUBPROCESS", + level="INFO", + data={ + "cmd": cmd, + "cwd": str(resolved_cwd) + if resolved_cwd else None, + "timeout": timeout + } + )) + + metrics.inc( + "subprocess_runs" + ) + + # ------------------------- + # EXECUTE + # ------------------------- + + try: + result = subprocess.run( + cmd, + cwd=( + str(resolved_cwd) + if resolved_cwd + else None + ), + capture_output=True, + text=True, + timeout=timeout, + check=False + ) + + stdout = ( + result.stdout or "" + ) + + stderr = ( + result.stderr or "" + ) + + ok = ( + result.returncode == 0 + ) + + # ------------------------- + # COMPLETE EVENT + # ------------------------- + + bus.emit(Event( + name="process_complete", + source="SUBPROCESS", + level=( + "SUCCESS" + if ok + else "ERROR" + ), + data={ + "cmd": cmd, + "code": result.returncode, + "stdout": stdout, + "stderr": stderr + } + )) + + return { + "ok": ok, + "stdout": stdout, + "stderr": stderr, + "code": result.returncode + } + + # ------------------------- + # TIMEOUT + # ------------------------- + + except subprocess.TimeoutExpired: + metrics.inc( + "subprocess_timeouts" + ) + + bus.emit(Event( + name="process_timeout", + source="SUBPROCESS", + level="ERROR", + data={ + "cmd": cmd, + "timeout": timeout + } + )) + + return { + "ok": False, + "error": "timeout", + "code": -1 + } + + # ------------------------- + # UNEXPECTED ERROR + # ------------------------- + + except Exception as e: + metrics.inc( + "subprocess_errors" + ) + + bus.emit(Event( + name="process_error", + source="SUBPROCESS", + level="ERROR", + data={ + "cmd": cmd, + "error": str(e) + } + )) + + return { + "ok": False, + "error": str(e), + "code": -1 + } \ No newline at end of file diff --git a/core/tools/base.py b/core/tools/base.py new file mode 100644 index 0000000..1539b2a --- /dev/null +++ b/core/tools/base.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + +from core.events import bus + + +# ========================= +# TOOL CONTEXT +# ========================= + +@dataclass +class ToolContext: + user: str | None = None + dry_run: bool = False + + # safe mutable metadata container + meta: dict[str, Any] = field(default_factory=dict) + + +# ========================= +# TOOL CONTRACT +# ========================= + +class Tool(Protocol): + name: str + + def execute(self, payload: dict[str, Any], ctx: ToolContext) -> Any: + ... + + +# ========================= +# BASE TOOL +# ========================= + +class BaseTool: + name: str = "base" + + # ------------------------- + # LIFECYCLE WRAPPER + # ------------------------- + + def run(self, payload: dict[str, Any], ctx: ToolContext): + bus.log(self.name, "tool_start", "INFO", {"payload": payload}) + + try: + # attach execution metadata (useful for event graph later) + ctx.meta.setdefault("tool", self.name) + ctx.meta.setdefault("dry_run", ctx.dry_run) + + if ctx.dry_run: + result = self.preview(payload, ctx) + else: + result = self.execute(payload, ctx) + + bus.log( + self.name, + "tool_success", + "SUCCESS", + { + "result_type": type(result).__name__, + "result": repr(result)[:500] # prevent log explosion + } + ) + + return result + + except Exception as e: + bus.log( + self.name, + "tool_error", + "ERROR", + { + "error_type": type(e).__name__, + "error": str(e) + } + ) + raise + + # ------------------------- + # OVERRIDABLE + # ------------------------- + + def execute(self, payload: dict[str, Any], ctx: ToolContext): + raise NotImplementedError("Tool must implement execute()") + + def preview(self, payload: dict[str, Any], ctx: ToolContext): + return { + "dry_run": True, + "tool": self.name, + "payload": payload + } \ No newline at end of file diff --git a/core/tools/registry.py b/core/tools/registry.py new file mode 100644 index 0000000..dda0b65 --- /dev/null +++ b/core/tools/registry.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from typing import Any + +from core.tools.base import BaseTool, ToolContext +from core.events import bus + + +# ========================= +# TOOL REGISTRY +# ========================= + +class ToolRegistry: + def __init__(self): + self._tools: dict[str, BaseTool] = {} + + # ------------------------- + # REGISTER + # ------------------------- + + def register(self, tool: BaseTool): + self._tools[tool.name] = tool + + bus.log( + "REGISTRY", + "tool_registered", + "INFO", + { + "tool": tool.name + } + ) + + # ------------------------- + # RESOLVE + # ------------------------- + + def get(self, name: str) -> BaseTool: + tool = self._tools.get(name) + + if tool is None: + bus.log( + "REGISTRY", + "tool_not_found", + "ERROR", + {"tool": name} + ) + + raise ValueError(f"Tool not found: {name}") + + return tool + + # ------------------------- + # EXECUTE + # ------------------------- + + def run( + self, + name: str, + payload: dict[str, Any], + ctx: ToolContext + ): + tool = self.get(name) + + # registry metadata + ctx.meta.setdefault("registry", True) + ctx.meta.setdefault("tool_name", name) + + bus.log( + "REGISTRY", + "tool_dispatch", + "INFO", + { + "tool": name, + "dry_run": ctx.dry_run, + } + ) + + try: + result = tool.run(payload, ctx) + + bus.log( + "REGISTRY", + "tool_completed", + "SUCCESS", + { + "tool": name, + "result_type": type(result).__name__ + } + ) + + return result + + except Exception as e: + bus.log( + "REGISTRY", + "tool_failed", + "ERROR", + { + "tool": name, + "error": str(e), + "error_type": type(e).__name__ + } + ) + raise + + # ------------------------- + # DISCOVERY (NEW) + # ------------------------- + + def all_tools(self) -> list[BaseTool]: + return list(self._tools.values()) + + def names(self) -> list[str]: + return list(self._tools.keys()) + + def exists(self, name: str) -> bool: + return name in self._tools + + +# ========================= +# SINGLETON +# ========================= + +registry = ToolRegistry() \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..ebf177e --- /dev/null +++ b/main.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from core.config import ( + WORKSPACE_ROOT, + API_HOST, + API_PORT, + SERVER_NAME, +) +from core.events import bus, Event +from core.metrics import metrics +from core.safety import safety +from core.executor import executor +from core.tools.registry import registry +from core.tools.base import ToolContext +from tools.discovery import load_all_tools + + +# ========================= +# RUNTIME BOOTSTRAP +# ========================= + +executor.start() + + +# ========================= +# MCP SERVER +# ========================= + +mcp = FastMCP(SERVER_NAME) + + +# ========================= +# REGISTRY → MCP AUTO BIND +# ========================= + +def bind_registry_tools(): + for tool in registry.all_tools(): + + tool_name = tool.name + tool_description = getattr( + tool, + "description", + f"{tool_name} tool" + ) + + def make_handler(bound_tool): + def handler(**kwargs): + ctx = ToolContext( + dry_run=safety.dry_run + ) + + return registry.run( + name=bound_tool.name, + payload=kwargs, + ctx=ctx + ) + + return handler + + mcp.tool( + name=tool_name, + description=tool_description + )(make_handler(tool)) + + bus.log( + "CORE", + "mcp_tool_bound", + "INFO", + { + "tool": tool_name + } + ) + + +# ========================= +# SYSTEM BOOT +# ========================= + +def boot_system(): + load_all_tools() + bind_registry_tools() + + bus.emit(Event( + name="system_boot", + source="CORE", + level="INFO", + data={ + "workspace": str(WORKSPACE_ROOT), + "server": SERVER_NAME, + "tool_count": len(registry._tools) + } + )) + + +# ========================= +# METRICS +# ========================= + +def get_metrics_snapshot(): + return metrics.snapshot() + + +# ========================= +# DEBUG EVENTS (OPTIONAL) +# ========================= + +def debug_event_printer(event: Event): + print( + f"[EVENT] " + f"{event.source} - " + f"{event.level} - " + f"{event.name}" + ) + + +# bus.subscribe(debug_event_printer) + + +# ========================= +# MCP RUNNER +# ========================= + +def run_mcp(): + boot_system() + + bus.emit(Event( + name="mcp_start", + source="CORE", + level="INFO", + data={ + "server": SERVER_NAME + } + )) + + mcp.run(transport="stdio") + + +# ========================= +# FASTAPI HOOK (ISOLATED) +# ========================= + +async def run_api(): + from fastapi import FastAPI + import uvicorn + + app = FastAPI() + + @app.get("/metrics") + def metrics_route(): + return get_metrics_snapshot() + + @app.get("/health") + def health(): + return { + "status": "ok" + } + + config = uvicorn.Config( + app, + host=API_HOST, + port=API_PORT, + log_level="info" + ) + + server = uvicorn.Server(config) + + await server.serve() + + +# ========================= +# ENTRYPOINT +# ========================= + +if __name__ == "__main__": + try: + run_mcp() + + except KeyboardInterrupt: + bus.emit(Event( + name="system_shutdown", + source="CORE", + level="INFO" + )) + + except Exception as e: + bus.emit(Event( + name="fatal_error", + source="CORE", + level="ERROR", + data={ + "error": str(e) + } + )) + raise \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8cc2e53 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest +from pathlib import Path + +TEST_ROOT = Path(__file__).parent + +@pytest.fixture +def workspace(tmp_path): + return tmp_path \ No newline at end of file diff --git a/tests/profiles.json b/tests/profiles.json new file mode 100644 index 0000000..abc50f3 --- /dev/null +++ b/tests/profiles.json @@ -0,0 +1,95 @@ +{ + "schema_version": "1.0.0", + "description": "MCP test execution profiles for safe, staged tool testing and CI orchestration.", + + "default_profile": "unit", + + "global": { + "timeout_seconds": 10, + "allow_network": false, + "allow_subprocess": true, + "allow_filesystem_write": false, + "allow_docker": false, + "allow_cmake": false, + "allow_qemu": false, + "allow_git_push": false, + "log_level": "info" + }, + + "profiles": { + "unit": { + "description": "Fast isolated tests. No side effects.", + "inherits": "global", + "overrides": { + "allow_filesystem_write": false, + "allow_network": false, + "timeout_seconds": 5 + } + }, + + "integration": { + "description": "Tool interaction tests (http, subprocess, file system).", + "inherits": "global", + "overrides": { + "allow_filesystem_write": true, + "allow_network": true, + "timeout_seconds": 30 + } + }, + + "dangerous": { + "description": "Full system tests. Docker, cmake, qemu allowed.", + "inherits": "global", + "overrides": { + "allow_filesystem_write": true, + "allow_network": true, + "allow_docker": true, + "allow_cmake": true, + "allow_qemu": true, + "timeout_seconds": 600 + } + }, + + "chaos": { + "description": "Stress testing and randomness injection.", + "inherits": "dangerous", + "overrides": { + "allow_network": true, + "timeout_seconds": 120, + "enable_reruns": true, + "enable_random_order": true, + "enable_parallel": true + } + }, + + "ci": { + "description": "CI-safe deterministic execution.", + "inherits": "unit", + "overrides": { + "timeout_seconds": 15, + "enable_coverage": true + } + } + }, + + "tool_rules": { + "cmake": { + "allowed_profiles": ["dangerous", "chaos"] + }, + "docker": { + "allowed_profiles": ["dangerous", "chaos"] + }, + "qemu": { + "allowed_profiles": ["dangerous"] + }, + "filesystem_write": { + "allowed_profiles": ["integration", "dangerous", "chaos"] + }, + "git_push": { + "allowed_profiles": ["dangerous"] + }, + "network": { + "allowed_profiles": ["integration", "dangerous", "chaos"] + } + } +} \ No newline at end of file diff --git a/tests/tool_types.json b/tests/tool_types.json new file mode 100644 index 0000000..66c8481 --- /dev/null +++ b/tests/tool_types.json @@ -0,0 +1,23 @@ +{ + "schema_version": "1.0.0", + + "tool_types": { + "filesystem": ["read_file", "write_file", "delete_file", "append_file"], + "build": ["cmake_configure", "cmake_build", "cmake_clean"], + "container": ["docker_run", "docker_exec", "docker_build"], + "vm": ["qemu_run", "bochs_run"], + "media": ["ffmpeg_transcode", "extract_audio", "resize_image"], + "network": ["gitea_push", "http_request"], + "analysis": ["search_text", "analyze_code", "workspace_stats"] + }, + + "risk_levels": { + "filesystem": "medium", + "build": "high", + "container": "high", + "vm": "critical", + "media": "low", + "network": "medium", + "analysis": "low" + } +} \ No newline at end of file diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..a0bf8c4 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,16 @@ +""" +Tool bootstrap. + +Importing this module forces +tool registration into registry. +""" + +from tools.ping import PingTool +from tools.filesystem import FilesystemTool +from tools.subprocess import SubprocessTool + +__all__ = [ + "PingTool", + "FilesystemTool", + "SubprocessTool", +] \ No newline at end of file diff --git a/tools/bochs.py b/tools/bochs.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/cmake.py b/tools/cmake.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/discovery.py b/tools/discovery.py new file mode 100644 index 0000000..33b65d5 --- /dev/null +++ b/tools/discovery.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import importlib +import pkgutil + +import tools as tools_pkg + + +def load_all_tools(): + """ + Explicit tool loader. + + Imports all modules inside /tools so they register + themselves into the registry. + """ + + for module in pkgutil.iter_modules( + tools_pkg.__path__ + ): + importlib.import_module( + f"tools.{module.name}" + ) \ No newline at end of file diff --git a/tools/docker.py b/tools/docker.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/ffmpeg.py b/tools/ffmpeg.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/filesystem.py b/tools/filesystem.py new file mode 100644 index 0000000..92130c8 --- /dev/null +++ b/tools/filesystem.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from core.events import bus +from core.safety import safety +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry + + +class FilesystemTool(BaseTool): + name = "filesystem" + description = "Safe filesystem operations" + + # ========================= + # EXECUTE + # ========================= + + def execute( + self, + payload: dict[str, Any], + ctx: ToolContext + ): + action = str(payload.get("action", "")).strip() + + bus.log( + "FILESYSTEM", + "filesystem_execute", + "INFO", + { + "action": action + } + ) + + match action: + case "read_file": + return self.read_file(payload) + + case "write_file": + return self.write_file(payload) + + case "list_dir": + return self.list_dir(payload) + + case "exists": + return self.exists(payload) + + case "mkdir": + return self.mkdir(payload) + + case _: + raise ValueError( + f"Unknown filesystem action: {action}" + ) + + # ========================= + # READ FILE + # ========================= + + def read_file( + self, + payload: dict[str, Any] + ): + path_value = payload.get("path") + + if not isinstance(path_value, str): + raise ValueError("path must be string") + + path = safety.validate_path(path_value) + + return { + "path": str(path), + "content": path.read_text( + encoding="utf-8" + ) + } + + # ========================= + # WRITE FILE + # ========================= + + def write_file( + self, + payload: dict[str, Any] + ): + path_value = payload.get("path") + content_value = payload.get("content") + + if not isinstance(path_value, str): + raise ValueError("path must be string") + + if not isinstance(content_value, str): + raise ValueError( + "content must be string" + ) + + path = safety.validate_path(path_value) + + safety.check_file_write( + path, + content_value + ) + + path.parent.mkdir( + parents=True, + exist_ok=True + ) + + path.write_text( + content_value, + encoding="utf-8" + ) + + return { + "ok": True, + "path": str(path), + "bytes_written": len( + content_value.encode("utf-8") + ) + } + + # ========================= + # LIST DIRECTORY + # ========================= + + def list_dir( + self, + payload: dict[str, Any] + ): + path_value = payload.get( + "path", + "." + ) + + if not isinstance(path_value, str): + raise ValueError( + "path must be string" + ) + + path = safety.validate_path( + path_value + ) + + entries: list[dict[str, Any]] = [] + + for item in path.iterdir(): + entries.append( + { + "name": item.name, + "path": str(item), + "is_dir": item.is_dir(), + "is_file": item.is_file() + } + ) + + return { + "path": str(path), + "count": len(entries), + "entries": entries + } + + # ========================= + # EXISTS + # ========================= + + def exists( + self, + payload: dict[str, Any] + ): + path_value = payload.get("path") + + if not isinstance(path_value, str): + raise ValueError( + "path must be string" + ) + + path = safety.validate_path( + path_value + ) + + return { + "path": str(path), + "exists": path.exists(), + "is_dir": path.is_dir(), + "is_file": path.is_file() + } + + # ========================= + # MKDIR + # ========================= + + def mkdir( + self, + payload: dict[str, Any] + ): + path_value = payload.get("path") + + if not isinstance(path_value, str): + raise ValueError( + "path must be string" + ) + + path = safety.validate_path( + path_value + ) + + path.mkdir( + parents=True, + exist_ok=True + ) + + return { + "ok": True, + "path": str(path) + } + + +# ========================= +# SELF REGISTER +# ========================= + +registry.register( + FilesystemTool() +) \ No newline at end of file diff --git a/tools/git.py b/tools/git.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/gitea.py b/tools/gitea.py new file mode 100644 index 0000000..fceb2ef --- /dev/null +++ b/tools/gitea.py @@ -0,0 +1,50 @@ +import requests +from core.config import GITEA_URL, GITEA_TOKEN +from core.logging_core import logger + +HEADERS = { + "Authorization": f"token {GITEA_TOKEN}", + "Content-Type": "application/json" +} + +def create_repo(name: str, private: bool = True): + url = f"{GITEA_URL}/api/v1/user/repos" + + payload = { + "name": name, + "private": private, + "auto_init": True + } + + r = requests.post(url, json=payload, headers=HEADERS) + + logger.info(f"[GITEA] create_repo={name} status={r.status_code}") + + return r.json() + +import subprocess +from core.subprocess import run_command + +def git_push(repo_path: str, message: str): + cmds = [ + ["git", "-C", repo_path, "add", "."], + ["git", "-C", repo_path, "commit", "-m", message], + ["git", "-C", repo_path, "push"] + ] + + results = [run_command(c) for c in cmds] + return results + +def create_or_update_file(owner, repo, path, content, message): + url = f"{GITEA_URL}/api/v1/repos/{owner}/{repo}/contents/{path}" + + payload = { + "content": content.encode("utf-8").decode("utf-8"), + "message": message + } + + r = requests.post(url, json=payload, headers=HEADERS) + + logger.info(f"[GITEA] file={path} status={r.status_code}") + + return r.json() \ No newline at end of file diff --git a/tools/ping.py b/tools/ping.py new file mode 100644 index 0000000..e6890ab --- /dev/null +++ b/tools/ping.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry +from core.events import bus + + +class PingTool(BaseTool): + name = "ping" + + # optional metadata (future MCP auto-binding) + description = "Health check" + + # ------------------------- + # EXECUTE + # ------------------------- + + def execute( + self, + payload: dict[str, str], + ctx: ToolContext + ): + message = payload.get("message", "pong") + + bus.log( + "TOOLS", + "ping_execute", + "INFO", + { + "message": message + } + ) + + return { + "status": "ok", + "echo": message, + "tool": self.name + } + + +# ========================= +# SELF REGISTER +# ========================= + +registry.register(PingTool()) \ No newline at end of file diff --git a/tools/qemu.py b/tools/qemu.py new file mode 100644 index 0000000..e69de29 diff --git a/tools/subprocess.py b/tools/subprocess.py new file mode 100644 index 0000000..84dfae2 --- /dev/null +++ b/tools/subprocess.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +from typing import Any + +from core.subprocess import run_command +from core.tools.base import BaseTool, ToolContext +from core.tools.registry import registry + + +class SubprocessTool(BaseTool): + name = "subprocess" + description = "Run a subprocess command safely" + + # ========================= + # EXECUTE + # ========================= + + def execute( + self, + payload: dict[str, Any], + ctx: ToolContext + ): + cmd = payload.get("cmd") + + if not isinstance(cmd, list): + raise ValueError( + "cmd must be list[str]" + ) + + cwd = payload.get("cwd") + + if cwd is not None and not isinstance( + cwd, + str + ): + raise ValueError( + "cwd must be string" + ) + + timeout = payload.get( + "timeout", + 60 + ) + + if not isinstance( + timeout, + int + ): + raise ValueError( + "timeout must be int" + ) + + return run_command( + cmd=cmd, + cwd=cwd, + timeout=timeout + ) + + +# ========================= +# SELF REGISTER +# ========================= + +registry.register( + SubprocessTool() +) \ No newline at end of file diff --git a/tools/venv.py b/tools/venv.py new file mode 100644 index 0000000..e69de29