OpenSpace/openspace/entrypoints/mcp/server.py
2026-07-17 11:43:42 +08:00

2787 lines
106 KiB
Python

"""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}/<one-new-local-package-segment>",
})
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}/<one-new-regular-package-segment>"
),
})
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()