feat(skill-catalog-subscription): synced catalog + HTTP sync + MCP tools
Consumes the external Subscription Platform as source of truth for skill content; reuses skills_learning domain models (extended with KnowledgeSkill) and workflow.skill_exec resolver. HTTP/MCP deps land in api/ per CONSTITUTION.md; synced skills use a physically separate SQLite file (tasks/skills.sqlite3) to preserve the skill-authoring capability boundary. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -92,7 +92,12 @@ def tool_handlers(
|
||||
}
|
||||
|
||||
|
||||
def create_mcp_server(*, manager: DeviceManager | None = None) -> Any:
|
||||
def create_mcp_server(
|
||||
*,
|
||||
manager: DeviceManager | None = None,
|
||||
skill_catalog_store: Any | None = None,
|
||||
skill_active_subscriptions: set[str] | None = None,
|
||||
) -> Any:
|
||||
try:
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
except ImportError as exc:
|
||||
@@ -159,4 +164,14 @@ def create_mcp_server(*, manager: DeviceManager | None = None) -> Any:
|
||||
def _device_status(device_id: str) -> dict[str, Any]:
|
||||
return handlers["device_status"](device_id=device_id)
|
||||
|
||||
if skill_catalog_store is not None:
|
||||
from api.skill_catalog_mcp import register_skill_catalog_tools
|
||||
|
||||
register_skill_catalog_tools(
|
||||
server,
|
||||
store=skill_catalog_store,
|
||||
get_active_subscriptions=lambda: set(skill_active_subscriptions or set()),
|
||||
get_registered_tools=lambda: set(handlers.keys()),
|
||||
)
|
||||
|
||||
return server
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""MCP tool surface for the Skill Catalog.
|
||||
|
||||
API layer per CONSTITUTION.md: MCP dependencies (FastMCP) live here.
|
||||
Reads from :mod:`storage.skill_catalog`; flow-template parameter resolution
|
||||
delegates to the existing :func:`workflow.skill_exec.resolve_skill_steps`.
|
||||
No server-side execution primitive is exposed — flow templates are resolved
|
||||
here but executed step-by-step by the LLM via the existing device-capability
|
||||
tools (design D5).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from skills_learning.models import (
|
||||
FlowTemplateSkill,
|
||||
KnowledgeSkill,
|
||||
SkillMetadata,
|
||||
)
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
from workflow.skill_exec import SkillExecutionError, resolve_skill_steps
|
||||
|
||||
# Tool names registered by this module. Used by tests and by callers that
|
||||
# need to assert the full registered set (e.g., no batch-execute tool).
|
||||
SKILL_TOOL_NAMES = (
|
||||
"list_skills",
|
||||
"search_skills",
|
||||
"get_skill",
|
||||
"resolve_flow_template",
|
||||
)
|
||||
|
||||
|
||||
class SkillCatalogError(Exception):
|
||||
"""Base for skill MCP semantic errors."""
|
||||
|
||||
|
||||
class SkillNotFoundError(SkillCatalogError):
|
||||
"""Raised when a skill id is unknown or not visible to the caller.
|
||||
|
||||
Both cases raise the same error to avoid leaking existence (design D4,
|
||||
task 2.4 indistinguishability contract).
|
||||
"""
|
||||
|
||||
|
||||
class InvalidFlowTemplateError(SkillCatalogError):
|
||||
"""Raised when a skill exists but cannot be returned as a flow template
|
||||
(e.g., it's a knowledge skill, or a step references an unknown tool)."""
|
||||
|
||||
|
||||
class MissingParameterError(SkillCatalogError):
|
||||
"""Raised when required flow-template parameters are missing/invalid."""
|
||||
|
||||
|
||||
def skill_tool_handlers(
|
||||
*,
|
||||
store: SkillCatalogStore,
|
||||
get_active_subscriptions: Callable[[], set[str]],
|
||||
get_registered_tools: Callable[[], set[str]] | None = None,
|
||||
) -> dict[str, Callable[..., dict[str, Any]]]:
|
||||
"""Return a dict of MCP tool handler functions, keyed by tool name.
|
||||
|
||||
Decoupled from FastMCP so handlers can be tested directly without
|
||||
standing up a server (mirrors :func:`api.mcp.tool_handlers`).
|
||||
"""
|
||||
tools_getter = get_registered_tools or (lambda: set())
|
||||
|
||||
def _list_skills() -> dict[str, Any]:
|
||||
metas = store.list_skills(get_active_subscriptions())
|
||||
return {
|
||||
"ok": True,
|
||||
"skills": [_metadata_to_summary(m) for m in metas],
|
||||
}
|
||||
|
||||
def _search_skills(query: str) -> dict[str, Any]:
|
||||
metas = store.search_skills(query, get_active_subscriptions())
|
||||
return {
|
||||
"ok": True,
|
||||
"skills": [_metadata_to_summary(m) for m in metas],
|
||||
}
|
||||
|
||||
def _get_skill(skill_id: str) -> dict[str, Any]:
|
||||
skill = store.get_skill(
|
||||
skill_id,
|
||||
get_active_subscriptions(),
|
||||
registered_tools=tools_getter(),
|
||||
)
|
||||
if skill is None:
|
||||
return _error_response(SkillNotFoundError(skill_id))
|
||||
return {"ok": True, "skill": _skill_to_full_dict(skill)}
|
||||
|
||||
def _resolve_flow_template(
|
||||
skill_id: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
params = params or {}
|
||||
skill = store.get_skill(
|
||||
skill_id,
|
||||
get_active_subscriptions(),
|
||||
registered_tools=tools_getter(),
|
||||
)
|
||||
if skill is None:
|
||||
return _error_response(SkillNotFoundError(skill_id))
|
||||
if not isinstance(skill, FlowTemplateSkill):
|
||||
return _error_response(
|
||||
InvalidFlowTemplateError(
|
||||
f"skill {skill_id} is not a flow_template (kind={skill.metadata.kind})"
|
||||
)
|
||||
)
|
||||
try:
|
||||
steps = resolve_skill_steps(skill, params)
|
||||
except SkillExecutionError as exc:
|
||||
return _error_response(MissingParameterError(str(exc)))
|
||||
return {"ok": True, "steps": steps}
|
||||
|
||||
return {
|
||||
"list_skills": _list_skills,
|
||||
"search_skills": _search_skills,
|
||||
"get_skill": _get_skill,
|
||||
"resolve_flow_template": _resolve_flow_template,
|
||||
}
|
||||
|
||||
|
||||
def register_skill_catalog_tools(
|
||||
server: Any,
|
||||
*,
|
||||
store: SkillCatalogStore,
|
||||
get_active_subscriptions: Callable[[], set[str]],
|
||||
get_registered_tools: Callable[[], set[str]] | None = None,
|
||||
) -> Any:
|
||||
"""Register ``list_skills``/``search_skills``/``get_skill``/
|
||||
``resolve_flow_template`` as MCP tools on ``server``.
|
||||
|
||||
Returns the server so the caller can chain. No batch-execute tool is
|
||||
registered (design D5): the LLM issues each resulting device-capability
|
||||
tool call itself, preserving the Observe-Think-Act loop.
|
||||
"""
|
||||
handlers = skill_tool_handlers(
|
||||
store=store,
|
||||
get_active_subscriptions=get_active_subscriptions,
|
||||
get_registered_tools=get_registered_tools,
|
||||
)
|
||||
|
||||
@server.tool(name="list_skills")
|
||||
def _list_skills() -> dict[str, Any]:
|
||||
return handlers["list_skills"]()
|
||||
|
||||
@server.tool(name="search_skills")
|
||||
def _search_skills(query: str) -> dict[str, Any]:
|
||||
return handlers["search_skills"](query=query)
|
||||
|
||||
@server.tool(name="get_skill")
|
||||
def _get_skill(skill_id: str) -> dict[str, Any]:
|
||||
return handlers["get_skill"](skill_id=skill_id)
|
||||
|
||||
@server.tool(name="resolve_flow_template")
|
||||
def _resolve_flow_template(
|
||||
skill_id: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return handlers["resolve_flow_template"](skill_id=skill_id, params=params)
|
||||
|
||||
return server
|
||||
|
||||
|
||||
def _metadata_to_summary(meta: SkillMetadata) -> dict[str, Any]:
|
||||
"""Compact metadata for list/search — no Subscription-Platform-specific fields."""
|
||||
return {
|
||||
"id": meta.id,
|
||||
"name": meta.name,
|
||||
"description": meta.description,
|
||||
"kind": meta.kind,
|
||||
"tags": list(meta.tags),
|
||||
}
|
||||
|
||||
|
||||
def _skill_to_full_dict(skill: Any) -> dict[str, Any]:
|
||||
"""Full skill payload for ``get_skill``."""
|
||||
base = _metadata_to_summary(skill.metadata)
|
||||
base["version"] = skill.metadata.version
|
||||
base["updated_at"] = skill.metadata.updated_at.isoformat()
|
||||
if isinstance(skill, KnowledgeSkill):
|
||||
base["content"] = skill.content
|
||||
elif isinstance(skill, FlowTemplateSkill):
|
||||
base["steps"] = [step.to_dict() for step in skill.steps]
|
||||
base["parameters"] = {
|
||||
name: dict(schema) for name, schema in skill.parameters.items()
|
||||
}
|
||||
return base
|
||||
|
||||
|
||||
def _error_response(exc: Exception) -> dict[str, Any]:
|
||||
return {"ok": False, "error": _semantic_skill_error(exc)}
|
||||
|
||||
|
||||
def _semantic_skill_error(exc: Exception) -> str:
|
||||
if isinstance(exc, SkillNotFoundError):
|
||||
return "skill not found"
|
||||
if isinstance(exc, InvalidFlowTemplateError):
|
||||
return "skill unavailable"
|
||||
if isinstance(exc, MissingParameterError):
|
||||
message = str(exc)
|
||||
return f"missing parameter: {message}" if message else "invalid parameter"
|
||||
return "operation failed"
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Skill catalog sync with the external Subscription Platform.
|
||||
|
||||
API layer per CONSTITUTION.md: HTTP dependencies (``httpx``) live here, and
|
||||
this module is the sole caller of :mod:`storage.skill_catalog`'s private
|
||||
``_apply_sync_*`` methods — the "Subscription Platform is sole source of
|
||||
truth" contract is enforced by that import boundary.
|
||||
|
||||
Push (webhook) is optional; the baseline pull loop is correct standalone.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
import httpx
|
||||
|
||||
from skills_learning.models import (
|
||||
FlowTemplateSkill,
|
||||
KnowledgeSkill,
|
||||
Skill,
|
||||
)
|
||||
from storage.skill_catalog import SkillCatalogStore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionClient(Protocol):
|
||||
"""Contract for talking to the Subscription Platform.
|
||||
|
||||
Concrete implementations may use HTTP, an in-process mock, or any other
|
||||
transport — :class:`SkillSyncRunner` only depends on this Protocol, so
|
||||
the real Subscription Platform API can be wired in by adjusting only the
|
||||
concrete client class (see design.md Open Questions).
|
||||
"""
|
||||
|
||||
def fetch_entitled_skills(
|
||||
self,
|
||||
subscription_id: str,
|
||||
since_version: int | None = None,
|
||||
) -> "SyncDelta":
|
||||
"""Return the skills entitled to ``subscription_id``.
|
||||
|
||||
If ``since_version`` is given, the implementation MAY return only
|
||||
skills changed since that version (incremental). If omitted or if
|
||||
the implementation does not support incremental fetch, returns the
|
||||
full entitled set (the runner treats ``is_full_replace`` as truthy).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncDelta:
|
||||
"""Result of a single ``fetch_entitled_skills`` call."""
|
||||
|
||||
skills: list[Skill]
|
||||
removed_ids: list[str] = field(default_factory=list)
|
||||
latest_version: int | None = None
|
||||
is_full_replace: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncOutcome:
|
||||
"""Per-subscription result of one ``SkillSyncRunner.tick()`` pass."""
|
||||
|
||||
success: bool
|
||||
error: str | None
|
||||
fetched: int
|
||||
|
||||
|
||||
class HttpSubscriptionClient:
|
||||
"""Concrete HTTP implementation of :class:`SubscriptionClient`.
|
||||
|
||||
The request/response shape is shaped to an assumed Subscription Platform
|
||||
REST API; once the real API is finalized, only this class needs to
|
||||
change (see design.md D3 and Open Questions).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
auth_token: str | None = None,
|
||||
*,
|
||||
timeout: float = 30.0,
|
||||
client: httpx.Client | None = None,
|
||||
) -> None:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.auth_token = auth_token
|
||||
self.timeout = timeout
|
||||
self._client = client
|
||||
|
||||
def fetch_entitled_skills(
|
||||
self,
|
||||
subscription_id: str,
|
||||
since_version: int | None = None,
|
||||
) -> SyncDelta:
|
||||
url = f"{self.base_url}/subscriptions/{subscription_id}/skills"
|
||||
params: dict[str, Any] = {}
|
||||
if since_version is not None:
|
||||
params["since_version"] = str(since_version)
|
||||
headers: dict[str, str] = {}
|
||||
if self.auth_token:
|
||||
headers["Authorization"] = f"Bearer {self.auth_token}"
|
||||
|
||||
response = self._ensure_client().get(
|
||||
url,
|
||||
params=params or None,
|
||||
headers=headers or None,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return _parse_sync_payload(response.json())
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def _ensure_client(self) -> httpx.Client:
|
||||
if self._client is None:
|
||||
self._client = httpx.Client()
|
||||
return self._client
|
||||
|
||||
|
||||
def _parse_sync_payload(payload: dict[str, Any]) -> SyncDelta:
|
||||
"""Parse the assumed Subscription Platform response into a SyncDelta."""
|
||||
skills: list[Skill] = []
|
||||
for item in payload.get("skills") or []:
|
||||
kind = item.get("kind", "knowledge")
|
||||
if kind == "flow_template":
|
||||
skills.append(FlowTemplateSkill.from_dict(item))
|
||||
else:
|
||||
skills.append(KnowledgeSkill.from_dict(item))
|
||||
removed_ids = [str(rid) for rid in payload.get("removed_ids") or []]
|
||||
latest_raw = payload.get("latest_version")
|
||||
latest_version = int(latest_raw) if latest_raw is not None else None
|
||||
is_full_replace = bool(payload.get("is_full_replace", True))
|
||||
return SyncDelta(
|
||||
skills=skills,
|
||||
removed_ids=removed_ids,
|
||||
latest_version=latest_version,
|
||||
is_full_replace=is_full_replace,
|
||||
)
|
||||
|
||||
|
||||
class SkillSyncRunner:
|
||||
"""Drives periodic sync between the Subscription Platform and local catalog.
|
||||
|
||||
Push (webhook) is optional: poll-only operation is the baseline. The
|
||||
runner does not auto-start any thread on import — call
|
||||
:meth:`start_background` to begin polling, or :meth:`tick` for manual
|
||||
control (used by tests and by the webhook trigger).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
store: SkillCatalogStore,
|
||||
client: SubscriptionClient,
|
||||
subscriptions: list[str],
|
||||
poll_interval: float = 300.0,
|
||||
) -> None:
|
||||
self.store = store
|
||||
self.client = client
|
||||
self.subscriptions = list(subscriptions)
|
||||
self.poll_interval = poll_interval
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._tick_lock = threading.Lock()
|
||||
for sub_id in self.subscriptions:
|
||||
self.store._register_subscription(sub_id)
|
||||
|
||||
def tick(self) -> dict[str, SyncOutcome]:
|
||||
"""Run one sync pass across all configured subscriptions.
|
||||
|
||||
Failures do not raise; the cache is preserved and the failure is
|
||||
recorded in the subscriptions row (``last_error`` /
|
||||
``last_synced_at``) for observability.
|
||||
"""
|
||||
with self._tick_lock:
|
||||
return {sub_id: self._sync_one(sub_id) for sub_id in self.subscriptions}
|
||||
|
||||
def sync_one(self, subscription_id: str) -> SyncOutcome:
|
||||
"""Sync a single subscription immediately (used by the webhook)."""
|
||||
with self._tick_lock:
|
||||
return self._sync_one(subscription_id)
|
||||
|
||||
def start_background(self) -> None:
|
||||
"""Start a daemon thread that calls :meth:`tick` on the configured interval."""
|
||||
if self._thread is not None:
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._run_forever, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop_background(self) -> None:
|
||||
self._stop.set()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=5.0)
|
||||
self._thread = None
|
||||
|
||||
def _run_forever(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
try:
|
||||
self.tick()
|
||||
except Exception:
|
||||
log.exception("skill sync tick raised unexpectedly")
|
||||
self._stop.wait(self.poll_interval)
|
||||
|
||||
def _sync_one(self, subscription_id: str) -> SyncOutcome:
|
||||
try:
|
||||
delta = self.client.fetch_entitled_skills(subscription_id)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"skill sync fetch failed for %s: %s", subscription_id, exc
|
||||
)
|
||||
self.store._set_subscription_state(
|
||||
subscription_id,
|
||||
last_error=f"{type(exc).__name__}: {exc}",
|
||||
bump_synced_at=True,
|
||||
)
|
||||
return SyncOutcome(success=False, error=str(exc), fetched=0)
|
||||
|
||||
if delta.is_full_replace:
|
||||
self.store._apply_sync_replace_all(
|
||||
subscription_id,
|
||||
delta.skills,
|
||||
latest_version=delta.latest_version,
|
||||
last_error=None,
|
||||
)
|
||||
else:
|
||||
for skill in delta.skills:
|
||||
self.store._apply_sync_upsert(skill, subscription_id)
|
||||
for skill_id in delta.removed_ids:
|
||||
self.store._apply_sync_remove(skill_id)
|
||||
self.store._set_subscription_state(
|
||||
subscription_id,
|
||||
last_synced_version=delta.latest_version,
|
||||
last_error=None,
|
||||
)
|
||||
return SyncOutcome(success=True, error=None, fetched=len(delta.skills))
|
||||
|
||||
|
||||
def register_skill_sync_webhook(app: Any, runner: SkillSyncRunner) -> Any:
|
||||
"""Attach ``POST /webhooks/skill-sync`` to a FastAPI app.
|
||||
|
||||
On notification, triggers an immediate ``runner.tick()`` out-of-cycle.
|
||||
Optional — the poll loop remains correct standalone. The endpoint
|
||||
accepts an optional JSON body ``{"subscription_id": "..."}`` to sync
|
||||
only that subscription; if omitted, syncs all configured subscriptions.
|
||||
Returns the runner so the caller can chain.
|
||||
"""
|
||||
from fastapi import Body, FastAPI
|
||||
|
||||
if not isinstance(app, FastAPI):
|
||||
raise TypeError("register_skill_sync_webhook requires a FastAPI app")
|
||||
|
||||
@app.post("/webhooks/skill-sync")
|
||||
async def _skill_sync_webhook(
|
||||
body: dict | None = Body(default=None),
|
||||
) -> dict[str, Any]:
|
||||
sub_id = (body or {}).get("subscription_id")
|
||||
if sub_id:
|
||||
outcome = runner.sync_one(str(sub_id))
|
||||
outcomes = {str(sub_id): outcome}
|
||||
else:
|
||||
outcomes = runner.tick()
|
||||
return {
|
||||
"ok": True,
|
||||
"outcomes": {
|
||||
sid: {"success": o.success, "error": o.error, "fetched": o.fetched}
|
||||
for sid, o in outcomes.items()
|
||||
},
|
||||
}
|
||||
|
||||
return runner
|
||||
Reference in New Issue
Block a user