feat(skills): unified read-merge + authoring/override MCP tools
Adds the merged read surface (api/skill_catalog_view.py) over synced + local stores with origin discrimination and override precedence, and extends the skill MCP tools with create_skill/update_skill/delete_skill that dispatch by origin (edit local skills; create/update/remove local overrides for cloud skills). Wired into api.mcp.create_mcp_server. Full non-integration suite green (564 passed). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ from api.skill_catalog_mcp import (
|
||||
SKILL_TOOL_NAMES,
|
||||
InvalidFlowTemplateError,
|
||||
MissingParameterError,
|
||||
SkillAuthoringError,
|
||||
SkillCatalogError,
|
||||
SkillNotFoundError,
|
||||
register_skill_catalog_tools,
|
||||
@@ -95,9 +96,17 @@ def seeded_store(store):
|
||||
return store
|
||||
|
||||
|
||||
def _handlers(store, *, registered_tools=None, active=None):
|
||||
def _handlers(store, *, registered_tools=None, active=None, local=None):
|
||||
if local is None:
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from storage.local_skills import LocalSkillStore
|
||||
|
||||
local = LocalSkillStore(db_path=Path(tempfile.mkdtemp()) / "local.sqlite3")
|
||||
return skill_tool_handlers(
|
||||
store=store,
|
||||
local_store=local,
|
||||
get_active_subscriptions=lambda: set(active if active is not None else {"sub-a"}),
|
||||
get_registered_tools=(lambda: set(registered_tools or {"tap", "input_text"})),
|
||||
)
|
||||
@@ -260,13 +269,16 @@ def _fastmcp_tool_names(server) -> set[str]:
|
||||
)
|
||||
|
||||
|
||||
def test_register_skill_catalog_tools_registers_expected_names(seeded_store):
|
||||
def test_register_skill_catalog_tools_registers_expected_names(seeded_store, tmp_path):
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from storage.local_skills import LocalSkillStore
|
||||
|
||||
server = FastMCP("test")
|
||||
register_skill_catalog_tools(
|
||||
server,
|
||||
store=seeded_store,
|
||||
local_store=LocalSkillStore(db_path=tmp_path / "local.sqlite3"),
|
||||
get_active_subscriptions=lambda: {"sub-a"},
|
||||
get_registered_tools=lambda: {"tap", "input_text"},
|
||||
)
|
||||
@@ -275,14 +287,17 @@ def test_register_skill_catalog_tools_registers_expected_names(seeded_store):
|
||||
assert expected in names, f"missing tool: {expected}"
|
||||
|
||||
|
||||
def test_no_batch_execute_tool_is_registered(seeded_store):
|
||||
def test_no_batch_execute_tool_is_registered(seeded_store, tmp_path):
|
||||
"""Task 4.4: assert no run_skill_flow / execute / batch tool is registered."""
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from storage.local_skills import LocalSkillStore
|
||||
|
||||
server = FastMCP("test")
|
||||
register_skill_catalog_tools(
|
||||
server,
|
||||
store=seeded_store,
|
||||
local_store=LocalSkillStore(db_path=tmp_path / "local.sqlite3"),
|
||||
get_active_subscriptions=lambda: {"sub-a"},
|
||||
get_registered_tools=lambda: {"tap", "input_text"},
|
||||
)
|
||||
@@ -330,3 +345,108 @@ def test_skill_error_hierarchy():
|
||||
assert issubclass(SkillNotFoundError, SkillCatalogError)
|
||||
assert issubclass(InvalidFlowTemplateError, SkillCatalogError)
|
||||
assert issubclass(MissingParameterError, SkillCatalogError)
|
||||
assert issubclass(SkillAuthoringError, SkillCatalogError)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Authoring tools: create / update / delete with origin dispatch (D10)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def local(tmp_path):
|
||||
from storage.local_skills import LocalSkillStore
|
||||
|
||||
return LocalSkillStore(db_path=tmp_path / "local.sqlite3")
|
||||
|
||||
|
||||
def _authoring_handlers(seeded_store, local):
|
||||
return _handlers(seeded_store, local=local)
|
||||
|
||||
|
||||
def test_create_skill_creates_local_skill(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
result = handlers["create_skill"](
|
||||
payload={"kind": "knowledge", "name": "My Note", "content": "hello"}
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["origin"] == "local"
|
||||
new_id = result["skill"]["id"]
|
||||
# discoverable via list with origin local
|
||||
listed = handlers["list_skills"]()
|
||||
entry = next(s for s in listed["skills"] if s["id"] == new_id)
|
||||
assert entry["origin"] == "local"
|
||||
assert entry["locally_overridden"] is False
|
||||
|
||||
|
||||
def test_create_skill_rejects_invalid_payload(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
result = handlers["create_skill"](payload={"kind": "knowledge", "name": ""})
|
||||
assert result["ok"] is False
|
||||
assert "authoring" in result["error"]
|
||||
|
||||
|
||||
def test_update_skill_edits_local_skill(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
created = handlers["create_skill"](
|
||||
payload={"kind": "knowledge", "name": "Note", "content": "v1"}
|
||||
)
|
||||
local_id = created["skill"]["id"]
|
||||
result = handlers["update_skill"](
|
||||
skill_id=local_id,
|
||||
payload={"kind": "knowledge", "name": "Note", "content": "v2"},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["origin"] == "local"
|
||||
assert handlers["get_skill"](skill_id=local_id)["skill"]["content"] == "v2"
|
||||
|
||||
|
||||
def test_update_skill_on_cloud_id_creates_override(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
# k1 is a cloud knowledge skill in the seeded store
|
||||
result = handlers["update_skill"](
|
||||
skill_id="k1",
|
||||
payload={"kind": "knowledge", "name": "Knowledge One", "content": "OVERRIDDEN"},
|
||||
)
|
||||
assert result["ok"] is True
|
||||
assert result["origin"] == "cloud"
|
||||
assert result["locally_overridden"] is True
|
||||
# get_skill reflects the override
|
||||
got = handlers["get_skill"](skill_id="k1")
|
||||
assert got["skill"]["content"] == "OVERRIDDEN"
|
||||
assert got["skill"]["origin"] == "cloud"
|
||||
assert got["skill"]["locally_overridden"] is True
|
||||
|
||||
|
||||
def test_delete_skill_removes_local_skill(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
created = handlers["create_skill"](
|
||||
payload={"kind": "knowledge", "name": "Temp", "content": "x"}
|
||||
)
|
||||
local_id = created["skill"]["id"]
|
||||
result = handlers["delete_skill"](skill_id=local_id)
|
||||
assert result["ok"] is True
|
||||
assert result["origin"] == "local"
|
||||
assert handlers["get_skill"](skill_id=local_id)["ok"] is False
|
||||
|
||||
|
||||
def test_delete_skill_on_cloud_override_reverts(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
handlers["update_skill"](
|
||||
skill_id="k1",
|
||||
payload={"kind": "knowledge", "name": "Knowledge One", "content": "OVERRIDDEN"},
|
||||
)
|
||||
result = handlers["delete_skill"](skill_id="k1")
|
||||
assert result["ok"] is True
|
||||
assert result["origin"] == "cloud"
|
||||
# cloud skill resurfaces with original content
|
||||
got = handlers["get_skill"](skill_id="k1")
|
||||
assert got["skill"]["content"] == "body of knowledge"
|
||||
assert got["skill"]["locally_overridden"] is False
|
||||
|
||||
|
||||
def test_delete_skill_on_cloud_without_override_errors(seeded_store, local):
|
||||
handlers = _authoring_handlers(seeded_store, local)
|
||||
result = handlers["delete_skill"](skill_id="k1") # cloud, no override
|
||||
assert result["ok"] is False
|
||||
assert "authoring" in result["error"]
|
||||
|
||||
Reference in New Issue
Block a user