Compare commits

...

2 Commits

Author SHA1 Message Date
John Lancaster 36abea5940 zensical docs skill 2026-06-19 00:24:08 -05:00
John Lancaster 07475f972f intent adjustments 2026-06-19 00:01:52 -05:00
13 changed files with 174 additions and 48 deletions
+8 -4
View File
@@ -21,7 +21,7 @@ This architecture keeps authored content human-friendly while preserving machine
The architecture is designed to satisfy three long-term requirements:
1. Methodology must be editable as markdown by humans.
2. Agents must consume stable, discoverable resource contracts.
2. Agents must consume stable, discoverable resource contracts, with a minimal read-only catalog tool fallback for constrained clients.
3. Public documentation must be pre-built static output served from the application runtime without a separate docs service.
## System Model
@@ -36,7 +36,7 @@ The document resource returns canonical Markdown, while clients can perform any
### Catalog Module
The catalog is the canonical discovery layer and publishes normalized records for all modules.
The catalog is the canonical discovery layer and publishes normalized records for all modules. It may also expose a minimal set of read-only discovery tools that resolve back to the same canonical markdown content when a client chat surface does not expose MCP resource attachment.
Typical catalog resources:
@@ -128,7 +128,7 @@ Markdown remains easy to review, while contracts remain stable for clients.
### Client Independence
Clients can use Ask, Edit, or Agent modes without requiring server-owned prompt orchestration.
Clients can use Ask, Edit, or Agent modes without requiring server-owned prompt orchestration. However, MCP affordances are still chat-surface-dependent: some clients or sessions expose resource attachment directly, while others make tool invocation the more reliable retrieval path.
## Authoring and Publishing Lifecycle
@@ -148,9 +148,13 @@ In-scope:
Out-of-scope:
1. Prompt-first orchestration as the primary interface
2. Large tool inventories duplicating static guidance
2. Large tool inventories duplicating static guidance across skill modules
3. Separate dynamic docs service at runtime
Allowed exception:
1. A small catalog-level tool layer is acceptable when it improves client interoperability without creating a second source of truth for skill content.
## Example Content Inputs
Existing markdown reference sets are valid examples of authored source material for this architecture:
+2
View File
@@ -108,6 +108,8 @@ Example mapping model:
Catalog resources provide discovery metadata and stable identifiers.
When clients cannot attach MCP resources directly, catalog-level tools may retrieve the same underlying skill documents indirectly. This does not create a second content source; it is only an alternate access path to the same markdown-backed contract.
## Why This Pattern
### Operational Simplicity
+10 -4
View File
@@ -14,7 +14,9 @@ Use this checklist after generating a new skill under `docs/skills/<slug>/`.
Create `src/personal_mcp/skills/<python_namespace>/` 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/<skill-id>/document`, and return `load_skill_document(skill_id=<skill-id>, skill_slug=<slug>)`.
Follow the existing pattern: create a `FastMCP` instance, register `resource://skills/<skill-id>/document`, and return the shared loader result.
For the default layout, load `docs/skills/<slug>/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/<skill-id>/document`.
@@ -23,7 +25,7 @@ Use this checklist after generating a new skill under `docs/skills/<slug>/`.
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/<slug>/SKILL.md`, and the catalog discovers metadata from `src/personal_mcp/skills/*/metadata.yaml` automatically.
The document loader reads canonical Markdown from `docs/skills/<slug>/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/<slug>/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: <One sentence describing what the skill provides.>
tags:
- <tag-one>
- <tag-two>
document_path: <optional repo-relative path to a markdown file>
capabilities:
- resource://skills/<skill-id>/document
depends_on: []
```
Omit `document_path` when the canonical document is `docs/skills/<slug>/SKILL.md`.
## Root Mount Template
Add an import in `src/personal_mcp/mcp.py`:
+10
View File
@@ -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.
+44
View File
@@ -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/<skill-id>/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:
+11 -36
View File
@@ -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}
+2
View File
@@ -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")
@@ -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)
@@ -7,6 +7,7 @@ tags:
- scaffolding
- skills
- mcp
document_path: docs/new_skill.md
capabilities:
- resource://skills/new-skill/document
depends_on: []
+8 -4
View File
@@ -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,
)
@@ -0,0 +1 @@
"""Zensical documentation authoring skill server."""
@@ -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: []
@@ -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",
)