"""OpenSpace MCP Server Exposes the following tools to MCP clients: cloud_auth_flow — Register/login and provision cloud agent API keys execute_task — Delegate a task (auto-registers skills, auto-searches, auto-evolves) search_skills — Standalone local skill search cloud_browse_skills — LLM-guided cloud package/skill browsing and import fix_skill — Run a manual FIX job for a broken skill through evolution upload_skill — Upload a local skill to cloud after resolving placement/metadata Usage: python -m openspace.entrypoints.mcp.server # auto (TTY -> SSE, MCP host -> stdio) python -m openspace.entrypoints.mcp.server --transport sse # SSE on port 8080 python -m openspace.entrypoints.mcp.server --transport streamable-http # Streamable HTTP on port 8081 python -m openspace.entrypoints.mcp.server --port 9090 # SSE on custom port Environment variables: see ``openspace/host_detection/`` and ``openspace/cloud/auth_flow.py``. """ from __future__ import annotations import asyncio import inspect import json import logging import os import sys from pathlib import Path from typing import Any, Dict, List, Mapping, Optional class _MCPSafeStdout: """Stdout wrapper: binary (.buffer) → real stdout, text (.write) → stderr.""" def __init__(self, real_stdout, stderr): self._real = real_stdout self._stderr = stderr @property def buffer(self): return self._real.buffer def fileno(self): return self._real.fileno() def write(self, s): return self._stderr.write(s) def writelines(self, lines): return self._stderr.writelines(lines) def flush(self): self._stderr.flush() self._real.flush() def isatty(self): return self._stderr.isatty() @property def encoding(self): return self._stderr.encoding @property def errors(self): return self._stderr.errors @property def closed(self): return self._stderr.closed def readable(self): return False def writable(self): return True def seekable(self): return False def __getattr__(self, name): return getattr(self._stderr, name) _PROJECT_ROOT = Path(__file__).resolve().parents[3] _PACKAGE_ROOT = Path(__file__).resolve().parents[2] _LOG_DIR = _PROJECT_ROOT / "logs" _LOG_DIR.mkdir(parents=True, exist_ok=True) _real_stdout = sys.stdout # Windows pipe buffers are small. When using stdio MCP transport, # the parent process only reads stdout for MCP messages and does NOT # drain stderr. Heavy log/print output during execute_task fills the stderr # pipe buffer, blocking this process on write() → deadlock → timeout. # Redirect stderr to a log file on Windows to prevent this. if os.name == "nt": _stderr_file = open( _LOG_DIR / "mcp_stderr.log", "a", encoding="utf-8", buffering=1 ) sys.stderr = _stderr_file sys.stdout = _MCPSafeStdout(_real_stdout, sys.stderr) logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", handlers=[logging.FileHandler(_LOG_DIR / "mcp_server.log")], ) logger = logging.getLogger(__name__) from openspace.entrypoints.mcp.response import ( format_task_result as _format_task_result, json_error as _json_error, json_ok as _json_ok, ) from openspace.runtime import ExecutionRequest from mcp.server.fastmcp import FastMCP _fastmcp_kwargs: dict = {} try: if "description" in inspect.signature(FastMCP.__init__).parameters: _fastmcp_kwargs["description"] = ( "OpenSpace: Unite the Agents. Evolve the Mind. Rebuild the World." ) except (TypeError, ValueError): pass mcp = FastMCP("OpenSpace", **_fastmcp_kwargs) _openspace_instance = None _openspace_lock = asyncio.Lock() # Internal state: tracks bot skill directories already registered this session. _registered_skill_dirs: set = set() _UPLOAD_META_FILENAME = ".upload_meta.json" def _resolve_session_storage_dir(workspace: str | None) -> str | None: """Keep MCP runtime session files inside an explicit or workspace-local root.""" explicit = os.environ.get("OPENSPACE_SESSION_STORAGE_DIR") if explicit: return str(Path(explicit).expanduser().resolve()) config_home = os.environ.get("OPENSPACE_CONFIG_HOME") if config_home: return str(Path(config_home).expanduser().resolve()) if workspace: return str((Path(workspace).expanduser().resolve() / ".openspace")) return None async def _get_openspace(): """Lazy-initialise the OpenSpace engine.""" global _openspace_instance if _openspace_instance is not None and _openspace_instance.is_initialized(): return _openspace_instance async with _openspace_lock: if _openspace_instance is not None and _openspace_instance.is_initialized(): return _openspace_instance logger.info("Initializing OpenSpace engine ...") from openspace import OpenSpace, OpenSpaceConfig from openspace.host_detection import ( build_grounding_config_path, build_llm_kwargs, load_runtime_env, ) load_runtime_env() env_model = os.environ.get("OPENSPACE_MODEL", "") workspace = os.environ.get("OPENSPACE_WORKSPACE") max_iter = int(os.environ.get("OPENSPACE_MAX_ITERATIONS", "20")) enable_rec = os.environ.get("OPENSPACE_ENABLE_RECORDING", "true").lower() in ("true", "1", "yes") backend_scope_raw = os.environ.get("OPENSPACE_BACKEND_SCOPE") backend_scope = ( [b.strip() for b in backend_scope_raw.split(",") if b.strip()] if backend_scope_raw else None ) config_path = build_grounding_config_path() model, llm_kwargs = build_llm_kwargs(env_model) _pkg_root = str(_PROJECT_ROOT) recording_base = workspace or _pkg_root recording_log_dir = str(Path(recording_base) / "logs" / "recordings") config = OpenSpaceConfig( llm_model=model, llm_kwargs=llm_kwargs, workspace_dir=workspace, session_storage_dir=_resolve_session_storage_dir(workspace), grounding_max_iterations=max_iter, enable_recording=enable_rec, recording_backends=["shell"] if enable_rec else None, # ["shell", "mcp", "web"] if enable_rec else None recording_log_dir=recording_log_dir, backend_scope=backend_scope, grounding_config_path=config_path, ) _openspace_instance = OpenSpace(config=config) await _openspace_instance.initialize() logger.info("OpenSpace engine ready (model=%s).", model) # Auto-register host bot skill directories from env (set once by human) host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") if host_skill_dirs_raw: dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] if dirs: await _auto_register_skill_dirs(dirs) logger.info("Auto-registered host skill dirs from OPENSPACE_HOST_SKILL_DIRS: %s", dirs) return _openspace_instance async def _get_runtime_store(*, required: bool = True): """Return the SkillStore owned by the OpenSpace runtime.""" openspace = await _get_openspace() store = openspace.get_skill_store() if store and not getattr(store, "_closed", False): return store if required: raise RuntimeError("SkillStore is not initialized") return None async def _get_cloud_mapping_store(): from openspace.cloud.local_mapping import CloudLocalMappingStore db_path = None if _openspace_instance is not None: try: store = _openspace_instance.get_skill_store() if store and not getattr(store, "_closed", False): db_path = getattr(store, "db_path", None) except Exception as exc: logger.debug(f"Cloud mapping store DB path lookup failed: {exc}") return CloudLocalMappingStore(db_path) def _get_cloud_client(*, mapping_store=None): """Get a OpenSpaceClient instance (raises CloudError if not configured).""" from openspace.cloud.client import OpenSpaceClient from openspace.cloud.config import load_cloud_config return OpenSpaceClient(load_cloud_config(), mapping_store=mapping_store) def _normalize_upload_origin(origin: str) -> str: mapping = { "imported": "imported", "captured": "capture", "capture": "capture", "derived": "derive", "derive": "derive", "fixed": "fix", "fix": "fix", } normalized = mapping.get(origin) if not normalized: raise ValueError("origin must be imported, captured/capture, derived/derive, or fixed/fix") return normalized def _cloud_available_for_implicit_use() -> bool: """Return whether automatic cloud discovery should run.""" try: from openspace.cloud.config import load_cloud_config config = load_cloud_config() except Exception as exc: logger.warning("Cloud config invalid; skipping automatic cloud use: %s", exc) return False if not config.enabled: return False if not config.api_key: logger.warning("OPENSPACE_CLOUD_API_KEY is required; skipping automatic cloud use") return False return True def _write_upload_meta(skill_dir: Path, info: Dict[str, Any]) -> None: """Write ``.upload_meta.json`` so ``upload_skill`` can read pre-saved metadata. Called after a validated evolution action commits. The upload tool still requires package placement for non-fix v2 uploads unless placement has already been saved in this metadata. """ meta = { "origin": info.get("origin", "imported"), "parent_local_skill_ids": info.get("parent_local_skill_ids", []), "parent_cloud_skill_ids": info.get("parent_cloud_skill_ids", []), "change_summary": info.get("change_summary", ""), "created_by": info.get("created_by", "openspace"), "tags": info.get("tags", []), } if isinstance(info.get("upload_placement"), dict): meta["upload_placement"] = info["upload_placement"] meta_path = skill_dir / _UPLOAD_META_FILENAME try: meta_path.write_text( json.dumps(meta, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) logger.debug(f"Wrote upload metadata to {meta_path}") except Exception as e: logger.warning(f"Failed to write upload metadata: {e}") def _save_upload_meta(skill_dir: Path, meta: Dict[str, Any]) -> None: meta_path = skill_dir / _UPLOAD_META_FILENAME meta_path.write_text( json.dumps(meta, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) async def _read_upload_meta(skill_dir: Path) -> Dict[str, Any]: """Read upload metadata with three-tier fallback. Resolution order: 1. ``.upload_meta.json`` sidecar file (written right after evolution) 2. SkillStore DB lookup by path (long-term persistence) 3. Empty dict (caller applies defaults) This ensures metadata survives even if the sidecar file is deleted or the user comes back to upload much later. """ # Tier 1: sidecar file meta_path = skill_dir / _UPLOAD_META_FILENAME if meta_path.exists(): try: data = json.loads(meta_path.read_text(encoding="utf-8")) if data: return data except (json.JSONDecodeError, OSError) as e: logger.warning(f"Failed to read upload metadata file: {e}") # Tier 2: DB lookup try: store = await _get_runtime_store(required=False) if store: rec = store.load_record_by_path(str(skill_dir)) if rec: logger.debug(f"Upload metadata resolved from DB for {skill_dir}") return { "origin": rec.lineage.origin.value, "parent_local_skill_ids": rec.lineage.parent_skill_ids, "parent_cloud_skill_ids": [], "change_summary": rec.lineage.change_summary, "created_by": rec.lineage.created_by or "", "tags": rec.tags, } except Exception as e: logger.debug(f"DB upload metadata lookup failed: {e}") return {} async def _auto_register_skill_dirs(skill_dirs: List[str]) -> int: """Register bot skill directories into OpenSpace's SkillRegistry + DB. Called automatically by ``execute_task`` on every invocation. Directories are re-scanned each time so that skills created by the host bot since the last call are discovered immediately. """ global _registered_skill_dirs valid_dirs = [Path(d) for d in skill_dirs if Path(d).is_dir()] if not valid_dirs: return 0 openspace = await _get_openspace() _register_evidence_read_roots(openspace, *valid_dirs) registry = openspace.get_skill_registry() if not registry: logger.warning("_auto_register_skill_dirs: SkillRegistry not initialized") return 0 added = registry.discover_from_dirs(valid_dirs) db_created = 0 if added: store = await _get_runtime_store(required=False) if store: db_created = await store.sync_from_registry(added) is_first = any(d not in _registered_skill_dirs for d in skill_dirs) for d in skill_dirs: _registered_skill_dirs.add(d) if added: action = "Auto-registered" if is_first else "Re-scanned & found" logger.info( f"{action} {len(added)} skill(s) from {len(valid_dirs)} dir(s), " f"{db_created} new DB record(s)" ) return len(added) def _register_evidence_read_roots(openspace: Any, *roots: Any) -> None: runtime = getattr(openspace, "runtime", None) register = getattr(runtime, "register_evidence_read_roots", None) if not callable(register): return try: register(*roots) except Exception: logger.debug("MCP evidence read root registration skipped", exc_info=True) async def _cloud_search_candidates(task: str, limit: int = 8) -> List[Dict[str, Any]]: """Search cloud for skills relevant to *task* and return import candidates. This is intentionally discovery-only. Cloud skills must not be downloaded silently because the agent needs to inspect the local package taxonomy and choose a ``local_category_path`` before import. """ try: normalized_task_query = task.strip() if not normalized_task_query: return [] cloud_client = _get_cloud_client() cloud_search_results = await asyncio.to_thread( cloud_client.search_cloud_skills, query=normalized_task_query, limit=min(limit * 2, 50), ) if not cloud_search_results: return [] cloud_hits = [ cloud_result for cloud_result in cloud_search_results if cloud_result.get("cloud_skill_id") ][:limit] import_results: List[Dict[str, Any]] = [] for cloud_hit in cloud_hits: cloud_skill_id = str(cloud_hit.get("cloud_skill_id") or "") if not cloud_skill_id: continue import_results.append({ "cloud_skill_id": cloud_skill_id, "name": cloud_hit.get("name", ""), "summary": cloud_hit.get("description") or cloud_hit.get("summary", ""), "package_id": cloud_hit.get("package_id", ""), "package_path": cloud_hit.get("package_path", ""), "import_status": "needs_local_category_path", "required_next_tool": "cloud_browse_skills", "required_next_action": "local_taxonomy_then_import_skill", }) if import_results: logger.info( "Cloud search found %d skill candidate(s); explicit local taxonomy " "selection is required before import", len(import_results), ) return import_results except Exception as e: logger.warning(f"_cloud_search_candidates failed (non-fatal): {e}") return [] async def _do_import_cloud_skill( cloud_skill_id: str, target_dir: Optional[str] = None, *, local_category: str | None = None, local_category_path: str | None = None, ) -> Dict[str, Any]: """Download a cloud skill and register it locally.""" client = _get_cloud_client() if target_dir: base_dir = Path(target_dir) else: host_ws = ( os.environ.get("NANOBOT_WORKSPACE") or os.environ.get("OPENCLAW_STATE_DIR") ) if host_ws: base_dir = Path(host_ws) / "skills" base_dir.mkdir(parents=True, exist_ok=True) else: openspace = await _get_openspace() grounding_config = openspace.get_grounding_config() skill_cfg = grounding_config.skills if grounding_config else None if skill_cfg and skill_cfg.skill_dirs: base_dir = Path(skill_cfg.skill_dirs[0]) else: base_dir = _PACKAGE_ROOT / "skills" result = await asyncio.to_thread( client.import_skill, cloud_skill_id, base_dir, local_category=local_category, local_category_path=local_category_path, ) skill_dir = Path(result.get("local_path", "")) if skill_dir.exists(): openspace = await _get_openspace() _register_evidence_read_roots(openspace, skill_dir) registry = openspace.get_skill_registry() if registry: meta = registry.register_skill_dir(skill_dir) if meta: store = await _get_runtime_store(required=False) if store: await store.sync_from_registry([meta]) _register_evidence_read_roots(openspace, skill_dir) result["registered"] = True result.setdefault("registered", False) return result def _resolve_cloud_import_base_dir(target_dir: Optional[str] = None) -> Path: """Resolve a writable local root for cloud package imports.""" if target_dir: base_dir = Path(target_dir) else: host_ws = ( os.environ.get("NANOBOT_WORKSPACE") or os.environ.get("OPENCLAW_STATE_DIR") or os.environ.get("OPENSPACE_WORKSPACE") ) base_dir = Path(host_ws) / "skills" if host_ws else _PACKAGE_ROOT / "skills" base_dir.mkdir(parents=True, exist_ok=True) return base_dir def _summarize_recall_package(package: Dict[str, Any]) -> Dict[str, Any]: previews = [ { "cloud_skill_id": str(preview.get("cloud_skill_id") or ""), "skill_name": str(preview.get("skill_name") or preview.get("title") or ""), "preview_text": str(preview.get("preview_text") or preview.get("summary") or ""), } for preview in (package.get("preview_entries") or []) if isinstance(preview, dict) ] return { "package_id": str(package.get("package_id") or ""), "package_path": str(package.get("package_path") or ""), "package_kind": str(package.get("package_kind") or ""), "package_display_name": str(package.get("package_display_name") or ""), "summary_line": str(package.get("summary_line") or ""), "display_scope": str(package.get("display_scope") or ""), "rank": package.get("rank"), "score": package.get("score"), "preview_entries": previews, } def _summarize_cloud_skill_candidate(skill: Dict[str, Any]) -> Dict[str, Any]: metadata = skill.get("authored_metadata") if not isinstance(metadata, dict): metadata = {} return { "cloud_skill_id": str(skill.get("cloud_skill_id") or skill.get("skill_id") or ""), "title": str(skill.get("title") or metadata.get("name") or skill.get("skill_name") or ""), "summary": str(skill.get("summary") or metadata.get("description") or skill.get("snippet") or ""), "package_id": str(skill.get("package_id") or ""), "package_path": str(skill.get("package_path") or ""), "effective_visibility": str(skill.get("effective_visibility") or skill.get("access_mode") or ""), "manifest_hash": skill.get("manifest_hash"), "artifact_dir": skill.get("artifact_dir"), "artifact_state": skill.get("artifact_state"), "downloadable": skill.get("downloadable"), "metadata_only": skill.get("metadata_only"), "rank": skill.get("rank"), "score": skill.get("score"), "match_mode": skill.get("match_mode"), "snippet": skill.get("snippet"), } def _summarize_projection_pull( pull: Dict[str, Any], *, max_packages: int, max_skills: int, ) -> Dict[str, Any]: all_packages = pull.get("packages") or [] all_skills = pull.get("skills") or [] packages = [ { "package_id": str(package.get("package_id") or ""), "package_path": str(package.get("package_path") or ""), "package_kind": str(package.get("package_kind") or ""), "package_display_name": str(package.get("package_display_name") or ""), "parent_package_id": package.get("parent_package_id"), "outline_path": package.get("outline_path"), } for package in all_packages[:max_packages] if isinstance(package, dict) ] skills = [ _summarize_cloud_skill_candidate(skill) for skill in all_skills[:max_skills] if isinstance(skill, dict) ] return { "root_package_id": str(pull.get("root_package_id") or ""), "root_package_path": str(pull.get("root_package_path") or ""), "bundle_version": pull.get("bundle_version"), "projection_hash": pull.get("projection_hash"), "serving_epoch": pull.get("serving_epoch"), "source_epoch": pull.get("source_epoch"), "packages": packages, "skills": skills, "package_count": len(all_packages), "skill_count": len(all_skills), "packages_truncated": len(all_packages) > len(packages), "skills_truncated": len(all_skills) > len(skills), } # MCP Tools @mcp.tool() async def cloud_auth_flow( action: str, email: str | None = None, password: str | None = None, name: str | None = None, agent_name: str = "openspace-local-agent", agent_id: str | None = None, persist: bool = True, credentials_path: str | None = None, ) -> str: """Register/login users and provision cloud agent API keys. Supported actions: - register_user - login_user - bootstrap_agent_key - list_agents - rotate_agent_key - verify_agent_key This tool never returns raw API keys, bearer tokens, or passwords. Agent keys created by bootstrap/rotate are saved to local OPENSPACE_CLOUD_* config by default. ``agent_name`` is owner-scoped: recovery only searches the currently authenticated user's agents. """ try: from openspace.cloud.auth_flow import cloud_auth_flow as run_cloud_auth_flow result = await asyncio.to_thread( run_cloud_auth_flow, action=action, email=email, password=password, name=name, agent_name=agent_name, agent_id=agent_id, persist=persist, credentials_path=credentials_path, ) return _json_ok(result) except Exception as e: from openspace.cloud.redaction import redact_cloud_secret logger.error("cloud_auth_flow failed: %s", redact_cloud_secret(str(e)), exc_info=True) return _json_error(redact_cloud_secret(str(e)), status="error") @mcp.tool() async def execute_task( task: str, workspace_dir: str | None = None, max_iterations: int | None = None, skill_dirs: list[str] | None = None, search_scope: str = "all", ) -> str: """Execute a task with OpenSpace's full grounding engine. OpenSpace will: 1. Auto-register bot skills from skill_dirs (if provided) 2. Search for relevant skills (scope controls local vs cloud+local) 3. Attempt skill-guided execution → fallback to pure tools 4. Auto-analyze → auto-evolve (FIX/DERIVED/CAPTURED) if needed If skills are auto-evolved, the response includes ``evolved_skills`` with ``upload_ready: true``. For non-fix uploads without pre-saved placement, call ``upload_skill`` with ``skill_dir``, ``visibility``, and ``cloud_package_path``. Note: This call blocks until the task completes (may take minutes). Set MCP client tool-call timeout ≥ 600 seconds. Args: task: The task instruction (natural language). workspace_dir: Working directory. Defaults to OPENSPACE_WORKSPACE env. max_iterations: Max agent iterations (default: 20). skill_dirs: Bot's skill directories to auto-register so OpenSpace can select and track them. Directories are re-scanned on every call to discover skills created since the last invocation. search_scope: Skill search scope before execution. "all" (default) — local + cloud; falls back to local if no API key is configured. "local" — local SkillRegistry only (fast, no cloud). """ try: openspace = await _get_openspace() # Re-scan host skill directories (from env) to pick up skills # created by the host bot since the last call. host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") if host_skill_dirs_raw: env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] if env_dirs: await _auto_register_skill_dirs(env_dirs) # Auto-register bot skill directories (from call parameter) if skill_dirs: await _auto_register_skill_dirs(skill_dirs) # Determine where CAPTURED skills should be written. # Prefer the explicit skill_dirs parameter (= calling host agent's dir), # then fall back to the first env-based host skill dir. capture_skill_dir: str | None = None if skill_dirs: capture_skill_dir = skill_dirs[0] elif host_skill_dirs_raw: first_env = next( (d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()), None, ) if first_env: capture_skill_dir = first_env # Cloud search + import (if requested) cloud_skill_candidates: List[Dict[str, Any]] = [] if search_scope == "all" and _cloud_available_for_implicit_use(): cloud_skill_candidates = await _cloud_search_candidates(task) # Execute result = await openspace.execute( ExecutionRequest( prompt=task, workspace_dir=workspace_dir, max_iterations=max_iterations, capture_skill_dir=capture_skill_dir, ) ) # Write .upload_meta.json for each evolved skill for es in result.evolved_skills: if not isinstance(es, dict): continue skill_path = es.get("path", "") if skill_path: _write_upload_meta(Path(skill_path).parent, es) formatted = _format_task_result(result) if cloud_skill_candidates: formatted["cloud_skill_candidates"] = cloud_skill_candidates formatted["cloud_action_required"] = ( "Cloud candidates were found but not imported automatically. " "Use cloud_browse_skills(action='local_taxonomy') and then " "cloud_browse_skills(action='import_skill', cloud_skill_id=..., " "local_category_path=...) before expecting them in local retrieval." ) return _json_ok(formatted) except Exception as e: logger.error(f"execute_task failed: {e}", exc_info=True) return _json_error(e, status="error") @mcp.tool() async def search_skills( query: str, limit: int = 20, ) -> str: """Search skills in the local OpenSpace registry. Standalone local search for browsing / discovery. Use this when the bot wants to inspect skills that are already installed locally, then decide whether to handle the task locally or delegate to ``execute_task``. **Scope difference from execute_task**: - ``search_skills`` returns results to the bot for decision-making. - ``execute_task``'s internal search feeds directly into execution (the bot never sees the search results). For cloud package/skill browsing, use ``cloud_browse_skills``. Keeping cloud browsing out of this tool avoids overlapping agent choices. Uses hybrid ranking: BM25 → embedding re-rank → lexical boost. Embedding uses OpenAI when configured, then OpenRouter when configured, and falls back to lexical-only without a remote embedding provider. Args: query: Search query text (natural language or keywords). limit: Maximum results to return (default: 20). """ try: from openspace.cloud.search import hybrid_search_skills q = query.strip() if not q: return _json_ok({"results": [], "count": 0}) # Re-scan host skill directories so newly created skills are searchable. local_skills = None store = None openspace = await _get_openspace() host_skill_dirs_raw = os.environ.get("OPENSPACE_HOST_SKILL_DIRS", "") if host_skill_dirs_raw: env_dirs = [d.strip() for d in host_skill_dirs_raw.split(",") if d.strip()] if env_dirs: await _auto_register_skill_dirs(env_dirs) registry = openspace.get_skill_registry() if registry: local_skills = registry.list_skills() store = await _get_runtime_store(required=False) results = await hybrid_search_skills( query=q, local_skills=local_skills, store=store, source="local", limit=limit, ) return _json_ok({"results": results, "count": len(results), "source": "local"}) except Exception as e: logger.error(f"search_skills failed: {e}", exc_info=True) return _json_error(e) @mcp.tool() async def cloud_browse_skills( action: str, query: str | None = None, search_id: str | None = None, package_ids: list[str] | None = None, package_id: str | None = None, cloud_skill_id: str | None = None, target_dir: str | None = None, local_category: str | None = None, local_category_path: str | None = None, limit: int = 8, audience: str = "requester_visible", artifact_filter: str = "downloadable_only", max_packages_per_pull: int = 40, max_skills_per_pull: int = 40, ) -> str: """Browse cloud packages/skills with one agent-facing stepwise tool. This is the cloud discovery entrypoint for agents. It keeps the LLM in the loop by returning package/skill summaries plus ``next_actions`` after each step. The agent continues by calling this same tool with the next action. Recommended action order: 1. ``search_skills`` with ``query`` to rank concrete downloadable skills. Optionally pass ``package_id`` to restrict search to one package subtree. 2. ``fetch_skill_detail`` with ``cloud_skill_id`` when exact metadata is needed before import. 3. ``local_placement`` to choose or create a local package taxonomy path. 4. ``import_skill`` with ``cloud_skill_id`` and ``local_category_path`` to download/register the selected skill locally. Optional package discovery actions: 1. ``recall`` with ``query`` to get package candidates and ``search_id``. 2. ``pull_projection`` with ``search_id`` + selected ``package_ids`` to inspect JSON package projections and record package-selection telemetry. 3. ``search_skills`` with ``package_id`` to run skill-first search scoped to a chosen package. Optional: ``import_package_bundle`` with ``package_id`` downloads a selected package bundle when the agent needs outline files or bundled artifacts. Args: action: One of ``local_placement``, ``local_taxonomy``, ``recall``, ``pull_projection``, ``search_skills``, ``fetch_skill_detail``, ``import_skill``, or ``import_package_bundle``. query: Query text for ``search_skills`` and ``recall``. search_id: ``search_id`` returned by ``recall``. package_ids: Selected package ids for ``pull_projection``. package_id: One selected package id for package-local actions. cloud_skill_id: Selected cloud skill id for detail/import actions. target_dir: Optional local import root. local_category: Optional local skill type for ``import_skill``. local_category_path: Optional local package taxonomy path for ``import_skill``. This is independent from cloud package_path. limit: Max package or skill candidates, depending on action. """ normalized_action = str(action or "").strip().lower().replace("-", "_") aliases = { "local_tree": "local_taxonomy", "inspect_local_tree": "local_taxonomy", "local_category_tree": "local_taxonomy", "placement": "local_placement", "local_place": "local_placement", "choose_local_path": "local_placement", "choose_local_category_path": "local_placement", "recall_packages": "recall", "pull": "pull_projection", "projection": "pull_projection", "package_projection": "pull_projection", "skill_search": "search_skills", "global_skill_search": "search_skills", "fetch": "fetch_skill_detail", "detail": "fetch_skill_detail", "import": "import_skill", "bundle": "import_package_bundle", } normalized_action = aliases.get(normalized_action, normalized_action) if normalized_action == "local_placement": return await cloud_local_placement( query=query, local_category=local_category, local_category_path=local_category_path, limit=limit, ) if normalized_action == "local_taxonomy": return await cloud_local_taxonomy( query=query, local_category=local_category, local_category_path=local_category_path, limit=limit, ) if normalized_action == "recall": return await cloud_recall_packages( query=query or "", limit=limit, audience=audience, ) if normalized_action == "pull_projection": return await cloud_pull_package_projection( search_id=search_id or "", package_ids=package_ids or [], audience=audience, max_packages_per_pull=max_packages_per_pull, max_skills_per_pull=max_skills_per_pull, ) if normalized_action == "search_skills": return await cloud_search_skills( query=query or "", package_id=package_id, limit=limit, audience=audience, artifact_filter=artifact_filter, ) if normalized_action == "fetch_skill_detail": return await cloud_fetch_skill_detail(cloud_skill_id=cloud_skill_id or "") if normalized_action == "import_skill": return await cloud_import_skill( cloud_skill_id=cloud_skill_id or "", target_dir=target_dir, local_category=local_category, local_category_path=local_category_path, ) if normalized_action == "import_package_bundle": return await cloud_import_package_bundle( package_id=package_id or "", target_dir=target_dir, audience=audience, ) return _json_ok({ "status": "error", "code": "UNKNOWN_CLOUD_BROWSE_ACTION", "message": "Unknown cloud_browse_skills action", "action": action, "valid_actions": [ "local_placement", "local_taxonomy", "recall", "pull_projection", "search_skills", "fetch_skill_detail", "import_skill", "import_package_bundle", ], "recommended_sequence": [ "recall", "pull_projection", "search_skills", "fetch_skill_detail", "local_placement", "import_skill", ], }) async def cloud_local_taxonomy( *, query: str | None = None, local_category: str | None = None, local_category_path: str | None = None, limit: int = 8, ) -> str: """Return a bounded local package taxonomy view for LLM placement.""" try: return _json_ok( await _local_taxonomy_payload( query=query, local_category=local_category, local_category_path=local_category_path, limit=limit, ) ) except Exception as e: logger.error("cloud_local_taxonomy failed: %s", e, exc_info=True) return _json_error(e, status="error") async def cloud_local_placement( *, query: str | None = None, local_category: str | None = None, local_category_path: str | None = None, limit: int = 8, ) -> str: """Return an explicit local category-path placement flow for agents.""" try: return _json_ok( await _local_placement_payload( query=query, local_category=local_category, local_category_path=local_category_path, limit=limit, ) ) except Exception as e: logger.error("cloud_local_placement failed: %s", e, exc_info=True) return _json_error(e, status="error") async def _local_placement_payload( *, query: str | None = None, local_category: str | None = None, local_category_path: str | None = None, limit: int = 8, ) -> dict[str, Any]: taxonomy = await _local_taxonomy_payload( query=query, local_category=local_category, local_category_path=local_category_path, limit=limit, ) candidates = _local_taxonomy_candidate_paths(taxonomy) payload = { **taxonomy, "status": "needs_local_category_path", "code": "LOCAL_CATEGORY_PATH_REQUIRED", "placement_decision_required": True, "interaction_flow": _local_placement_interaction_flow(), "local_category_path_policy": _local_category_path_policy(), "existing_path_candidates": candidates, "new_child_path_examples": _local_new_child_path_examples(candidates), } if str(local_category_path or "").strip(): payload["candidate_local_category_path"] = str(local_category_path).strip() payload["status"] = "local_category_path_candidate" payload["code"] = "LOCAL_CATEGORY_PATH_CANDIDATE" payload["next_actions"] = [ { "tool": "cloud_browse_skills", "action": "local_placement", "reason": "Expand one returned path, search by task/skill terms, or inspect another local taxonomy branch.", "optional_fields": ["local_category_path", "query", "local_category", "limit"], }, { "tool": "cloud_browse_skills", "action": "import_skill", "reason": "For cloud imports, pass the selected or newly composed local_category_path.", "required_fields": ["cloud_skill_id", "local_category_path"], "optional_fields": ["local_category"], }, { "consumer": "evolution_suggestion", "reason": "For DERIVED/CAPTURED generation, put the selected or newly composed path in decision.local_category_path.", "required_fields": ["local_category_path"], }, ] return payload async def _local_taxonomy_payload( *, query: str | None = None, local_category: str | None = None, local_category_path: str | None = None, limit: int = 8, ) -> dict[str, Any]: from openspace.cloud.skill_classification import ( build_local_taxonomy_snapshot, initialize_local_skill_taxonomy, ) openspace = await _get_openspace() registry = openspace.get_skill_registry() local_skills = [] if registry: local_skills = registry.list_skills() mapping_store = await _get_cloud_mapping_store() try: bootstrap = initialize_local_skill_taxonomy( mapping_store=mapping_store, skills=local_skills, ) payload = build_local_taxonomy_snapshot( mapping_store=mapping_store, skills=local_skills, category=local_category, path_prefix=local_category_path, query=query, max_paths=min(max(int(limit), 1), 25), ) payload["bootstrap"] = { "initialized_local_skill_count": bootstrap["created_count"], "skipped_local_skill_count": bootstrap["skipped_count"], } finally: mapping_store.close() payload["next_actions"] = [ { "tool": "cloud_browse_skills", "action": "local_placement", "reason": "Use the explicit local placement flow before choosing or creating local_category_path.", "optional_fields": ["local_category", "local_category_path", "query", "limit"], }, { "tool": "cloud_browse_skills", "action": "import_skill", "reason": "After selecting a local package taxonomy path, import the selected cloud skill.", "required_fields": ["cloud_skill_id", "local_category_path"], } ] return payload def _local_placement_interaction_flow() -> list[dict[str, Any]]: return [ { "step": "browse_roots_or_search", "call": "cloud_browse_skills(action='local_placement', query=... optional)", "agent_decision": "Inspect top-level local taxonomy roots or query-matched paths.", }, { "step": "expand_one_branch", "call": "cloud_browse_skills(action='local_placement', local_category_path=...)", "agent_decision": "Expand one candidate path instead of requesting the full local tree.", }, { "step": "choose_or_create", "agent_decision": ( "Choose an existing existing_path_candidates[].local_category_path, " "or compose a nearby new child path under a returned candidate." ), }, { "step": "use_selected_path", "call": ( "cloud_browse_skills(action='import_skill', cloud_skill_id=..., " "local_category_path=...) or include local_category_path in a DERIVED/CAPTURED decision" ), }, ] def _local_category_path_policy() -> dict[str, Any]: return { "local_category_path_is_agent_selected": True, "path_shape": "package-style taxonomy path, for example technology/computing/browser-automation", "category_is_separate_skill_type": True, "independent_from_cloud_package_path": True, "allowed_forms": [ { "form": "existing_local_taxonomy_path", "source": "existing_path_candidates[].local_category_path", }, { "form": "new_child_local_taxonomy_path", "source": "existing_path_candidates[].local_category_path + '/' + one_new_segment", }, { "form": "new_local_root_path", "source": "allowed when no returned root fits the skill/task; keep it package-style and specific", }, ], "new_path_creation": { "allowed": True, "preferred_increment": "one new child segment under a returned candidate", "may_create_new_root": True, "does_not_create_cloud_package": True, "does_not_upload_or_download_anything": True, }, "avoid": [ "Using category-only paths such as workflow or tool_guide as taxonomy placement.", "Using a vague root when a more specific returned branch fits.", "Treating local_category_path as cloud_package_path; they are separate fields.", ], } def _local_taxonomy_candidate_paths(taxonomy: Mapping[str, Any]) -> list[dict[str, Any]]: rows = [] for key in ("paths", "children", "roots", "sample_paths"): value = taxonomy.get(key) if isinstance(value, list): rows.extend(item for item in value if isinstance(item, dict)) seen: set[str] = set() candidates: list[dict[str, Any]] = [] for row in rows: path = str(row.get("local_category_path") or "").strip() if not path or path in seen: continue seen.add(path) candidates.append({ "local_category_path": path, "skill_count": int(row.get("skill_count") or 0), "skill_categories": dict(row.get("skill_categories") or {}), "review_states": dict(row.get("review_states") or {}), "sources": list(row.get("sources") or []), "examples": list(row.get("examples") or [])[:3], }) return candidates[:25] def _local_new_child_path_examples( candidates: list[dict[str, Any]], *, limit: int = 5, ) -> list[dict[str, str]]: examples: list[dict[str, str]] = [] for candidate in candidates: parent = str(candidate.get("local_category_path") or "").strip().strip("/") if not parent: continue examples.append({ "parent_local_category_path": parent, "local_category_path_example": f"{parent}/", }) if len(examples) >= limit: break return examples async def cloud_recall_packages( query: str, limit: int = 8, audience: str = "requester_visible", task_id: str | None = None, ) -> str: """Recall cloud package candidates and return them for LLM selection. This is the first step of the LLM-guided cloud skill browsing flow. It deliberately stops after ``POST /api/v2/recall/search`` so the agent can inspect package summaries and choose which package ids to pull next. Next typical call through the public tool: ``cloud_browse_skills(action="pull_projection", search_id=..., package_ids=...)``. """ try: q = query.strip() if not q: return _json_ok({"status": "success", "search_id": "", "results": [], "count": 0}) client = _get_cloud_client() payload = await asyncio.to_thread( client.search_packages, query=q, audience=audience, limit=min(max(int(limit), 1), 50), task_id=task_id, ) results = [ _summarize_recall_package(item) for item in (payload.get("results") or []) if isinstance(item, dict) ] return _json_ok({ "status": "success", "query": q, "audience": payload.get("audience", audience), "search_id": payload.get("search_id", ""), "results": results, "count": len(results), "next_actions": [ { "tool": "cloud_browse_skills", "action": "pull_projection", "reason": "Inspect selected package projection and record package-selection telemetry.", "required_fields": ["search_id", "package_ids"], }, { "tool": "cloud_browse_skills", "action": "search_skills", "reason": "Narrow a known package directly to concrete downloadable skills.", "required_fields": ["package_id", "query"], }, ], }) except Exception as e: logger.error("cloud_recall_packages failed: %s", e, exc_info=True) return _json_error(e, status="error") async def cloud_pull_package_projection( search_id: str, package_ids: list[str], audience: str = "requester_visible", max_packages_per_pull: int = 40, max_skills_per_pull: int = 40, ) -> str: """Pull JSON package projections selected from ``cloud_browse_skills(action="recall")``. This calls ``POST /api/v2/packages/pull``. It returns package/skill summaries for the LLM to decide whether to search inside a package, import an exact skill, or import/download the package bundle for deeper outline inspection. """ try: clean_ids = [str(package_id).strip() for package_id in (package_ids or []) if str(package_id).strip()] if not str(search_id or "").strip(): return _json_error("search_id is required from cloud_recall_packages") if not clean_ids: return _json_error("package_ids must contain at least one package_id") client = _get_cloud_client() payload = await asyncio.to_thread( client.pull_packages, package_ids=clean_ids, search_id=str(search_id).strip(), audience=audience, ) pulls = [ _summarize_projection_pull( pull, max_packages=max(int(max_packages_per_pull), 1), max_skills=max(int(max_skills_per_pull), 1), ) for pull in (payload.get("pulls") or []) if isinstance(pull, dict) ] return _json_ok({ "status": "success", "search_id": payload.get("search_id", search_id), "audience": payload.get("audience", audience), "pulls": pulls, "count": len(pulls), "next_actions": [ { "tool": "cloud_browse_skills", "action": "search_skills", "reason": "Search concrete skills inside a chosen root_package_id.", "required_fields": ["package_id", "query"], }, { "tool": "cloud_browse_skills", "action": "fetch_skill_detail", "reason": "Inspect exact cloud skill metadata before import.", "required_fields": ["cloud_skill_id"], }, { "tool": "cloud_browse_skills", "action": "local_taxonomy", "reason": "Inspect the local package taxonomy before choosing where to import the cloud skill.", "required_fields": [], }, { "tool": "cloud_browse_skills", "action": "import_skill", "reason": "Download/register an exact selected cloud skill after choosing a local package taxonomy path.", "required_fields": ["cloud_skill_id", "local_category_path"], "optional_fields": ["local_category"], }, { "tool": "cloud_browse_skills", "action": "import_package_bundle", "reason": "Download/import the package bundle when the agent wants the package outline and bundled artifacts.", "required_fields": ["package_id"], }, ], }) except Exception as e: logger.error("cloud_pull_package_projection failed: %s", e, exc_info=True) return _json_error(e, status="error") async def cloud_search_skills( query: str, package_id: str | None = None, limit: int = 10, audience: str = "requester_visible", artifact_filter: str = "downloadable_only", ) -> str: """Search concrete cloud skills through ``POST /api/v2/skills/search``.""" try: q = str(query or "").strip() pkg = str(package_id or "").strip() if not q: return _json_error("query is required") client = _get_cloud_client() payload = await asyncio.to_thread( client.search_skills, query=q, package_id=pkg or None, audience=audience, limit=min(max(int(limit), 1), 50), artifact_filter=artifact_filter, ) results = [ _summarize_cloud_skill_candidate(item) for item in (payload.get("results") or []) if isinstance(item, dict) ] return _json_ok({ "status": "success", "endpoint": "/api/v2/skills/search", "package_id": pkg, "query": q, "audience": payload.get("audience", audience), "root_package_id": payload.get("root_package_id", pkg), "root_package_path": payload.get("root_package_path", ""), "skill_search_id": payload.get("skill_search_id", ""), "requested_mode": payload.get("requested_mode", ""), "served_mode": payload.get("served_mode", ""), "semantic_status": payload.get("semantic_status", ""), "fallback_reason": payload.get("fallback_reason", ""), "artifact_filter": artifact_filter, "results": results, "count": len(results), "next_actions": [ { "tool": "cloud_browse_skills", "action": "fetch_skill_detail", "reason": "Inspect exact metadata for a selected cloud_skill_id.", "required_fields": ["cloud_skill_id"], }, { "tool": "cloud_browse_skills", "action": "local_taxonomy", "reason": "Inspect the local package taxonomy before choosing where to import the cloud skill.", "required_fields": [], }, { "tool": "cloud_browse_skills", "action": "import_skill", "reason": "Download/register a selected cloud skill after choosing a local package taxonomy path.", "required_fields": ["cloud_skill_id", "local_category_path"], "optional_fields": ["local_category"], }, ], }) except Exception as e: logger.error("cloud_search_skills failed: %s", e, exc_info=True) return _json_error(e, status="error") async def cloud_fetch_skill_detail( cloud_skill_id: str, ) -> str: """Fetch exact cloud skill metadata before the LLM decides to import.""" try: skill_id = str(cloud_skill_id or "").strip() if not skill_id: return _json_error("cloud_skill_id is required") client = _get_cloud_client() detail = await asyncio.to_thread(client.fetch_cloud_skill, skill_id) return _json_ok({ "status": "success", "skill": detail, "summary": _summarize_cloud_skill_candidate(detail), "next_actions": [ { "tool": "cloud_browse_skills", "action": "local_taxonomy", "reason": "Inspect the local package taxonomy before choosing where to import this cloud skill.", "required_fields": [], }, { "tool": "cloud_browse_skills", "action": "import_skill", "reason": "Download/register this exact cloud skill after choosing a local package taxonomy path.", "required_fields": ["cloud_skill_id", "local_category_path"], "optional_fields": ["local_category"], } ], }) except Exception as e: logger.error("cloud_fetch_skill_detail failed: %s", e, exc_info=True) return _json_error(e, status="error") async def cloud_import_skill( cloud_skill_id: str, target_dir: str | None = None, local_category: str | None = None, local_category_path: str | None = None, ) -> str: """Download and register one exact cloud skill selected by the agent. ``local_category_path`` is the agent-selected local package taxonomy path. It is independent from the cloud package path stored in the binding. """ try: skill_id = str(cloud_skill_id or "").strip() if not skill_id: return _json_error("cloud_skill_id is required") if not str(local_category_path or "").strip(): client = _get_cloud_client() try: detail = await asyncio.to_thread(client.fetch_cloud_skill, skill_id) except Exception: detail = {"cloud_skill_id": skill_id} taxonomy_query = " ".join( str(detail.get(key) or "") for key in ("title", "name", "summary", "description", "package_path") ).strip() return _json_ok({ "status": "needs_local_category_path", "code": "LOCAL_CATEGORY_PATH_REQUIRED", "message": ( "Read the local package taxonomy, then choose local_category_path " "before importing a cloud skill." ), "policy_decision_required": True, "cloud_skill_id": skill_id, "cloud_skill": _summarize_cloud_skill_candidate(detail), "local_placement": await _local_placement_payload( query=taxonomy_query or None, limit=8, ), "next_actions": [ { "tool": "cloud_browse_skills", "action": "local_placement", "reason": "Browse or expand the local placement flow if the initial candidates are insufficient.", "optional_fields": ["local_category", "local_category_path", "query", "limit"], }, { "tool": "cloud_browse_skills", "action": "import_skill", "reason": "Import after the agent selects a local package taxonomy path from/under the local tree.", "required_fields": ["cloud_skill_id", "local_category_path"], "optional_fields": ["local_category"], } ], }) result = await _do_import_cloud_skill( skill_id, target_dir=target_dir, local_category=local_category, local_category_path=local_category_path, ) return _json_ok({ **result, "status": result.get("status", "success"), "next_actions": [ { "tool": "search_skills", "reason": "Confirm the imported skill participates in local retrieval.", "required_fields": ["query", "source"], } ], }) except Exception as e: logger.error("cloud_import_skill failed: %s", e, exc_info=True) return _json_error(e, status="error") async def cloud_import_package_bundle( package_id: str, target_dir: str | None = None, audience: str = "requester_visible", ) -> str: """Download/import a selected cloud package bundle for deeper inspection. This is intentionally separate from search. Use it after the LLM has selected a package and wants the package bundle index/outline/artifacts locally rather than only JSON projection metadata. """ try: pkg = str(package_id or "").strip() if not pkg: return _json_error("package_id is required") client = _get_cloud_client() base_dir = _resolve_cloud_import_base_dir(target_dir) result = await asyncio.to_thread( client.import_package_bundle, pkg, base_dir, audience=audience, ) local_path = str(result.get("local_path") or "").strip() package_dir = Path(local_path) if local_path else None if package_dir is not None and package_dir.exists(): openspace = await _get_openspace() _register_evidence_read_roots(openspace, package_dir) registry = openspace.get_skill_registry() if registry: discovered = registry.discover_from_dirs([package_dir]) store = await _get_runtime_store(required=False) if store and discovered: await store.sync_from_registry(discovered) result["registered_skill_count"] = len(discovered) result.setdefault("registered_skill_count", 0) return _json_ok(result) except Exception as e: logger.error("cloud_import_package_bundle failed: %s", e, exc_info=True) return _json_error(e, status="error") @mcp.tool() async def fix_skill( skill_dir: str, direction: str, ) -> str: """Run a manual FIX evolution job for a broken skill. This endpoint creates durable evidence and a TriggerJob, then asks the runtime to claim and process that exact job through the evolution engine. It does not directly author, validate, commit, write SkillStore mutation state, or prepare upload metadata. The skill does not need to be pre-registered in OpenSpace. Provide the skill directory path and OpenSpace registers it before creating the job. Args: skill_dir: Path to the broken skill directory (must contain SKILL.md). direction: What's broken and how to fix it. Be specific: e.g. "The upstream endpoint changed path" or "Add retry logic for HTTP 429 rate limit errors". """ try: from openspace.skill_engine.triggers import ManualTriggerRequest if not direction: return _json_error("direction is required — describe what to fix.") skill_path = Path(skill_dir) skill_md = skill_path / "SKILL.md" if not skill_md.exists(): return _json_error(f"SKILL.md not found in {skill_dir}") openspace = await _get_openspace() registry = openspace.get_skill_registry() if not registry: return _json_error("SkillRegistry not initialized") trigger_engine = openspace.get_trigger_engine() if not trigger_engine: return _json_error("Evolution TriggerEngine is not initialized") meta = registry.register_skill_dir(skill_path) if not meta: return _json_error(f"Failed to register skill from {skill_dir}") store = await _get_runtime_store() await store.sync_from_registry([meta]) _register_evidence_read_roots(openspace, skill_path) request = ManualTriggerRequest( action="fix", reason="manual_fix", skill_ids=(meta.skill_id,), metadata={ "skill_dir": str(skill_path), "direction": direction, "skill_name": meta.name, }, ) jobs = trigger_engine.from_manual_request(request) if not jobs: return _json_ok({ "status": "rejected", "error": "Manual fix request did not produce a TriggerJob.", "skill_id": meta.skill_id, }) outcomes = [] get_evolution_engine = getattr(openspace, "get_evolution_engine", None) evolution_engine = ( get_evolution_engine() if callable(get_evolution_engine) else None ) runtime = getattr(openspace, "runtime", None) drain_evolution_jobs = getattr(runtime, "drain_evolution_jobs", None) run_now_available = evolution_engine is not None and callable( drain_evolution_jobs ) if run_now_available: outcomes = await drain_evolution_jobs( job_ids=[job.job_id for job in jobs], limit=len(jobs), ) outcome_summaries = [_evolution_run_summary(outcome) for outcome in outcomes] fix_result = _manual_fix_result( outcomes, evolution_mode=getattr( getattr(runtime, "config", None), "evolution_mode", "autonomous", ), engine_available=run_now_available, ) return _json_ok({ **fix_result, "target_skill_id": meta.skill_id, "skill_id": fix_result.get("skill_id") or meta.skill_id, "skill_dir": str(skill_path), "jobs": [job.to_dict() for job in jobs], "outcomes": outcome_summaries, }) except Exception as e: logger.error(f"fix_skill failed: {e}", exc_info=True) return _json_error(e, status="error") def _manual_fix_result( outcomes: list[Any], *, evolution_mode: str, engine_available: bool, ) -> dict[str, Any]: if not engine_available: return { "status": "failed", "reason": "evolution_engine_unavailable", "retryable": True, } summaries = [_evolution_run_summary(outcome) for outcome in outcomes] for summary in summaries: committed_actions = [ action for action in summary.get("actions", []) if action.get("commit_status") in {"committed", "committed_reconciled"} ] if committed_actions or summary["evolved_skill_ids"]: action = committed_actions[0] if committed_actions else {} skill_id = ( action.get("skill_id") or (summary["evolved_skill_ids"][0] if summary["evolved_skill_ids"] else "") ) return { "status": "fixed", "action_id": action.get("action_id") or ( summary["action_ids"][0] if summary["action_ids"] else "" ), "skill_id": skill_id, "changed_files": action.get("changed_files", []), "upload_metadata_refs": action.get("upload_metadata_refs", []), "retryable": False, } rejected = _manual_fix_rejection(summaries) if rejected is not None: return rejected failed = _manual_fix_failure(summaries) if failed is not None: return failed mode = str(evolution_mode or "autonomous").strip().lower() if mode == "audit_only" and any(summary["admission_ids"] for summary in summaries): return { "status": "accepted_audit_only", "reason": "evolution_mode_audit_only", "decision_ids": [ item for summary in summaries for item in summary["decision_ids"] ], "admission_ids": [ item for summary in summaries for item in summary["admission_ids"] ], "retryable": False, } return { "status": "failed", "reason": "manual_fix_job_produced_no_committed_action", "retryable": True, } def _evolution_run_summary(outcome: Any) -> dict[str, Any]: decisions = list(getattr(outcome, "decisions", []) or []) admissions = list(getattr(outcome, "admissions", []) or []) candidates = list(getattr(outcome, "candidates", []) or []) actions = list(getattr(outcome, "actions", []) or []) evolved = list(getattr(outcome, "evolved_skill_records", []) or []) return { "job_id": str(getattr(outcome, "job_id", "") or ""), "status": str(getattr(outcome, "status", "") or ""), "decisions": [ { "decision_id": str(getattr(item, "decision_id", "") or ""), "proposed_action": str(getattr(item, "proposed_action", "") or ""), "candidate_policy": str(getattr(item, "candidate_policy", "") or ""), "noop_reason": str(getattr(item, "noop_reason", "") or ""), } for item in decisions ], "decision_ids": [ str(getattr(item, "decision_id", "") or "") for item in decisions if getattr(item, "decision_id", None) ], "admissions": [ { "admission_id": str(getattr(item, "admission_id", "") or ""), "outcome": str(getattr(item, "outcome", "") or ""), "hard_failures": [ str(value) for value in (getattr(item, "hard_failures", []) or []) ], "warnings": [ str(value) for value in (getattr(item, "warnings", []) or []) ], } for item in admissions ], "admission_ids": [ str(getattr(item, "admission_id", "") or "") for item in admissions if getattr(item, "admission_id", None) ], "candidate_ids": [ str(getattr(item, "candidate_id", "") or "") for item in candidates if getattr(item, "candidate_id", None) ], "actions": [ { "action_id": str(getattr(item, "action_id", "") or ""), "validation_id": str(getattr(item, "validation_id", "") or ""), "commit_status": str(getattr(item, "commit_status", "") or ""), "skill_id": str(getattr(item, "skill_id", "") or ""), "changed_files": [ str(value) for value in (getattr(item, "changed_files", []) or []) ], "failure_reason": str(getattr(item, "failure_reason", "") or ""), "upload_metadata_refs": [ str(value) for value in ( getattr(item, "upload_metadata_refs", []) or [] ) ], } for item in actions ], "action_ids": [ str(getattr(item, "action_id", "") or "") for item in actions if getattr(item, "action_id", None) ], "validation_ids": [ str(getattr(item, "validation_id", "") or "") for item in actions if getattr(item, "validation_id", None) ], "evolved_skill_ids": [ str(getattr(item, "skill_id", "") or "") for item in evolved if getattr(item, "skill_id", None) ], "errors": [str(item) for item in (getattr(outcome, "errors", []) or [])], } def _manual_fix_rejection(summaries: list[dict[str, Any]]) -> dict[str, Any] | None: rejected_admissions: list[dict[str, Any]] = [] for summary in summaries: for admission in summary.get("admissions", []): if admission.get("outcome") in { "reject", "rejected", "noop", "needs_human_review", "human_review", }: rejected_admissions.append(admission) if not rejected_admissions: return None reasons: list[str] = [] for admission in rejected_admissions: reasons.extend(admission.get("hard_failures", [])) reasons.extend(admission.get("warnings", [])) return { "status": "rejected", "reason": "; ".join(reason for reason in reasons if reason)[:1000] or "admission_or_validation_rejected", "admission_ids": [ item["admission_id"] for item in rejected_admissions if item.get("admission_id") ], "retryable": False, } def _manual_fix_failure(summaries: list[dict[str, Any]]) -> dict[str, Any] | None: errors: list[str] = [] retryable = False for summary in summaries: if str(summary.get("status", "")).startswith("failed"): errors.extend(summary.get("errors", [])) retryable = True for action in summary.get("actions", []): commit_status = str(action.get("commit_status") or "") if commit_status and commit_status not in { "committed", "committed_reconciled", }: errors.append(action.get("failure_reason") or commit_status) retryable = commit_status in {"failed", "failed_needs_review"} if not errors: return None return { "status": "failed", "reason": "; ".join(error for error in errors if error)[:1000] or "manual_fix_job_failed", "retryable": retryable, } async def _resolve_and_save_upload_placement( skill_path: Path, cloud_package_path: str, *, origin: str | None = None, mapping_store: Any | None = None, client: Any | None = None, ) -> dict[str, Any]: normalized_path = str(cloud_package_path or "").strip().strip("/") if not normalized_path: return { "status": "error", "code": "PACKAGE_PATH_REQUIRED", "message": "cloud_package_path is required for non-fix uploads", } if mapping_store is None: mapping_store = await _get_cloud_mapping_store() if client is None: client = _get_cloud_client(mapping_store=mapping_store) from openspace.cloud.package_placement import ( PackagePlacementError, PackagePlacementResolver, ) try: placement = PackagePlacementResolver( client, mapping_store=mapping_store, ).resolve_cloud_package_path(normalized_path) except PackagePlacementError as exc: return exc.to_payload() meta = await _read_upload_meta(skill_path) if not isinstance(meta, dict): meta = {} if origin is not None: meta["origin"] = _normalize_upload_origin(origin) else: meta.setdefault("origin", "imported") upload_placement = { "requested_package_id": placement.requested_package_id, "requested_parent_package_id": placement.requested_parent_package_id, "requested_new_package_segment": placement.requested_new_package_segment, "snapshot_version_used": placement.snapshot_version_used, "root_sub_domain_package_id": placement.root_sub_domain_package_id, "package_path": placement.package_path, } meta["upload_placement"] = upload_placement _save_upload_meta(skill_path, meta) return { "status": "success", "skill_dir": str(skill_path), "cloud_package_path": placement.package_path or normalized_path, "upload_placement": upload_placement, } async def _resolve_and_save_upload_placement_fields( skill_path: Path, *, requested_package_id: str | None = None, requested_parent_package_id: str | None = None, requested_new_package_segment: str | None = None, origin: str | None = None, mapping_store: Any | None = None, client: Any | None = None, ) -> dict[str, Any]: if mapping_store is None: mapping_store = await _get_cloud_mapping_store() if client is None: client = _get_cloud_client(mapping_store=mapping_store) from openspace.cloud.package_placement import ( PackagePlacementError, PackagePlacementResolver, ) try: placement = PackagePlacementResolver( client, mapping_store=mapping_store, ).validate_confirmed_placement( requested_package_id=str(requested_package_id or "").strip() or None, requested_parent_package_id=str(requested_parent_package_id or "").strip() or None, requested_new_package_segment=str(requested_new_package_segment or "").strip() or None, ) except PackagePlacementError as exc: return exc.to_payload() meta = await _read_upload_meta(skill_path) if not isinstance(meta, dict): meta = {} if origin is not None: meta["origin"] = _normalize_upload_origin(origin) else: meta.setdefault("origin", "imported") upload_placement = { "requested_package_id": placement.requested_package_id, "requested_parent_package_id": placement.requested_parent_package_id, "requested_new_package_segment": placement.requested_new_package_segment, "snapshot_version_used": placement.snapshot_version_used, "root_sub_domain_package_id": placement.root_sub_domain_package_id, "package_path": placement.package_path, } meta["upload_placement"] = upload_placement _save_upload_meta(skill_path, meta) return { "status": "success", "skill_dir": str(skill_path), "cloud_package_path": placement.package_path, "upload_placement": upload_placement, } async def prepare_upload_placement( skill_dir: str, cloud_package_path: str, origin: str | None = None, ) -> str: """Resolve a cloud package path and save confirmed upload placement. Internal compatibility helper. The agent-facing path is now ``upload_skill(cloud_package_path=...)``; this helper remains available to tests and direct Python callers. Args: skill_dir: Path to skill directory (must contain SKILL.md). cloud_package_path: Domain/sub-domain/regular package path. Existing regular packages are selected directly; a single missing child regular package can be prepared when the parent allows child creation. origin: Optional upload origin to persist alongside placement. """ try: skill_path = Path(skill_dir) if not (skill_path / "SKILL.md").exists(): return _json_error(f"SKILL.md not found in {skill_dir}") payload = await _resolve_and_save_upload_placement( skill_path, cloud_package_path, origin=origin, ) if payload.get("status") == "success": payload["next_action"] = "upload_skill" return _json_ok(payload) except Exception as e: logger.error(f"prepare_upload_placement failed: {e}", exc_info=True) return _json_error(e, status="error") async def _upload_cloud_tree_payload( *, client: Any, skill_path: Path, cloud_sub_domain_package_id: str | None = None, cloud_package_query: str | None = None, cloud_package_path_prefix: str | None = None, limit: int = 12, ) -> dict[str, Any]: subdomain_id = str(cloud_sub_domain_package_id or "").strip() max_items = min(max(int(limit), 1), 50) package_query = str(cloud_package_query or "").strip() package_prefix = str(cloud_package_path_prefix or "").strip().strip("/") domain_payload = await asyncio.to_thread(client.get_package_domain_index) snapshot_version = str(domain_payload.get("snapshot_version") or "") domain_nodes = [ _summarize_upload_package_node(node) for node in (domain_payload.get("nodes") or []) if isinstance(node, dict) ] domain_node_matches = _filter_upload_package_nodes( domain_nodes, query=package_query, prefix=package_prefix, limit=max_items, ) sub_domain_matches = _filter_upload_package_nodes( [node for node in domain_nodes if node.get("package_kind") == "sub-domain"], query=package_query, prefix=package_prefix, limit=max_items, ) payload: dict[str, Any] = { "status": "needs_cloud_package_path", "code": "CLOUD_PACKAGE_PATH_REQUIRED", "message": ( "Inspect the cloud package tree, then call upload_skill with " "cloud_package_id for an existing regular package, or " "cloud_parent_package_id plus cloud_new_package_segment to create " "one new child regular package. cloud_package_path is also accepted " "when the server returns a non-empty package path." ), "policy_decision_required": True, "skill_dir": str(skill_path), "snapshot_version": snapshot_version, "query": package_query, "path_prefix": package_prefix, "interaction_flow": _upload_cloud_package_interaction_flow(), "cloud_package_path_policy": _upload_cloud_package_path_policy(), "domain_index": { "snapshot_version": snapshot_version, "total_node_count": len(domain_nodes), "nodes": domain_node_matches["items"], "nodes_truncated": domain_node_matches["truncated"], "sub_domain_nodes": sub_domain_matches["items"], "sub_domain_count": sub_domain_matches["match_count"], "sub_domain_nodes_truncated": sub_domain_matches["truncated"], }, "next_actions": [ { "tool": "upload_skill", "reason": "Inspect a selected sub-domain upload subtree before choosing cloud_package_path.", "required_fields": ["skill_dir", "cloud_sub_domain_package_id"], "optional_fields": ["cloud_package_query", "cloud_package_path_prefix", "cloud_package_limit"], }, { "tool": "upload_skill", "reason": "Upload after choosing a regular package id from the upload tree.", "required_fields": ["skill_dir", "cloud_package_id"], }, { "tool": "upload_skill", "reason": "Upload while creating one new regular child package under a chosen parent.", "required_fields": ["skill_dir", "cloud_parent_package_id", "cloud_new_package_segment"], }, ], } if not subdomain_id: return payload subtree = await asyncio.to_thread( client.get_package_subtree_for_upload, subdomain_id, snapshot_version=snapshot_version or None, ) subtree_nodes = [ _summarize_upload_package_node(node) for node in (subtree.get("nodes") or []) if isinstance(node, dict) ] selectable = _filter_upload_package_nodes( [node for node in subtree_nodes if node.get("can_select_as_upload_target")], query=package_query, prefix=package_prefix, limit=max_items, ) creatable_parent_nodes = [ node for node in subtree_nodes if _can_create_upload_child(node) ] creatable = _filter_upload_package_nodes( creatable_parent_nodes, query=package_query, prefix=package_prefix, limit=max_items, ) child_view = _upload_package_children( subtree_nodes, prefix=package_prefix or str(subtree.get("root_package_path") or ""), limit=max_items, ) new_child_examples = _upload_new_child_path_examples( creatable["items"], limit=min(max_items, 5), ) payload["subtree"] = { "root_sub_domain_package_id": ( subtree.get("root_sub_domain_package_id") or subtree.get("root_package_id") or subdomain_id ), "root_package_path": subtree.get("root_package_path", ""), "snapshot_version": subtree.get("snapshot_version", snapshot_version), "total_node_count": len(subtree_nodes), "children": child_view["items"], "child_count": child_view["match_count"], "children_truncated": child_view["truncated"], "selectable_regular_packages": selectable["items"], "selectable_regular_package_count": selectable["match_count"], "selectable_regular_packages_truncated": selectable["truncated"], "creatable_parent_packages": creatable["items"], "creatable_parent_package_count": creatable["match_count"], "creatable_parent_packages_truncated": creatable["truncated"], "new_child_path_examples": new_child_examples, } payload["next_actions"] = [ { "tool": "upload_skill", "reason": "Upload after choosing an existing regular package id, or creating one new child under a creatable parent.", "accepted_forms": [ { "required_fields": ["skill_dir", "cloud_package_id"], "source": "subtree.selectable_regular_packages[].package_id", }, { "required_fields": ["skill_dir", "cloud_parent_package_id", "cloud_new_package_segment"], "source": "subtree.creatable_parent_packages[].package_id plus one new segment", }, { "required_fields": ["skill_dir", "cloud_package_path"], "source": "only when subtree package_path is non-empty", }, ], } ] return payload def _upload_cloud_package_interaction_flow() -> list[dict[str, Any]]: return [ { "step": "browse_domain_index", "call": "upload_skill(skill_dir=..., cloud_package_query=... optional)", "agent_decision": "Choose a cloud_sub_domain_package_id to inspect.", }, { "step": "browse_upload_subtree", "call": "upload_skill(skill_dir=..., cloud_sub_domain_package_id=..., cloud_package_path_prefix=... optional)", "agent_decision": ( "Choose an existing selectable_regular_packages[].package_id, " "or choose creatable_parent_packages[].package_id plus one new regular segment. " "Use package_path only when it is non-empty." ), }, { "step": "upload", "call": "upload_skill(skill_dir=..., visibility=..., cloud_package_id=...) or upload_skill(skill_dir=..., visibility=..., cloud_parent_package_id=..., cloud_new_package_segment=...)", "result": ( "OpenSpace saves UUID placement fields in .upload_meta.json, " "revalidates them against the current cloud tree, then uploads." ), }, ] def _upload_cloud_package_path_policy() -> dict[str, Any]: return { "cloud_package_path_is_agent_selected": True, "code_does_not_choose_semantic_destination": True, "allowed_forms": [ { "form": "existing_regular_package_id", "source": "subtree.selectable_regular_packages[].package_id", "upload_fields": ["requested_package_id"], }, { "form": "new_child_regular_package_id_plus_segment", "source": "subtree.creatable_parent_packages[].package_id + one_new_regular_package_segment", "upload_fields": [ "requested_parent_package_id", "requested_new_package_segment", ], }, { "form": "existing_or_new_child_path", "source": "cloud_package_path when the server exposes a non-empty package_path", "upload_fields": ["requested_package_id or requested_parent_package_id/requested_new_package_segment"], }, ], "new_path_creation": { "allowed": True, "max_missing_segments": 1, "new_segment_kind": "regular package", "parent_requirement": ( "Parent must be a sub-domain or regular package that does not " "explicitly disable child regular package creation." ), "segment_must_be_single_path_part": True, }, "not_allowed": [ "Uploading directly to a domain or sub-domain path.", "Creating multiple missing path segments in one upload.", "Passing only a raw path without letting upload_skill resolve and revalidate placement.", ], } def _can_create_upload_child(node: dict[str, Any]) -> bool: kind = str(node.get("package_kind") or "") if kind not in {"sub-domain", "regular"}: return False return node.get("can_create_child_regular_package") is not False def _upload_new_child_path_examples( parent_nodes: list[dict[str, Any]], *, limit: int, ) -> list[dict[str, Any]]: examples: list[dict[str, Any]] = [] for node in parent_nodes: parent_path = str(node.get("package_path") or "").strip().strip("/") if not parent_path: continue examples.append({ "parent_package_id": node.get("package_id", ""), "parent_package_path": parent_path, "cloud_package_path_example": ( f"{parent_path}/" ), }) if len(examples) >= limit: break return examples def _summarize_upload_package_node(node: dict[str, Any]) -> dict[str, Any]: return { "package_id": node.get("package_id", ""), "package_path": node.get("package_path", ""), "package_kind": node.get("package_kind", ""), "parent_package_id": node.get("parent_package_id"), "root_sub_domain_package_id": node.get("root_sub_domain_package_id"), "can_select_as_upload_target": bool(node.get("can_select_as_upload_target")), "can_create_child_regular_package": node.get("can_create_child_regular_package"), "select_disabled_reason": node.get("select_disabled_reason"), "child_count": node.get("child_count"), } def _filter_upload_package_nodes( nodes: list[dict[str, Any]], *, query: str, prefix: str, limit: int, ) -> dict[str, Any]: q_tokens = [token for token in str(query or "").lower().split() if token] normalized_prefix = str(prefix or "").strip().strip("/").lower() matches: list[dict[str, Any]] = [] for node in nodes: package_path = str(node.get("package_path") or "") if normalized_prefix and not package_path.lower().startswith(normalized_prefix): continue if q_tokens: text = "\n".join( str(node.get(key) or "") for key in ( "package_id", "package_path", "package_kind", "select_disabled_reason", ) ).lower() if not all(token in text for token in q_tokens): continue matches.append(node) return { "items": matches[:limit], "match_count": len(matches), "truncated": len(matches) > limit, } def _upload_package_children( nodes: list[dict[str, Any]], *, prefix: str, limit: int, ) -> dict[str, Any]: prefix_parts = [ part.strip().lower() for part in str(prefix or "").replace("\\", "/").split("/") if part.strip() ] children: dict[str, dict[str, Any]] = {} for node in nodes: raw_path = str(node.get("package_path") or "") path_parts = [part.strip() for part in raw_path.replace("\\", "/").split("/") if part.strip()] lowered = [part.lower() for part in path_parts] if prefix_parts: if len(lowered) <= len(prefix_parts) or lowered[: len(prefix_parts)] != prefix_parts: continue child_parts = path_parts[: len(prefix_parts) + 1] else: if not path_parts: continue child_parts = path_parts[:1] child_path = "/".join(child_parts) existing = children.get(child_path) if existing is None: existing = { "package_path": child_path, "package_kind": "", "package_id": "", "subtree_package_count": 0, "selectable_regular_package_count": 0, "can_create_child_regular_package": False, } children[child_path] = existing existing["subtree_package_count"] += 1 if node.get("can_select_as_upload_target"): existing["selectable_regular_package_count"] += 1 if node.get("can_create_child_regular_package"): existing["can_create_child_regular_package"] = True if lowered == [part.lower() for part in child_parts]: existing["package_id"] = node.get("package_id", "") existing["package_kind"] = node.get("package_kind", "") existing["can_select_as_upload_target"] = bool(node.get("can_select_as_upload_target")) existing["select_disabled_reason"] = node.get("select_disabled_reason") rows = sorted(children.values(), key=lambda item: str(item.get("package_path") or "")) return { "items": rows[:limit], "match_count": len(rows), "truncated": len(rows) > limit, } @mcp.tool() async def upload_skill( skill_dir: str, visibility: str = "private", cloud_package_path: str | None = None, cloud_package_id: str | None = None, cloud_parent_package_id: str | None = None, cloud_new_package_segment: str | None = None, cloud_sub_domain_package_id: str | None = None, cloud_package_query: str | None = None, cloud_package_path_prefix: str | None = None, cloud_package_limit: int = 12, origin: str | None = None, parent_local_skill_ids: list[str] | None = None, owner_agent_id: str | None = None, submitted_skill_id: str | None = None, content_diff: str | None = None, ) -> str: """Upload a trusted local skill to the cloud. Public and private uploads both fail closed unless ``skill_dir`` resolves to a matching trusted record in the active local SkillStore. Trust remains local metadata and is not sent in the cloud upload request. For evolved skills from validated evolution actions, lineage metadata is **pre-saved** in ``.upload_meta.json``. The bot provides: - ``skill_dir`` — path to the skill directory - ``visibility`` — "private" by default, or "public" when explicitly sharing - package placement for non-fix uploads without pre-saved placement For non-fix uploads, this tool is also the agent-facing cloud package browser. Calling it without confirmed placement returns a bounded step-by-step picker payload; calling it with ``cloud_sub_domain_package_id`` expands one upload subtree. After the agent chooses an existing regular package id, or chooses an eligible parent package id plus one new child segment, the tool saves UUID placement fields in ``.upload_meta.json`` and revalidates the placement immediately before upload. ``cloud_package_path`` is also accepted when the cloud returns a non-empty path. **origin + parent_local_skill_ids constraints**: - imported / captured → parent_local_skill_ids must be empty - derived → at least 1 parent with cloud binding - fixed → exactly 1 parent with cloud binding Args: skill_dir: Path to skill directory (must contain SKILL.md). visibility: "public" or "private". Defaults to "private"; choose "public" only when explicitly sharing. cloud_package_path: Agent-selected existing regular package path, or one new child regular package segment under an eligible parent. Required for non-fix uploads unless upload_placement already exists in .upload_meta.json. cloud_package_id: Agent-selected existing regular package id from selectable_regular_packages[].package_id. cloud_parent_package_id: Agent-selected parent package id from creatable_parent_packages[].package_id when creating one new regular child package. cloud_new_package_segment: New child regular package segment to create under cloud_parent_package_id. cloud_sub_domain_package_id: Optional sub-domain package id. If provided without confirmed placement, this returns the upload subtree for the agent to inspect. cloud_package_query: Optional filter when browsing cloud package choices. cloud_package_path_prefix: Optional cloud package path prefix to expand/filter. cloud_package_limit: Maximum cloud package candidates returned while browsing. origin: Override origin. Default: from .upload_meta.json or "imported". parent_local_skill_ids: Override local parents. Default: from .upload_meta.json/SkillStore. """ try: from openspace.cloud.local_mapping import ( UnboundLocalSkillError, ensure_local_skill_id, read_local_skill_id, write_local_skill_id, ) skill_path = Path(skill_dir) if not (skill_path / "SKILL.md").exists(): return _json_error(f"SKILL.md not found in {skill_dir}") from openspace.cloud.upload_trust import ( SkillUploadTrustError, require_trusted_skill_for_upload, ) runtime_store = await _get_runtime_store(required=False) try: require_trusted_skill_for_upload( skill_path, skill_store=runtime_store, ) except SkillUploadTrustError as exc: return _json_ok(exc.to_payload()) # Read pre-saved metadata (written after validated evolution commits) meta = await _read_upload_meta(skill_path) # Merge: explicit params override pre-saved values final_origin = origin if origin is not None else meta.get("origin", "imported") final_parent_local_ids = ( parent_local_skill_ids if parent_local_skill_ids is not None else meta.get("parent_local_skill_ids", []) ) final_parent_local_ids = [sid for sid in (final_parent_local_ids or []) if sid] final_owner_agent_id = owner_agent_id if owner_agent_id is not None else meta.get("owner_agent_id") existing_local_skill_id = read_local_skill_id(skill_path) final_submitted_skill_id = submitted_skill_id or meta.get("local_skill_id") if final_submitted_skill_id and existing_local_skill_id and final_submitted_skill_id != existing_local_skill_id: return _json_ok({ "status": "error", "code": "LOCAL_SKILL_ID_MISMATCH", "message": "submitted_skill_id must match the local .skill_id", "submitted_skill_id": final_submitted_skill_id, "local_skill_id": existing_local_skill_id, }) if final_submitted_skill_id and not existing_local_skill_id: write_local_skill_id(skill_path, final_submitted_skill_id) local_skill_id = final_submitted_skill_id or ensure_local_skill_id(skill_path) final_content_diff = content_diff if content_diff is not None else meta.get("content_diff") mapping_store = await _get_cloud_mapping_store() origin_type = _normalize_upload_origin(final_origin) if origin_type in ("fix", "derive"): try: final_parent_cloud_ids = mapping_store.resolve_parent_local_ids_to_cloud_ids( final_parent_local_ids ) except UnboundLocalSkillError as exc: return _json_ok(exc.to_payload()) else: if final_parent_local_ids: return _json_ok({ "status": "error", "code": "PARENT_LOCAL_IDS_NOT_ALLOWED", "message": "imported/capture uploads must not include parent local skill IDs", "parent_local_skill_ids": final_parent_local_ids, }) final_parent_cloud_ids = [] client = _get_cloud_client(mapping_store=mapping_store) id_placement_selected = bool( str(cloud_package_id or "").strip() or str(cloud_parent_package_id or "").strip() or str(cloud_new_package_segment or "").strip() ) if cloud_package_path and id_placement_selected: return _json_ok({ "status": "error", "code": "PACKAGE_PLACEMENT_CONFLICTING_FIELDS", "message": ( "Use either cloud_package_path, cloud_package_id, or " "cloud_parent_package_id plus cloud_new_package_segment." ), }) if id_placement_selected: placement_payload = await _resolve_and_save_upload_placement_fields( skill_path, requested_package_id=cloud_package_id, requested_parent_package_id=cloud_parent_package_id, requested_new_package_segment=cloud_new_package_segment, origin=origin, mapping_store=mapping_store, client=client, ) if placement_payload.get("status") != "success": return _json_ok(placement_payload) meta = await _read_upload_meta(skill_path) if cloud_package_path: placement_payload = await _resolve_and_save_upload_placement( skill_path, cloud_package_path, origin=origin, mapping_store=mapping_store, client=client, ) if placement_payload.get("status") != "success": return _json_ok(placement_payload) meta = await _read_upload_meta(skill_path) placement_kwargs: dict[str, str | None] = { "requested_package_id": None, "requested_parent_package_id": None, "requested_new_package_segment": None, "snapshot_version_used": None, } if origin_type != "fix" or isinstance(meta.get("upload_placement"), dict): from openspace.cloud.package_placement import ( PackagePlacementError, PackagePlacementResolver, placement_from_upload_meta, ) raw_placement = placement_from_upload_meta(meta) if not raw_placement: return _json_ok( await _upload_cloud_tree_payload( client=client, skill_path=skill_path, cloud_sub_domain_package_id=cloud_sub_domain_package_id, cloud_package_query=cloud_package_query, cloud_package_path_prefix=cloud_package_path_prefix, limit=cloud_package_limit, ) ) try: resolved_placement = PackagePlacementResolver( client, mapping_store=mapping_store, ).validate_confirmed_placement(**raw_placement) except PackagePlacementError as exc: return _json_ok(exc.to_payload()) placement_kwargs = resolved_placement.to_upload_kwargs() result = await asyncio.to_thread( client.upload_skill_v2, skill_path, local_skill_store_db_path=getattr(runtime_store, "db_path", None), visibility=visibility, origin=final_origin, parent_cloud_skill_ids=final_parent_cloud_ids, **placement_kwargs, owner_agent_id=final_owner_agent_id, submitted_skill_id=local_skill_id, content_diff=final_content_diff, ) result["local_skill_id"] = local_skill_id if final_parent_local_ids: result["parent_local_skill_ids"] = final_parent_local_ids if final_parent_cloud_ids: result["parent_cloud_skill_ids"] = final_parent_cloud_ids return _json_ok(result) except Exception as e: logger.error(f"upload_skill failed: {e}", exc_info=True) return _json_error(e, status="error") def run_mcp_server() -> None: """Console-script entry point for ``openspace-mcp``.""" import argparse def _port_flag_was_set(argv: list[str]) -> bool: return any(arg == "--port" or arg.startswith("--port=") for arg in argv) def _default_port_for_transport(transport: str) -> int: return 8081 if transport == "streamable-http" else 8080 def _parse_port_from_env(default: int) -> int: raw_port = os.environ.get("OPENSPACE_MCP_PORT", "").strip() if not raw_port: return default try: return int(raw_port) except ValueError: logger.warning( "Ignoring invalid OPENSPACE_MCP_PORT=%r; falling back to %d.", raw_port, default, ) return default def _parse_host_from_env(default: str = "127.0.0.1") -> str: return os.environ.get("OPENSPACE_MCP_HOST", "").strip() or default def _resolve_transport(requested_transport: str, argv: list[str]) -> str: if requested_transport in ("stdio", "sse", "streamable-http"): return requested_transport env_transport = os.environ.get("OPENSPACE_MCP_TRANSPORT", "").strip().lower() if env_transport: if env_transport in ("stdio", "sse", "streamable-http"): return env_transport logger.warning( "Ignoring invalid OPENSPACE_MCP_TRANSPORT=%r; expected 'stdio', 'sse', or 'streamable-http'.", env_transport, ) # Treat an explicit port override as an HTTP/SSE intent. This keeps the # CLI behavior aligned with the usage examples above. if _port_flag_was_set(argv): return "sse" stdin_is_tty = hasattr(sys.stdin, "isatty") and sys.stdin.isatty() stdout_is_tty = _real_stdout.isatty() return "sse" if stdin_is_tty and stdout_is_tty else "stdio" argv = sys.argv[1:] parser = argparse.ArgumentParser(description="OpenSpace MCP Server") parser.add_argument( "--transport", choices=["auto", "stdio", "sse", "streamable-http"], default="auto", ) parser.add_argument("--host", default=_parse_host_from_env()) parser.add_argument("--port", type=int, default=None) args = parser.parse_args(argv) transport = _resolve_transport(args.transport, argv) port = args.port if port is None: port = _parse_port_from_env(_default_port_for_transport(transport)) if transport == "sse": mcp.settings.host = args.host mcp.settings.port = port logger.info("Starting OpenSpace MCP server with SSE transport on port %s", port) mcp.run(transport="sse") elif transport == "streamable-http": mcp.settings.host = args.host mcp.settings.port = port logger.info( "Starting OpenSpace MCP server with streamable HTTP transport on %s:%s", args.host, port, ) mcp.run(transport="streamable-http") else: logger.info("Starting OpenSpace MCP server with stdio transport") mcp.run(transport="stdio") if __name__ == "__main__": run_mcp_server()