diff --git a/docs/new_skill.md b/docs/new_skill.md index d3faf33..e5946f6 100644 --- a/docs/new_skill.md +++ b/docs/new_skill.md @@ -14,7 +14,9 @@ Use this checklist after generating a new skill under `docs/skills//`. Create `src/personal_mcp/skills//` with `__init__.py`, `server.py`, and `metadata.yaml`. 4. Expose the document resource in `server.py`. - Follow the existing pattern: create a `FastMCP` instance, register `resource://skills//document`, and return `load_skill_document(skill_id=, skill_slug=)`. + Follow the existing pattern: create a `FastMCP` instance, register `resource://skills//document`, and return the shared loader result. + For the default layout, load `docs/skills//SKILL.md`. + For special cases, set `document_path` in `metadata.yaml` to a repo-relative Markdown file and load from metadata instead of hardcoding a path in the server. 5. Register the catalog metadata. In `metadata.yaml`, add the skill `id`, `name`, `version`, `description`, `tags`, `capabilities`, and `depends_on`. The `capabilities` list should include `resource://skills//document`. @@ -23,7 +25,7 @@ Use this checklist after generating a new skill under `docs/skills//`. Import the new server in `src/personal_mcp/mcp.py` and add an `mcp.mount(...)` call with the Python namespace. 7. Let the loader and catalog do the rest. - The document loader reads canonical Markdown from `docs/skills//SKILL.md`, and the catalog discovers metadata from `src/personal_mcp/skills/*/metadata.yaml` automatically. + The document loader reads canonical Markdown from `docs/skills//SKILL.md` by default, or from `metadata.yaml`'s optional `document_path` override when present. The catalog discovers metadata from `src/personal_mcp/skills/*/metadata.yaml` automatically. 8. Rebuild and smoke-test. Run `uv run zensical build` to publish the docs site, then run a quick Python check or start the app to confirm the new resource loads. @@ -39,8 +41,9 @@ To keep behavior consistent across MCP clients and Copilot session types, follow ### Do 1. Add or update `metadata.yaml` fields (`id`, `description`, `tags`, `capabilities`) so catalog discovery quality stays high. -2. Use catalog resources as the primary discovery surface. -3. Add thin, read-only catalog tools only when client behavior needs a fallback path. +2. Use `document_path` when a skill should expose a Markdown file outside `docs/skills//SKILL.md`. +3. Use catalog resources as the primary discovery surface. +4. Add thin, read-only catalog tools only when client behavior needs a fallback path. ### Don't @@ -92,11 +95,14 @@ description: tags: - - +document_path: capabilities: - resource://skills//document depends_on: [] ``` +Omit `document_path` when the canonical document is `docs/skills//SKILL.md`. + ## Root Mount Template Add an import in `src/personal_mcp/mcp.py`: diff --git a/docs/skills/zensical-docs/SKILL.md b/docs/skills/zensical-docs/SKILL.md index a677dad..b7f721d 100644 --- a/docs/skills/zensical-docs/SKILL.md +++ b/docs/skills/zensical-docs/SKILL.md @@ -33,6 +33,16 @@ Always start a new documentation project with `uv run zensical new`. - This command provides the baseline scaffolding you need for structure, configuration, and theme setup. - Do not hand-build a new project skeleton when this command is available. +## Related Skill Discovery + +When the task is not just writing docs but creating or wiring a new MCP skill in this repository, use catalog discovery to load the bootstrap skill before drafting implementation steps. + +1. Search the catalog with terms such as `new skill`, `skill bootstrap`, or `scaffold skill`. +2. Fetch the `new-skill` document through the catalog tool path. +3. Use that skill for runtime package, metadata, and mount wiring, then return here for documentation architecture and Zensical-specific authoring guidance. + +This keeps implementation guidance and documentation guidance separated while still making both discoverable from one request. + ## Inputs To Collect Collect these before writing. If missing, make explicit assumptions. diff --git a/docs/usage.md b/docs/usage.md index 2898b7b..73316b5 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -147,6 +147,50 @@ Weak metadata reduces Copilot match quality and increases wrong context injectio If you skip the catalog/index step, behavior is less predictable and may either miss relevant skills or pull too much context. +## Copilot Instruction Pattern + +If you want Copilot to use `personal-mcp` skill content more reliably, the instruction file should describe three things clearly: + +1. when MCP-backed skill guidance is relevant +2. which retrieval path Copilot should prefer first +3. how much skill context it should load before answering + +That matters because instructions can strongly steer discovery behavior, but they do not force VS Code to auto-attach MCP resources. A good instruction tells Copilot to prefer the canonical MCP content path while remaining accurate about the fallback path. + +In this repository, the right policy is: + +1. start from catalog discovery +2. prefer MCP resources when the current chat surface exposes resource attachment +3. fall back to catalog tools when resource attachment is unavailable +4. keep loaded skill context bounded + +Suggested instruction text: + +```md +When a task may match a documented implementation pattern from `personal-mcp`: + +1. Start with catalog-first discovery. +2. Prefer MCP resources when the chat surface exposes resource attachment. +3. If MCP resource attachment is unavailable, use catalog tools instead. +4. Load only the most relevant skill document, or at most 2 skill documents. +5. Reconcile loaded skill guidance with the actual repository code before making changes. + +Preferred resource order: + +1. `resource://catalog/skills_index` or `resource://catalog/patterns` +2. `resource://skills//document` + +Preferred tool fallback order: + +1. `search_patterns` +2. `get_pattern_by_id` +3. `get_skill_document_by_id` + +If confidence is low after discovery, ask one clarifying question before loading more context. +``` + +This is intentionally guidance, not a guarantee. It gives Copilot a strong policy for when to use resources and when to fall back to discovery tools, while preserving the resource-first architecture. + ## Failure Modes and Recovery Common failure modes: diff --git a/src/personal_mcp/catalog/server.py b/src/personal_mcp/catalog/server.py index c904af6..6d42d57 100644 --- a/src/personal_mcp/catalog/server.py +++ b/src/personal_mcp/catalog/server.py @@ -4,7 +4,7 @@ from typing import Any import yaml from fastmcp import FastMCP -from personal_mcp.skills.document_loader import load_skill_document +from personal_mcp.skills.document_loader import load_skill_document_from_metadata catalog_server = FastMCP("catalog") @@ -59,6 +59,10 @@ def _matches_query(pattern: dict[str, Any], query: str) -> bool: if not lowered: return True + query_terms = [term for term in lowered.replace("-", " ").split() if term] + if not query_terms: + return True + haystack = " ".join( [ str(pattern.get("id", "")), @@ -68,7 +72,7 @@ def _matches_query(pattern: dict[str, Any], query: str) -> bool: " ".join(str(tag) for tag in pattern.get("tags", [])), ] ).lower() - return lowered in haystack + return all(term in haystack for term in query_terms) def _matches_tags(pattern: dict[str, Any], tags: list[str] | None) -> bool: @@ -83,34 +87,6 @@ def _matches_tags(pattern: dict[str, Any], tags: list[str] | None) -> bool: return all(tag in pattern_tags for tag in requested) -def _resolve_skill_slug(*, skill_id: str, namespace: str, metadata: dict[str, Any]) -> str: - candidates: list[str] = [] - slug = metadata.get("slug") - if isinstance(slug, str) and slug.strip(): - candidates.append(slug.strip()) - - candidates.extend( - [ - skill_id, - namespace.replace("_", "-"), - namespace, - ] - ) - - seen: set[str] = set() - for candidate in candidates: - if candidate in seen: - continue - seen.add(candidate) - try: - load_skill_document(skill_id=skill_id, skill_slug=candidate) - return candidate - except FileNotFoundError: - continue - - return skill_id - - @catalog_server.resource("resource://catalog/skills_index") def skills_index() -> dict[str, Any]: """Return a compact discovery index for all available pattern modules.""" @@ -184,14 +160,13 @@ def get_skill_document_by_id(skill_id: str) -> dict[str, Any]: if pattern_id != skill_id: continue - skill_slug = _resolve_skill_slug( - skill_id=skill_id, - namespace=namespace, - metadata=metadata, - ) return { "found": True, - "document": load_skill_document(skill_id=skill_id, skill_slug=skill_slug), + "document": load_skill_document_from_metadata( + skill_id=skill_id, + namespace=namespace, + metadata=metadata, + ), } return {"found": False, "id": skill_id} diff --git a/src/personal_mcp/mcp.py b/src/personal_mcp/mcp.py index de0b556..d5db227 100644 --- a/src/personal_mcp/mcp.py +++ b/src/personal_mcp/mcp.py @@ -14,6 +14,7 @@ from personal_mcp.skills.pytest_scaffolding.server import pytest_scaffolding_ser from personal_mcp.skills.python_logging_dictconfig.server import ( python_logging_dictconfig_server, ) +from personal_mcp.skills.zensical_docs.server import zensical_docs_server mcp = FastMCP("personal-mcp") @@ -28,3 +29,4 @@ mcp.mount(nicegui_ui_customization_server, namespace="nicegui_ui_customization") mcp.mount(pytest_scaffolding_server, namespace="pytest_scaffolding") mcp.mount(python_logging_dictconfig_server, namespace="python_logging_dictconfig") mcp.mount(fastapi_uv_docker_server, namespace="fastapi_uv_docker") +mcp.mount(zensical_docs_server, namespace="zensical_docs") diff --git a/src/personal_mcp/skills/document_loader.py b/src/personal_mcp/skills/document_loader.py index 9fbeeb3..fc3f261 100644 --- a/src/personal_mcp/skills/document_loader.py +++ b/src/personal_mcp/skills/document_loader.py @@ -1,10 +1,45 @@ from pathlib import Path +from typing import Any def _repo_root() -> Path: return Path(__file__).resolve().parents[3] +def resolve_skill_document_path( + *, skill_id: str, namespace: str, metadata: dict[str, Any] +) -> Path: + """Resolve the canonical Markdown document path for a skill.""" + document_path = metadata.get("document_path") + if isinstance(document_path, str) and document_path.strip(): + return _repo_root() / document_path.strip() + + candidates: list[str] = [] + slug = metadata.get("slug") + if isinstance(slug, str) and slug.strip(): + candidates.append(slug.strip()) + + candidates.extend( + [ + skill_id, + namespace.replace("_", "-"), + namespace, + ] + ) + + seen: set[str] = set() + for candidate in candidates: + if candidate in seen: + continue + seen.add(candidate) + + candidate_path = _repo_root() / "docs" / "skills" / candidate / "SKILL.md" + if candidate_path.exists(): + return candidate_path + + return _repo_root() / "docs" / "skills" / skill_id / "SKILL.md" + + def load_markdown_document(*, skill_id: str, document_path: Path) -> dict[str, str]: """Load an arbitrary Markdown document and expose it as a skill resource.""" if not document_path.exists(): @@ -25,3 +60,15 @@ def load_skill_document(*, skill_id: str, skill_slug: str) -> dict[str, str]: """Load the canonical skill markdown document for an MCP skill.""" document_path = _repo_root() / "docs" / "skills" / skill_slug / "SKILL.md" return load_markdown_document(skill_id=skill_id, document_path=document_path) + + +def load_skill_document_from_metadata( + *, skill_id: str, namespace: str, metadata: dict[str, Any] +) -> dict[str, str]: + """Load a skill document using metadata overrides when present.""" + document_path = resolve_skill_document_path( + skill_id=skill_id, + namespace=namespace, + metadata=metadata, + ) + return load_markdown_document(skill_id=skill_id, document_path=document_path) diff --git a/src/personal_mcp/skills/new_skill/metadata.yaml b/src/personal_mcp/skills/new_skill/metadata.yaml index 8673366..1895cd5 100644 --- a/src/personal_mcp/skills/new_skill/metadata.yaml +++ b/src/personal_mcp/skills/new_skill/metadata.yaml @@ -7,6 +7,7 @@ tags: - scaffolding - skills - mcp +document_path: docs/new_skill.md capabilities: - resource://skills/new-skill/document depends_on: [] diff --git a/src/personal_mcp/skills/new_skill/server.py b/src/personal_mcp/skills/new_skill/server.py index 8c3c7b0..c04f853 100644 --- a/src/personal_mcp/skills/new_skill/server.py +++ b/src/personal_mcp/skills/new_skill/server.py @@ -1,17 +1,21 @@ from pathlib import Path +import yaml + from fastmcp import FastMCP -from personal_mcp.skills.document_loader import load_markdown_document +from personal_mcp.skills.document_loader import load_skill_document_from_metadata new_skill_server = FastMCP("new-skill") +_METADATA_PATH = Path(__file__).with_name("metadata.yaml") +_METADATA = yaml.safe_load(_METADATA_PATH.read_text(encoding="utf-8")) or {} @new_skill_server.resource("resource://skills/new-skill/document") def skill_document() -> dict[str, str]: """Return the bootstrap guide used to scaffold new skills.""" - document_path = Path(__file__).resolve().parents[4] / "docs" / "new_skill.md" - return load_markdown_document( + return load_skill_document_from_metadata( skill_id="new-skill", - document_path=document_path, + namespace="new_skill", + metadata=_METADATA, ) diff --git a/src/personal_mcp/skills/zensical_docs/__init__.py b/src/personal_mcp/skills/zensical_docs/__init__.py new file mode 100644 index 0000000..b56413a --- /dev/null +++ b/src/personal_mcp/skills/zensical_docs/__init__.py @@ -0,0 +1 @@ +"""Zensical documentation authoring skill server.""" \ No newline at end of file diff --git a/src/personal_mcp/skills/zensical_docs/metadata.yaml b/src/personal_mcp/skills/zensical_docs/metadata.yaml new file mode 100644 index 0000000..3cb861e --- /dev/null +++ b/src/personal_mcp/skills/zensical_docs/metadata.yaml @@ -0,0 +1,16 @@ +id: zensical-docs +name: Zensical Documentation Authoring +version: 1.0.0 +description: Plan, write, and improve high-quality documentation with Zensical. +tags: + - zensical + - docs + - documentation + - information-architecture + - skills + - bootstrap + - discovery + - authoring +capabilities: + - resource://skills/zensical-docs/document +depends_on: [] \ No newline at end of file diff --git a/src/personal_mcp/skills/zensical_docs/server.py b/src/personal_mcp/skills/zensical_docs/server.py new file mode 100644 index 0000000..fd4196e --- /dev/null +++ b/src/personal_mcp/skills/zensical_docs/server.py @@ -0,0 +1,14 @@ +from fastmcp import FastMCP + +from personal_mcp.skills.document_loader import load_skill_document + +zensical_docs_server = FastMCP("zensical-docs") + + +@zensical_docs_server.resource("resource://skills/zensical-docs/document") +def skill_document() -> dict[str, str]: + """Return the canonical Markdown document for this skill.""" + return load_skill_document( + skill_id="zensical-docs", + skill_slug="zensical-docs", + ) \ No newline at end of file