Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,15 @@ All notable changes to vouch are documented here. Format follows
as reviewer; a PR opens only when the repo's own test gate is green and the
reviewer signs off. A sibling tool — it never writes to the KB or the review
gate. Paired with the `auto-pr` skill.
- `mcp.publish_skills` config flag (#235): when false, `kb.list_skills`
returns an empty catalogue and `kb.get_skill` returns `permission_denied`.
Default is `true` so existing KBs keep current behaviour. The flag is
surfaced on `kb.capabilities.mcp` so clients can detect the gate without
probing. Flipping the flag in `config.yaml` takes effect on the next call
— no server restart required.
- `kb.list_skills` / `kb.get_skill` — discover Claude Code skills and slash
commands from project-local and user-global `.claude/` trees. CLI mirrors:
`vouch list-skills`, `vouch get-skill <name>`.
- typed page kinds (#234): a KB can declare extra page kinds in
`.vouch/config.yaml` under `page_kinds`, each with `required_fields`, a
JSON-Schema-subset `frontmatter_schema`, `required_citations`, and one level
Expand Down
6 changes: 6 additions & 0 deletions schemas/capabilities.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
"title": "Level",
"type": "integer"
},
"mcp": {
"additionalProperties": true,
"description": "MCP transport knobs surfaced to clients (issue #235)",
"title": "Mcp",
"type": "object"
},
"methods": {
"items": {
"type": "string"
Expand Down
11 changes: 10 additions & 1 deletion src/vouch/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from . import __version__
from .models import Capabilities
from .openclaw.context_engine import describe_engine
from .storage import KBStore

# The full method surface this implementation exposes. Keep this list in
# sync with the MCP server + JSONL server registrations — `test_capabilities`
Expand Down Expand Up @@ -69,10 +70,12 @@
"kb.impact",
"kb.graph_export",
"kb.provenance_rebuild",
"kb.list_skills",
"kb.get_skill",
]


def capabilities() -> Capabilities:
def capabilities(store: KBStore | None = None) -> Capabilities:
retrieval = ["fts5", "substring"]
try:
from .embeddings import get_embedder
Expand All @@ -81,6 +84,11 @@ def capabilities() -> Capabilities:
retrieval.append("hybrid")
except Exception:
pass
mcp_section: dict[str, object] = {"publish_skills": True}
if store is not None:
from .mcp_config import load_config

mcp_section = load_config(store).to_capabilities_dict()
return Capabilities(
version=__version__,
methods=METHODS,
Expand All @@ -94,4 +102,5 @@ def capabilities() -> Capabilities:
"config_path": "retrieval.scope",
},
context_engines=[describe_engine()],
mcp=mcp_section,
)
25 changes: 24 additions & 1 deletion src/vouch/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from .proposals import (
reject as do_reject,
)
from .skills.errors import SkillsAccessDenied
from .storage import (
ArtifactNotFoundError,
KBNotFoundError,
Expand All @@ -83,6 +84,7 @@ def _cli_errors() -> Iterator[None]:
ProposalError,
LifecycleError,
migrations_mod.MigrationError,
SkillsAccessDenied,
) as e:
raise click.ClickException(str(e)) from e

Expand Down Expand Up @@ -179,7 +181,7 @@ def discover(path: str) -> None:
@cli.command()
def capabilities() -> None:
"""Emit the JSON capabilities descriptor (mirrors kb.capabilities)."""
_emit_json(build_caps().model_dump(mode="json"))
_emit_json(build_caps(_load_store()).model_dump(mode="json"))


# --- status / health ------------------------------------------------------
Expand Down Expand Up @@ -1478,6 +1480,27 @@ def neighbors(node_id: str, depth: int, rel_types: tuple[str, ...],
_emit_json(result)


@cli.command("list-skills")
def list_skills_cmd() -> None:
"""Enumerate Claude Code skills / slash commands (mirrors kb.list_skills)."""
from . import skills as skills_mod

store = _load_store()
_emit_json(skills_mod.list_skills(store))


@cli.command("get-skill")
@click.argument("name")
def get_skill_cmd(name: str) -> None:
"""Return the full markdown body of a named skill (mirrors kb.get_skill)."""
from . import skills as skills_mod

store = _load_store()
with _cli_errors():
result = skills_mod.get_skill(store, name)
_emit_json(result)


@cli.command()
@click.argument("task")
@click.option("--limit", default=10, show_default=True, type=int)
Expand Down
25 changes: 24 additions & 1 deletion src/vouch/jsonl_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from . import lifecycle as life
from . import salience as salience_mod
from . import sessions as sess_mod
from . import skills as skills_mod
from . import trust as trust_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
Expand All @@ -50,6 +51,7 @@
reject,
reject_auto_extracted,
)
from .skills.errors import SkillsAccessDenied
from .stats import collect_stats
from .storage import (
ArtifactNotFoundError,
Expand Down Expand Up @@ -82,7 +84,7 @@ def _agent() -> str:


def _h_capabilities(_: dict) -> dict:
return build_caps().model_dump(mode="json")
return build_caps(_store()).model_dump(mode="json")


def _h_status(_: dict) -> dict:
Expand Down Expand Up @@ -632,6 +634,20 @@ def _h_provenance_rebuild(_: dict) -> dict:
return {"edges": prov.rebuild_prov_edges(_store())}


def _h_list_skills(_: dict) -> list[dict]:
return skills_mod.list_skills(_store())


def _h_get_skill(p: dict) -> dict:
name = p.get("name")
if not isinstance(name, str) or not name.strip():
raise ValueError("`name` is required")
try:
return skills_mod.get_skill(_store(), name)
except KeyError as e:
raise ValueError(str(e)) from e


HANDLERS: dict[str, Callable[[dict], Any]] = {
"kb.capabilities": _h_capabilities,
"kb.status": _h_status,
Expand Down Expand Up @@ -687,6 +703,8 @@ def _h_provenance_rebuild(_: dict) -> dict:
"kb.impact": _h_impact,
"kb.graph_export": _h_graph_export,
"kb.provenance_rebuild": _h_provenance_rebuild,
"kb.list_skills": _h_list_skills,
"kb.get_skill": _h_get_skill,
}


Expand All @@ -707,6 +725,11 @@ def handle_request(envelope: dict) -> dict:
"ok": True,
"result": trust_mod.finish_kb_result(result),
}
except SkillsAccessDenied as e:
return {
"id": req_id, "ok": False,
"error": {"code": "permission_denied", "message": str(e)},
}
except KeyError as e:
return {
"id": req_id, "ok": False,
Expand Down
40 changes: 40 additions & 0 deletions src/vouch/mcp_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""MCP transport settings read from ``config.yaml``.

Mirrors gbrain's ``mcp.publish_skills`` gate (issue #235): when false, skill
catalogue endpoints hide the catalogue so a company-brain KB can keep slash
commands on disk without advertising them to every MCP caller.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import Any

import yaml

from .storage import KBStore


@dataclass(frozen=True)
class McpConfig:
publish_skills: bool = True

def to_capabilities_dict(self) -> dict[str, Any]:
return {"publish_skills": self.publish_skills}


def load_config(store: KBStore) -> McpConfig:
"""Read ``mcp:`` from config.yaml; fall back to defaults."""
try:
loaded = yaml.safe_load(store.config_path.read_text())
except (OSError, yaml.YAMLError):
return McpConfig()
if not isinstance(loaded, dict):
return McpConfig()
raw = loaded.get("mcp")
if not isinstance(raw, dict):
return McpConfig()
publish = raw.get("publish_skills", True)
if not isinstance(publish, bool):
publish = bool(publish)
return McpConfig(publish_skills=publish)
4 changes: 4 additions & 0 deletions src/vouch/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,7 @@ class Capabilities(BaseModel):
default_factory=list,
description="OpenClaw context engines exposed (see openclaw.plugin.json)",
)
mcp: dict[str, Any] = Field(
default_factory=lambda: {"publish_skills": True},
description="MCP transport knobs surfaced to clients (issue #235)",
)
35 changes: 34 additions & 1 deletion src/vouch/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import lifecycle as life
from . import salience as salience_mod
from . import sessions as sess_mod
from . import skills as skills_mod
from . import trust as trust_mod
from . import verify as verify_mod
from .capabilities import capabilities as build_caps
Expand All @@ -42,6 +43,7 @@
reject_auto_extracted,
)
from .scoping import filter_hits, scoped_fetch_limit, viewer_from
from .skills.errors import SkillsAccessDenied
from .stats import collect_stats
from .storage import (
ArtifactNotFoundError,
Expand Down Expand Up @@ -73,7 +75,7 @@ def _agent() -> str:
@mcp.tool()
def kb_capabilities() -> dict[str, Any]:
"""Return the protocol capabilities of this server."""
return build_caps().model_dump(mode="json")
return build_caps(_store()).model_dump(mode="json")


@mcp.tool()
Expand Down Expand Up @@ -857,6 +859,37 @@ def kb_provenance_rebuild() -> dict[str, Any]:
return {"edges": prov.rebuild_prov_edges(_store())}


# === skills (catalogue gated by mcp.publish_skills) =======================


@mcp.tool()
def kb_list_skills() -> list[dict[str, Any]]:
"""Enumerate every Claude Code skill / slash command visible to vouch.

Scans, in priority order:
1. ``<kb_root>/.claude/skills/<name>/SKILL.md`` — project-local skills
2. ``<kb_root>/.claude/commands/<name>.md`` — project-local commands
3. ``~/.claude/skills/<name>/SKILL.md`` — user-global skills
4. ``~/.claude/commands/<name>.md`` — user-global commands

Project entries override user ones with the same name. Returns
``[{name, description, scope, kind, path}]``. When ``mcp.publish_skills``
is false in config.yaml the catalogue is empty.
"""
return skills_mod.list_skills(_store())


@mcp.tool()
def kb_get_skill(name: str) -> dict[str, Any]:
"""Return the full markdown body of a named skill / slash command."""
try:
return skills_mod.get_skill(_store(), name)
except SkillsAccessDenied as e:
raise PermissionError(str(e)) from e
except KeyError as e:
raise ValueError(str(e)) from e


def _current_model_name() -> str:
try:
from .embeddings import get_embedder
Expand Down
20 changes: 20 additions & 0 deletions src/vouch/skills/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Skill / slash-command discovery for agents.

Lets an MCP-connected agent introspect installed skills and slash commands
without reading the filesystem. Scanned locations, in priority order (later
wins on name collision):

1. ``<kb_root>/.claude/skills/<name>/SKILL.md`` — project-local skills
2. ``<kb_root>/.claude/commands/<name>.md`` — project-local commands
3. ``~/.claude/skills/<name>/SKILL.md`` — user-global skills
4. ``~/.claude/commands/<name>.md`` — user-global commands

Project-local entries override user-global ones with the same name.
"""

from __future__ import annotations

from .access import get_skill, list_skills
from .errors import SkillsAccessDenied

__all__ = ["SkillsAccessDenied", "get_skill", "list_skills"]
29 changes: 29 additions & 0 deletions src/vouch/skills/access.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""Publish gate for skill catalogue endpoints (``mcp.publish_skills``)."""

from __future__ import annotations

from typing import Any

from ..mcp_config import load_config
from ..storage import KBStore
from .catalogue import get_catalogue_entry, list_catalogue
from .errors import SkillsAccessDenied

_GATE_MESSAGE = (
"skill catalogue is not published "
"(set mcp.publish_skills: true in config.yaml to enable)"
)


def list_skills(store: KBStore) -> list[dict[str, Any]]:
"""Return the skill catalogue, or ``[]`` when publishing is disabled."""
if not load_config(store).publish_skills:
return []
return list_catalogue(store)


def get_skill(store: KBStore, name: str) -> dict[str, Any]:
"""Return one skill body, or raise when publishing is disabled."""
if not load_config(store).publish_skills:
raise SkillsAccessDenied(_GATE_MESSAGE)
return get_catalogue_entry(store, name)
Loading
Loading