migration
This commit is contained in:
@@ -0,0 +1,49 @@
|
|||||||
|
# personal-mcp MCP Usage
|
||||||
|
|
||||||
|
This repository is resource-first.
|
||||||
|
|
||||||
|
- Canonical skill guidance lives in `docs/skills/<skill-id>/SKILL.md`.
|
||||||
|
- The machine-facing skill contract is FastMCP's native `skill://` resource family.
|
||||||
|
|
||||||
|
When a task appears to match a documented implementation pattern in `personal-mcp`, use this sequence:
|
||||||
|
|
||||||
|
1. Prefer an already attached native skill resource.
|
||||||
|
2. Otherwise browse native MCP resources and compare `skill://<name>/SKILL.md` descriptions.
|
||||||
|
3. Read the best matching main skill file, or at most 2 candidate main files.
|
||||||
|
4. Read `skill://<name>/_manifest` only when supporting material may be useful.
|
||||||
|
5. Fetch only the relevant supporting paths from that manifest.
|
||||||
|
6. Reconcile skill guidance with the actual repository code before proposing or making changes.
|
||||||
|
|
||||||
|
Preferred MCP resource order:
|
||||||
|
|
||||||
|
1. `skill://<name>/SKILL.md`
|
||||||
|
2. `skill://<name>/_manifest` when needed
|
||||||
|
3. `skill://<name>/<supporting-path>` for selected supporting files
|
||||||
|
|
||||||
|
Selection rules:
|
||||||
|
|
||||||
|
- Prefer the closest `name` and `description` match.
|
||||||
|
- Keep context bounded; do not load many skill documents speculatively.
|
||||||
|
- If confidence is low after reading at most two main files, ask one clarifying question before loading more context.
|
||||||
|
|
||||||
|
Repository-specific guidance:
|
||||||
|
|
||||||
|
- For tasks about adding or modifying a skill, use `skill://copilot-customization/SKILL.md` when relevant.
|
||||||
|
- Keep skills provider-native; do not add custom skill catalogs, per-skill resource modules, or skill-specific discovery tools.
|
||||||
|
|
||||||
|
|
||||||
|
## Python Checks
|
||||||
|
|
||||||
|
After changes run these commands to confirm functionality. Resolve any errors
|
||||||
|
|
||||||
|
```python
|
||||||
|
uv run ruff check
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
uv run ty check
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
@@ -10,6 +10,6 @@ For FastMCP implementation or protocol questions, load `skill://mcp-details/SKIL
|
|||||||
|
|
||||||
Inspect `skill://mcp-details/_manifest` only when a source reference is needed, then read the relevant supporting path. For FastMCP Python APIs, prefer the supporting reference that covers SDKs and FastMCP.
|
Inspect `skill://mcp-details/_manifest` only when a source reference is needed, then read the relevant supporting path. For FastMCP Python APIs, prefer the supporting reference that covers SDKs and FastMCP.
|
||||||
|
|
||||||
This repository is resource-first and exposes resources as tools only as a fallback. In tool-only clients, use `list_resources` and `read_resource` with the same `skill://` URIs.
|
This repository exposes skills through native MCP resources and prompts through native MCP prompt operations.
|
||||||
|
|
||||||
Reconcile the skill guidance with the installed FastMCP version and the repository's existing implementation before editing.
|
Reconcile the skill guidance with the installed FastMCP version and the repository's existing implementation before editing.
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
## Plan: Docs-First FastMCP End State
|
|
||||||
|
|
||||||
Create a docs-first FastMCP architecture where all Markdown remains in docs/ as the only source of truth, each skill is Anthropic-compatible in its own directory, skill metadata lives in SKILL.md frontmatter, and packaged docs are served through importlib.resources so stdio deployments work from installed wheels.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
1. Phase 1: Define the end-state content contract. Confirm canonical structure as docs/skills/<skill-id>/SKILL.md plus docs/skills/<skill-id>/references/..., with strict per-skill ownership and no metadata.yaml sidecar. Also define stable skill-id rules (kebab-case, immutable after release). Deliverable: update the current docs/ directory with the finalized end-state content contract from this step.
|
|
||||||
2. Phase 1: Define SKILL.md frontmatter schema with Pydantic-compatible fields: id, version, name, description, tags, capabilities, depends_on, and references manifest entries. The references manifest must map logical reference ids to relative paths so each skill can reorganize references internally without changing global server code. Depends on step 1. Deliverable: update the current docs/ directory with the finalized SKILL.md frontmatter schema from this step.
|
|
||||||
3. Phase 1: Define URI contract with explicit break-and-replace policy. Recommend resource://catalog/skills_index, resource://catalog/skills/{skill_id}, resource://skills/{skill_id}/document, resource://skills/{skill_id}/references/{ref_id}, and resource://docs/{path*}. Evolving URIs and reference ids requires direct replacement, with no aliases or compatibility shims. Depends on steps 1-2. Deliverable: update the current docs/ directory with the finalized URI contract and break-and-replace policy from this step.
|
|
||||||
4. Phase 2: Build a docs registry loader that reads packaged docs via importlib.resources.files(...) Traversable APIs, parses SKILL.md frontmatter, validates schema, and creates an in-memory registry keyed by skill_id. Fail fast for duplicate ids, missing files, broken reference mappings, or invalid depends_on. Depends on steps 2-3.
|
|
||||||
5. Phase 2: Register FastMCP resources from the registry using RFC6570 templates (including wildcard paths where appropriate), read-only/idempotent annotations, explicit mime types, and on_duplicate_resources="error" for startup safety. Depends on step 4.
|
|
||||||
6. Phase 2: Add discovery surfaces as resources first, then tool fallback. Keep catalog discovery in resources, then add ResourcesAsTools for tool-only clients. Add thin discovery tools only for parity and optional BM25/regex tool search when catalog/tool volume grows enough to affect token efficiency. Define canonical fallback tool names (`list_resources`, `read_resource`, `search_patterns`, `get_pattern_by_id`, `get_skill_document_by_id`), research host-specific naming behavior for GitHub Copilot, Cursor, Claude Desktop, and generic MCP clients, and require client-side name mapping or intentionally documented aliases when providers expose namespaced wrappers. Depends on step 5.
|
|
||||||
7. Phase 3: Implement packaging so docs/ is copied into package resource space at build time (wheel + sdist) while docs/ remains canonical in source control. Use importlib.resources at runtime only; avoid direct filesystem assumptions. Depends on steps 4-6.
|
|
||||||
8. Phase 3: Remove materialization coupling between skill source modules and docs. The website build reads docs/ directly, while MCP reads packaged docs resources from the installed package. This preserves one authored source with two distribution surfaces. Depends on step 7.
|
|
||||||
9. Phase 4: Add validation and CI gates: frontmatter schema checks, URI uniqueness checks, reference integrity checks, docs build check, package content check, and stdio smoke checks that read representative skill/document resources from an installed wheel. Depends on steps 5-8.
|
|
||||||
10. Phase 4: Add long-term maintainability guardrails: architecture decision record for URI and schema contracts, skill authoring checklist, and release checklist for evolving references safely within one skill. Parallel with step 9 after core architecture is stable.
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
**Relevant files**
|
|
||||||
- /home/john/Documents/prompts/docs/index.md — Keep top-level docs entry and explain docs-first architecture contract.
|
|
||||||
- /home/john/Documents/prompts/docs/skills — Canonical location for all skill content, including SKILL.md and references.
|
|
||||||
- /home/john/Documents/prompts/pyproject.toml — Build inclusion rules for packaged markdown resources in wheel/sdist.
|
|
||||||
- /home/john/Documents/prompts/src/personal_mcp/main.py — App/server startup wiring for resource registry initialization.
|
|
||||||
- /home/john/Documents/prompts/src/personal_mcp/mcp.py — FastMCP instance composition and transform registration.
|
|
||||||
- /home/john/Documents/prompts/src/personal_mcp/catalog/server.py — Catalog resource and fallback discovery behavior.
|
|
||||||
- /home/john/Documents/prompts/src/personal_mcp/skills/document_loader.py — Replace file-path assumptions with importlib.resources docs registry loading.
|
|
||||||
- /home/john/Documents/prompts/src/personal_mcp/web/materialize_skill_docs.py — De-scope or retire materialization once docs-first runtime is authoritative.
|
|
||||||
|
|
||||||
**Verification**
|
|
||||||
1. Run uv run zensical build to verify docs/ remains valid and site output is stable.
|
|
||||||
2. Run uv run pytest -q with tests that validate frontmatter parsing, URI generation, reference mapping, and catalog responses.
|
|
||||||
3. Run a packaging integrity check using importlib.resources.files(...) to confirm packaged docs resources exist and are readable from an installed wheel.
|
|
||||||
4. Run a stdio MCP smoke test that lists resources and reads at least one skill document and one reference document.
|
|
||||||
5. Run fallback-client smoke tests verifying list_resources/read_resource tools work and return expected metadata for both static and templated resources, and that GitHub Copilot, Cursor, Claude Desktop, and protocol-level SDK tests use canonical tool names or documented mapped aliases.
|
|
||||||
|
|
||||||
**Decisions**
|
|
||||||
- Anthropic compatibility: strict skill directory pattern with SKILL.md and references subtree.
|
|
||||||
- Metadata strategy: YAML frontmatter in SKILL.md (no separate metadata file).
|
|
||||||
- Discovery strategy: resource-first catalog with tool fallback for tool-only MCP clients.
|
|
||||||
- Included scope: ideal end-state architecture, contracts, validation, and packaging for stdio operation.
|
|
||||||
- Excluded scope: migration mechanics from current implementation, backward-compat shim details, and docs visual redesign.
|
|
||||||
|
|
||||||
**Further Considerations**
|
|
||||||
1. Prefer recursive references support under each skill plus frontmatter manifest ids, so skill teams can reorganize internal reference folders without URI churn.
|
|
||||||
2. Define a hard rule that skill_id and directory name must match exactly to eliminate namespace/slug drift classes of bugs.
|
|
||||||
3. Do not provide URI aliases; client updates must track canonical URI contract changes directly.
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
**Phase 3 Results: Packaging Contract and Surface Decoupling (Wheel/sdist Resources + Docs-Only Authoring)**
|
|
||||||
|
|
||||||
This section finalizes Phase 3 by defining how authored docs are packaged as runtime resources, how runtime loading avoids filesystem assumptions, and how website and MCP distribution surfaces are decoupled while sharing one authored source.
|
|
||||||
|
|
||||||
### Greenfield Framing (Normative)
|
|
||||||
|
|
||||||
This Phase 3 design assumes a full refactor with intentional break-and-replace behavior:
|
|
||||||
|
|
||||||
1. No compatibility shims, aliases, adapter layers, or dual-read runtime paths.
|
|
||||||
2. No runtime dependency on repository checkout layout.
|
|
||||||
3. Runtime docs access is package-resource-only.
|
|
||||||
4. Canonical authoring remains in `docs/` in source control.
|
|
||||||
|
|
||||||
### Research Baseline (Packaging + Runtime)
|
|
||||||
|
|
||||||
Authoritative references used for this phase:
|
|
||||||
|
|
||||||
1. Python `importlib.resources` docs (`files`, `Traversable`, and zip-safe behavior)
|
|
||||||
2. Python packaging guidance for wheel/sdist data inclusion
|
|
||||||
3. Hatchling build target configuration guidance for including non-code files
|
|
||||||
4. Existing repository constraints from Steps 4-5 (registry-first, deterministic startup, resource-first discovery)
|
|
||||||
|
|
||||||
Best-practice conclusions applied to this design:
|
|
||||||
|
|
||||||
1. Package docs as build artifacts so runtime reads work from installed wheels.
|
|
||||||
2. Keep docs source-of-truth in one place (`docs/`) and project into package resource space at build time.
|
|
||||||
3. Avoid `Path(__file__)`/repo-root probing in runtime paths.
|
|
||||||
4. Enforce parity across wheel and sdist so local/dev/prod behavior does not drift.
|
|
||||||
|
|
||||||
### Phase 3 Responsibilities (Normative)
|
|
||||||
|
|
||||||
Phase 3 MUST:
|
|
||||||
|
|
||||||
1. Ensure authored markdown under `docs/` is included in wheel and sdist artifacts.
|
|
||||||
2. Ensure runtime docs registry/document reads use `importlib.resources` only.
|
|
||||||
3. Ensure MCP runtime behavior is independent of current working directory or checkout structure.
|
|
||||||
4. Ensure website docs build continues to consume source `docs/` directly.
|
|
||||||
5. Remove materialization/path-probing coupling from runtime loader code.
|
|
||||||
6. Preserve deterministic packaged docs layout for registry/resource URI generation.
|
|
||||||
|
|
||||||
### Packaging Contract (Wheel + sdist)
|
|
||||||
|
|
||||||
Canonical packaging behavior:
|
|
||||||
|
|
||||||
1. Source-authored docs remain at repository root: `docs/`.
|
|
||||||
2. Build projects docs into package resource space under `personal_mcp/docs/` inside artifacts.
|
|
||||||
3. Runtime anchor for docs loading is `importlib.resources.files("personal_mcp").joinpath("docs")`.
|
|
||||||
4. Build artifacts MUST include:
|
|
||||||
- top-level docs pages used by discovery/overview
|
|
||||||
- `docs/skills/<skill-id>/SKILL.md`
|
|
||||||
- `docs/skills/<skill-id>/references/**`
|
|
||||||
|
|
||||||
Parity requirements:
|
|
||||||
|
|
||||||
1. Wheel and sdist contain equivalent docs content for runtime use.
|
|
||||||
2. Missing docs resources in either artifact is a hard validation failure.
|
|
||||||
|
|
||||||
### Build-System Plan (pyproject + build)
|
|
||||||
|
|
||||||
Primary target file:
|
|
||||||
|
|
||||||
1. `pyproject.toml`
|
|
||||||
|
|
||||||
Configuration goals:
|
|
||||||
|
|
||||||
1. Add explicit build inclusion rules so docs resources are shipped in wheel artifacts.
|
|
||||||
2. Add explicit sdist inclusion rules so docs are present for source builds.
|
|
||||||
3. Keep inclusion deterministic and auditable (no implicit glob side effects beyond intended docs content).
|
|
||||||
4. Ensure packaged destination path matches runtime anchor (`personal_mcp/docs`).
|
|
||||||
|
|
||||||
Implementation note:
|
|
||||||
|
|
||||||
1. Use Hatchling-native inclusion mapping (for example force-include or equivalent target-level include mapping) to project `docs/` into package resource space.
|
|
||||||
2. Prefer one clear packaging path over multiple fallback packaging mechanisms.
|
|
||||||
|
|
||||||
### Runtime Loader Contract (No Filesystem Assumptions)
|
|
||||||
|
|
||||||
Primary target file:
|
|
||||||
|
|
||||||
1. `src/personal_mcp/skills/document_loader.py`
|
|
||||||
|
|
||||||
Required runtime behavior:
|
|
||||||
|
|
||||||
1. Remove repository-root discovery helpers and path-probing candidates.
|
|
||||||
2. Remove metadata-based document path overrides that bypass canonical skill layout.
|
|
||||||
3. Resolve SKILL and reference documents via package-resource-relative paths only.
|
|
||||||
4. Keep reads UTF-8 and deterministic.
|
|
||||||
5. Raise explicit errors for missing packaged resources; no fallback probing.
|
|
||||||
|
|
||||||
Prohibited runtime behavior:
|
|
||||||
|
|
||||||
1. No `Path(__file__).resolve().parents[...]` lookup for docs.
|
|
||||||
2. No implicit fallback to source-tree `docs/` during runtime reads.
|
|
||||||
3. No slug-guessing or namespace substitution for path recovery.
|
|
||||||
|
|
||||||
### Surface Decoupling Contract (Website vs MCP)
|
|
||||||
|
|
||||||
Website surface:
|
|
||||||
|
|
||||||
1. Website build pipeline consumes source `docs/` directly (`uv run zensical build`).
|
|
||||||
2. Static output (`site/`) remains a build artifact served by web mounting logic.
|
|
||||||
|
|
||||||
MCP surface:
|
|
||||||
|
|
||||||
1. MCP runtime serves docs from packaged resources loaded by registry/resource handlers.
|
|
||||||
2. MCP does not read `site/` and does not depend on website build artifacts.
|
|
||||||
|
|
||||||
Decoupling guarantees:
|
|
||||||
|
|
||||||
1. One authored source (`docs/`), two distribution surfaces (website + MCP runtime).
|
|
||||||
2. Changes to website serving do not alter MCP resource loading semantics.
|
|
||||||
3. Changes to MCP runtime loader do not require website materialization logic.
|
|
||||||
|
|
||||||
### Integration Plan for Existing Modules
|
|
||||||
|
|
||||||
Primary integration targets:
|
|
||||||
|
|
||||||
1. `pyproject.toml`: add wheel/sdist docs inclusion mapping.
|
|
||||||
2. `src/personal_mcp/skills/document_loader.py`: replace filesystem probing with package-resource loading.
|
|
||||||
3. `src/personal_mcp/main.py`: keep startup composition deterministic once registry/resource registration is in place.
|
|
||||||
4. `src/personal_mcp/mcp.py`: maintain registry-driven resource composition as canonical runtime surface.
|
|
||||||
5. `src/personal_mcp/web/docs_mount.py`: continue static-site mount behavior without coupling to MCP runtime docs loading.
|
|
||||||
|
|
||||||
Cleanup targets:
|
|
||||||
|
|
||||||
1. Remove obsolete references to materialization-only modules if no longer present/used.
|
|
||||||
2. Remove dead code paths that attempt source-tree fallback loading.
|
|
||||||
|
|
||||||
### Validation and Test Plan (Phase 3 Scope)
|
|
||||||
|
|
||||||
Build/package validation:
|
|
||||||
|
|
||||||
1. Build wheel and sdist in CI/local.
|
|
||||||
2. Inspect artifacts to confirm `personal_mcp/docs/**` exists and includes representative skill/reference files.
|
|
||||||
3. Install built wheel in isolated environment and verify resource reads via `importlib.resources.files(...)`.
|
|
||||||
|
|
||||||
Runtime validation:
|
|
||||||
|
|
||||||
1. Run MCP in an environment where repo-root docs paths are unavailable and confirm reads still succeed.
|
|
||||||
2. Verify representative URIs resolve (skill document and reference document).
|
|
||||||
3. Confirm startup fails clearly if required packaged docs resources are missing.
|
|
||||||
|
|
||||||
Decoupling validation:
|
|
||||||
|
|
||||||
1. Run `uv run zensical build` to verify website pipeline still consumes source `docs/`.
|
|
||||||
2. Confirm MCP runtime does not require `site/` presence.
|
|
||||||
3. Confirm web static serving behavior is unchanged when docs are built.
|
|
||||||
|
|
||||||
Expected command path in this repo:
|
|
||||||
|
|
||||||
1. `uv run pytest -q`
|
|
||||||
2. `uv run zensical build`
|
|
||||||
|
|
||||||
### Acceptance Criteria for Phase 3 Completion
|
|
||||||
|
|
||||||
Phase 3 is complete when all are true:
|
|
||||||
|
|
||||||
1. Wheel and sdist include docs resources in deterministic package paths.
|
|
||||||
2. Runtime docs loading works from installed artifacts using `importlib.resources` only.
|
|
||||||
3. Runtime docs loading has no checkout-path dependency and no fallback probing.
|
|
||||||
4. Website docs build remains source-docs-driven and independent of MCP runtime loading.
|
|
||||||
5. No compatibility shims, aliases, or dual runtime loader paths exist.
|
|
||||||
|
|
||||||
### Non-goals for Phase 3
|
|
||||||
|
|
||||||
1. No Step 6 discovery-tool fallback implementation details.
|
|
||||||
2. No URI aliasing or backward-compat transition mechanics.
|
|
||||||
3. No redesign of skill frontmatter/schema contracts already finalized in earlier steps.
|
|
||||||
4. No web UI visual redesign or docs IA overhaul.
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
**Step 1 Results: End-State Content Contract**
|
|
||||||
|
|
||||||
This section finalizes Step 1 by defining the canonical authored content model.
|
|
||||||
|
|
||||||
### Step Deliverable
|
|
||||||
|
|
||||||
- Update the current `docs/` directory with the finalized Step 1 content contract from this document.
|
|
||||||
|
|
||||||
### Canonical source of truth
|
|
||||||
|
|
||||||
- All authored Markdown lives under `docs/`.
|
|
||||||
- MCP resources and static docs are two distribution surfaces of the same authored files.
|
|
||||||
- No parallel authored markdown is allowed in `src/` or other package-only paths.
|
|
||||||
|
|
||||||
### Canonical skill shape (Anthropic-compatible)
|
|
||||||
|
|
||||||
Each skill is one directory under `docs/skills/`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/
|
|
||||||
skills/
|
|
||||||
<skill-id>/
|
|
||||||
SKILL.md
|
|
||||||
references/
|
|
||||||
... (one or more markdown files, optional nested folders)
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- `SKILL.md` is required for every skill.
|
|
||||||
- `references/` is the only place for skill-specific supporting docs.
|
|
||||||
- Nested folders inside `references/` are allowed so a skill can reorganize internals without changing global architecture.
|
|
||||||
- Skill directories are independent ownership boundaries; no cross-skill file writes.
|
|
||||||
|
|
||||||
### File placement and ownership boundaries
|
|
||||||
|
|
||||||
- Top-level project docs stay in `docs/*.md`.
|
|
||||||
- Skill docs stay in `docs/skills/<skill-id>/...`.
|
|
||||||
- A skill may link to other skills, but must not store content inside another skill's directory.
|
|
||||||
- Server/runtime code may index and serve docs, but must not be the source of authored markdown.
|
|
||||||
|
|
||||||
### Metadata location constraint
|
|
||||||
|
|
||||||
- Skill metadata is embedded in YAML frontmatter in `SKILL.md`.
|
|
||||||
- No `metadata.yaml` sidecar in the end state.
|
|
||||||
- Reference lookup metadata (ids to relative paths) is declared from `SKILL.md` frontmatter, not inferred as a hidden global convention.
|
|
||||||
|
|
||||||
### Skill-id contract (change-friendly)
|
|
||||||
|
|
||||||
`skill-id` is the public identifier and SHOULD satisfy all rules below:
|
|
||||||
|
|
||||||
- Format: lowercase kebab-case only.
|
|
||||||
- Character set: `a-z`, `0-9`, and `-`.
|
|
||||||
- Must start with a letter.
|
|
||||||
- No underscores, spaces, dots, or uppercase characters.
|
|
||||||
- Directory name should equal `skill-id` in each committed revision.
|
|
||||||
- Frontmatter `id` should equal directory name in each committed revision.
|
|
||||||
- Treat `skill-id` as immutable after release; any rename is a breaking replacement and clients must move to the new id.
|
|
||||||
|
|
||||||
Example valid ids:
|
|
||||||
|
|
||||||
- `fastapi-uv-docker`
|
|
||||||
- `zensical-docs`
|
|
||||||
- `pytesting`
|
|
||||||
|
|
||||||
Example invalid ids:
|
|
||||||
|
|
||||||
- `fastapi_uv_docker` (underscore)
|
|
||||||
- `Zensical-Docs` (uppercase)
|
|
||||||
- `docs.zensical` (dot)
|
|
||||||
|
|
||||||
### Invariants this contract guarantees
|
|
||||||
|
|
||||||
- One authored source tree (`docs/`) for both website and MCP.
|
|
||||||
- One skill directory maps to one skill identity per revision.
|
|
||||||
- Namespace/slug drift is minimized by keeping directory and frontmatter ids aligned per revision.
|
|
||||||
- Per-skill reference structure can evolve without changing cross-skill architecture.
|
|
||||||
- Packaging for stdio is deterministic because authored content is path-stable.
|
|
||||||
|
|
||||||
### Non-goals for Step 1
|
|
||||||
|
|
||||||
- No URI versioning policy details yet (handled in Step 3).
|
|
||||||
- No full frontmatter schema details yet (handled in Step 2).
|
|
||||||
- No migration instructions from current architecture (out of scope for this plan).
|
|
||||||
@@ -1,298 +0,0 @@
|
|||||||
**Step 2 Results: SKILL.md Frontmatter and FastMCP Metadata Contract**
|
|
||||||
|
|
||||||
This section finalizes Step 2 by defining the canonical SKILL.md frontmatter schema, separating Anthropic-supported fields from repository extension fields, and mapping frontmatter to FastMCP-native metadata surfaces for resources and tools.
|
|
||||||
|
|
||||||
### Step Deliverable
|
|
||||||
|
|
||||||
- Update the current `docs/` directory with the finalized Step 2 frontmatter and metadata contract content from this document.
|
|
||||||
|
|
||||||
### Anthropic Frontmatter Support (Research Baseline)
|
|
||||||
|
|
||||||
Across Anthropic API and Agent Skills specification surfaces:
|
|
||||||
|
|
||||||
- Required for custom skill bundles: `name`, `description`.
|
|
||||||
- `name` constraints (Agent Skills API docs): 1-64 chars, lowercase letters/numbers/hyphens, no XML tags, and must not use reserved words `anthropic` or `claude`.
|
|
||||||
- `description` constraints (Agent Skills API docs): 1-1024 chars, non-empty, no XML tags.
|
|
||||||
|
|
||||||
Portable optional fields from the Agent Skills specification:
|
|
||||||
|
|
||||||
- `license`
|
|
||||||
- `compatibility`
|
|
||||||
- `metadata`
|
|
||||||
- `allowed-tools` (experimental)
|
|
||||||
|
|
||||||
Claude Code-specific optional fields (supported by Claude Code skills docs):
|
|
||||||
|
|
||||||
- `when_to_use`, `argument-hint`, `arguments`
|
|
||||||
- `disable-model-invocation`, `user-invocable`
|
|
||||||
- `allowed-tools`, `disallowed-tools`
|
|
||||||
- `model`, `effort`, `context`, `agent`, `hooks`, `paths`, `shell`
|
|
||||||
|
|
||||||
Contract decision for this repository:
|
|
||||||
|
|
||||||
- Treat `name` and `description` as required in all SKILL.md files, even where a client could infer defaults.
|
|
||||||
- Keep Anthropic-facing semantics in standard fields and keep MCP indexing metadata in a namespaced extension block.
|
|
||||||
- Preserve forward compatibility by allowing additive optional metadata fields over time.
|
|
||||||
|
|
||||||
### Canonical Frontmatter Schema For This Repository
|
|
||||||
|
|
||||||
Use this exact two-layer pattern:
|
|
||||||
|
|
||||||
1. Anthropic layer (portable): top-level fields intended for Anthropic/Agent Skills behavior.
|
|
||||||
2. Repository layer (runtime indexing): one namespaced block, `x-personal-mcp`, for MCP catalog and routing metadata.
|
|
||||||
|
|
||||||
Canonical shape:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: <skill-id>
|
|
||||||
description: <what this skill does and when to use it>
|
|
||||||
|
|
||||||
# Optional Anthropic/Agent Skills fields (use only when needed)
|
|
||||||
when_to_use: <extra trigger guidance>
|
|
||||||
allowed-tools: <space-separated string or YAML list>
|
|
||||||
disable-model-invocation: false
|
|
||||||
user-invocable: true
|
|
||||||
license: <optional>
|
|
||||||
compatibility: <optional>
|
|
||||||
|
|
||||||
# Repository-specific metadata (authoritative for MCP indexing)
|
|
||||||
x-personal-mcp:
|
|
||||||
id: <skill-id>
|
|
||||||
version: <semver>
|
|
||||||
tags:
|
|
||||||
- <tag>
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/<skill-id>/document
|
|
||||||
depends_on: []
|
|
||||||
references:
|
|
||||||
<ref-id>:
|
|
||||||
path: references/<file>.md
|
|
||||||
mime_type: text/markdown
|
|
||||||
title: <short title>
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
### Repository Metadata Field Rules (`x-personal-mcp`)
|
|
||||||
|
|
||||||
- `id` required: must follow Step 1 skill-id rules and equal directory name.
|
|
||||||
- `version` required: semantic version string.
|
|
||||||
- `tags` optional: list of kebab-case discovery labels.
|
|
||||||
- `capabilities` required: list of MCP URIs this skill publishes.
|
|
||||||
- `depends_on` optional: list of other skill ids.
|
|
||||||
- `references` optional map:
|
|
||||||
- key is `ref-id` (kebab-case).
|
|
||||||
- `path` is a skill-relative markdown path and must stay inside the same skill directory.
|
|
||||||
- nested folders under `references/` are allowed.
|
|
||||||
- `mime_type` defaults to `text/markdown` if omitted.
|
|
||||||
- `title` is an optional display label.
|
|
||||||
- renaming `ref-id` values is allowed when needed; optional aliases may be used during transitions.
|
|
||||||
|
|
||||||
### Pydantic Models For Frontmatter Validation
|
|
||||||
|
|
||||||
Define the Step 2 contract with Pydantic v2 models and change-friendly validation.
|
|
||||||
|
|
||||||
Normative model sketch:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
||||||
|
|
||||||
SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
||||||
SEMVER_RE = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
|
||||||
|
|
||||||
|
|
||||||
class ReferenceEntry(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
path: str
|
|
||||||
mime_type: str = "text/markdown"
|
|
||||||
title: str | None = None
|
|
||||||
|
|
||||||
@field_validator("path")
|
|
||||||
@classmethod
|
|
||||||
def validate_reference_path(cls, value: str) -> str:
|
|
||||||
p = PurePosixPath(value)
|
|
||||||
if p.is_absolute() or ".." in p.parts:
|
|
||||||
raise ValueError("reference path must be a relative in-skill path")
|
|
||||||
if not str(p).startswith("references/"):
|
|
||||||
raise ValueError("reference path must stay under references/")
|
|
||||||
if p.suffix.lower() != ".md":
|
|
||||||
raise ValueError("reference path must target a markdown file")
|
|
||||||
return str(p)
|
|
||||||
|
|
||||||
|
|
||||||
class PersonalMcpMetadata(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
id: str
|
|
||||||
version: str
|
|
||||||
tags: list[str] = Field(default_factory=list)
|
|
||||||
capabilities: list[str] = Field(min_length=1)
|
|
||||||
depends_on: list[str] = Field(default_factory=list)
|
|
||||||
references: dict[str, ReferenceEntry] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
@field_validator("id")
|
|
||||||
@classmethod
|
|
||||||
def validate_id(cls, value: str) -> str:
|
|
||||||
if not SKILL_ID_RE.fullmatch(value):
|
|
||||||
raise ValueError("id must be lowercase kebab-case and start with a letter")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("version")
|
|
||||||
@classmethod
|
|
||||||
def validate_version(cls, value: str) -> str:
|
|
||||||
if not SEMVER_RE.fullmatch(value):
|
|
||||||
raise ValueError("version must be semver")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("depends_on")
|
|
||||||
@classmethod
|
|
||||||
def validate_depends_on(cls, value: list[str]) -> list[str]:
|
|
||||||
for dep in value:
|
|
||||||
if not SKILL_ID_RE.fullmatch(dep):
|
|
||||||
raise ValueError(f"invalid depends_on skill id: {dep}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("references")
|
|
||||||
@classmethod
|
|
||||||
def validate_reference_ids(cls, value: dict[str, ReferenceEntry]) -> dict[str, ReferenceEntry]:
|
|
||||||
for ref_id in value:
|
|
||||||
if not SKILL_ID_RE.fullmatch(ref_id):
|
|
||||||
raise ValueError(f"invalid reference id: {ref_id}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def ensure_primary_capability(self) -> "PersonalMcpMetadata":
|
|
||||||
expected = f"resource://skills/{self.id}/document"
|
|
||||||
if expected not in self.capabilities:
|
|
||||||
raise ValueError(f"capabilities must include {expected}")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
class SkillFrontmatter(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
# Anthropic/Agent Skills fields
|
|
||||||
name: str = Field(min_length=1, max_length=64)
|
|
||||||
description: str = Field(min_length=1, max_length=1024)
|
|
||||||
when_to_use: str | None = None
|
|
||||||
allowed_tools: str | list[str] | None = Field(default=None, alias="allowed-tools")
|
|
||||||
disallowed_tools: str | list[str] | None = Field(default=None, alias="disallowed-tools")
|
|
||||||
disable_model_invocation: bool | None = Field(default=None, alias="disable-model-invocation")
|
|
||||||
user_invocable: bool | None = Field(default=None, alias="user-invocable")
|
|
||||||
argument_hint: str | None = Field(default=None, alias="argument-hint")
|
|
||||||
arguments: str | list[str] | None = None
|
|
||||||
license: str | None = None
|
|
||||||
compatibility: str | None = None
|
|
||||||
metadata: dict[str, str] | None = None
|
|
||||||
|
|
||||||
# Repository extension block
|
|
||||||
x_personal_mcp: PersonalMcpMetadata = Field(alias="x-personal-mcp")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, value: str) -> str:
|
|
||||||
if not SKILL_ID_RE.fullmatch(value):
|
|
||||||
raise ValueError("name must be lowercase kebab-case and start with a letter")
|
|
||||||
if "anthropic" in value or "claude" in value:
|
|
||||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def cross_validate(self) -> "SkillFrontmatter":
|
|
||||||
if self.x_personal_mcp.id != self.name:
|
|
||||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
|
||||||
return self
|
|
||||||
|
|
||||||
|
|
||||||
def validate_skill_frontmatter(raw: dict[str, Any], skill_dir_name: str) -> SkillFrontmatter:
|
|
||||||
model = SkillFrontmatter.model_validate(raw)
|
|
||||||
if model.name != skill_dir_name:
|
|
||||||
raise ValueError("frontmatter name must exactly match skill directory name")
|
|
||||||
return model
|
|
||||||
```
|
|
||||||
|
|
||||||
Validation behavior contract:
|
|
||||||
|
|
||||||
- Validate required core fields and relationships during registry load before FastMCP resource/tool registration.
|
|
||||||
- Allow unknown additive fields so frontmatter can evolve without blocking startup.
|
|
||||||
- Treat hard contract violations (missing required fields, invalid ids, broken required mappings) as startup errors.
|
|
||||||
- Treat non-critical compatibility issues as warnings when possible.
|
|
||||||
- Error messages should include skill path and failing field for CI readability.
|
|
||||||
|
|
||||||
Projection mode contract (for Anthropic API upload pipelines):
|
|
||||||
|
|
||||||
- Parse with `SkillFrontmatter` first.
|
|
||||||
- Emit Anthropic-safe frontmatter with standard fields only.
|
|
||||||
- Serialize repository metadata into standard `metadata` as namespaced keys.
|
|
||||||
- Preserve the canonical authored source in `x-personal-mcp`; projection output is a build artifact.
|
|
||||||
|
|
||||||
### Anthropic Upload Compatibility Rule
|
|
||||||
|
|
||||||
- Anthropic documentation guarantees behavior for standard frontmatter fields but does not explicitly guarantee handling of arbitrary unknown top-level keys.
|
|
||||||
- Therefore, publishing pipelines that target strict API compatibility should support a projection mode that emits only standard frontmatter fields for upload.
|
|
||||||
- In projection mode, repository extension metadata is serialized into the standard `metadata` field (for example as namespaced keys or JSON-encoded values), while source-of-truth authoring remains in `x-personal-mcp`.
|
|
||||||
|
|
||||||
### FastMCP Native Metadata Surfaces (Research Baseline)
|
|
||||||
|
|
||||||
Resources (`@mcp.resource` and templates) support native definition metadata:
|
|
||||||
|
|
||||||
- `name`, `description`, `mime_type`, `tags`
|
|
||||||
- `annotations` (`readOnlyHint`, `idempotentHint`)
|
|
||||||
- `icons`
|
|
||||||
- `meta` (custom metadata passed through to the MCP client resource object)
|
|
||||||
- `version`
|
|
||||||
- `enabled` (deprecated in v3; prefer server-level `mcp.enable()` / `mcp.disable()`)
|
|
||||||
|
|
||||||
Resources support runtime metadata:
|
|
||||||
|
|
||||||
- `ResourceContent.meta` (item-level)
|
|
||||||
- `ResourceResult.meta` (result-level `_meta`)
|
|
||||||
|
|
||||||
Tools (`@mcp.tool`) support native definition metadata:
|
|
||||||
|
|
||||||
- `name`, `description`, `tags`
|
|
||||||
- `annotations` (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`)
|
|
||||||
- `icons`
|
|
||||||
- `meta` (custom metadata passed through to the MCP client tool object)
|
|
||||||
- `version`
|
|
||||||
- `timeout`, `output_schema`, `run_in_thread`
|
|
||||||
- `enabled` (deprecated in v3; prefer server-level `mcp.enable()` / `mcp.disable()`)
|
|
||||||
|
|
||||||
Tools support runtime metadata:
|
|
||||||
|
|
||||||
- `ToolResult.meta` (execution-level metadata for each call)
|
|
||||||
|
|
||||||
### Frontmatter To FastMCP Mapping Contract
|
|
||||||
|
|
||||||
At server startup, map `x-personal-mcp` fields into FastMCP registration as follows:
|
|
||||||
|
|
||||||
- `x-personal-mcp.id` -> canonical URI namespace and identity checks.
|
|
||||||
- `description` -> default `description` for the primary skill document resource.
|
|
||||||
- `x-personal-mcp.tags` -> `tags` on resources/tools.
|
|
||||||
- `x-personal-mcp.version` -> `version` on resources/tools.
|
|
||||||
- `x-personal-mcp.capabilities` -> registered URI list plus catalog exposure.
|
|
||||||
- `x-personal-mcp.references[*]` -> resource templates or concrete resources with:
|
|
||||||
- `mime_type` from reference entry (or default)
|
|
||||||
- `meta` including `skill_id`, `ref_id`, and source `path`
|
|
||||||
- read-only annotations for documentation resources
|
|
||||||
- `x-personal-mcp.depends_on` -> catalog dependency graph metadata and validation checks.
|
|
||||||
|
|
||||||
### Invariants This Contract Guarantees
|
|
||||||
|
|
||||||
- Anthropic-required frontmatter stays valid for custom skill upload and Claude Code loading.
|
|
||||||
- MCP-specific metadata remains embedded in SKILL.md frontmatter, with no `metadata.yaml` sidecar.
|
|
||||||
- FastMCP registration uses only native metadata fields for resources/tools.
|
|
||||||
- Reference ids and metadata can evolve with low-friction updates while internal file layout under `references/` stays refactor-friendly.
|
|
||||||
|
|
||||||
### Non-goals For Step 2
|
|
||||||
|
|
||||||
- No URI versioning/deprecation rollout policy details (handled in Step 3).
|
|
||||||
- No migration script design from existing `metadata.yaml` files.
|
|
||||||
- No runtime caching/indexing performance tuning details.
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
**Step 3 Results: URI Contract and Compatibility Policy**
|
|
||||||
|
|
||||||
This section finalizes Step 3 by defining the canonical resource URI contract, template parameter rules, and explicit compatibility/versioning policy for URIs and reference ids.
|
|
||||||
|
|
||||||
### Step Deliverable
|
|
||||||
|
|
||||||
- Update the current `docs/` directory with the finalized Step 3 URI contract and compatibility policy content from this document.
|
|
||||||
|
|
||||||
### Canonical URI Surface (Normative)
|
|
||||||
|
|
||||||
The public, preferred URIs are:
|
|
||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
|
||||||
2. `resource://catalog/skills/{skill_id}`
|
|
||||||
3. `resource://skills/{skill_id}/document`
|
|
||||||
4. `resource://skills/{skill_id}/references/{ref_id}`
|
|
||||||
5. `resource://docs/{path*}`
|
|
||||||
|
|
||||||
Contract intent:
|
|
||||||
|
|
||||||
- Catalog URIs are discovery surfaces.
|
|
||||||
- Skill URIs are primary per-skill guidance surfaces.
|
|
||||||
- Docs wildcard URI is a direct authored-markdown access surface under `docs/`.
|
|
||||||
|
|
||||||
### URI Semantics
|
|
||||||
|
|
||||||
`resource://catalog/skills_index`
|
|
||||||
|
|
||||||
- Returns a compact list of skill records for discovery.
|
|
||||||
- One entry per `skill_id`.
|
|
||||||
- Must include enough metadata for client-side selection (at minimum id, name, description, tags, capabilities).
|
|
||||||
|
|
||||||
`resource://catalog/skills/{skill_id}`
|
|
||||||
|
|
||||||
- Returns one normalized record for `skill_id`.
|
|
||||||
- Must include canonical document URI and declared reference ids.
|
|
||||||
- Returns not-found when `skill_id` does not exist.
|
|
||||||
|
|
||||||
`resource://skills/{skill_id}/document`
|
|
||||||
|
|
||||||
- Returns the canonical `SKILL.md` authored content for that skill.
|
|
||||||
- `skill_id` must match Step 1 stable id rules.
|
|
||||||
|
|
||||||
`resource://skills/{skill_id}/references/{ref_id}`
|
|
||||||
|
|
||||||
- Returns one reference document declared in the skill frontmatter references manifest.
|
|
||||||
- `ref_id` is the stable public handle for that reference document.
|
|
||||||
|
|
||||||
`resource://docs/{path*}`
|
|
||||||
|
|
||||||
- Returns authored markdown at a normalized relative path under `docs/`.
|
|
||||||
- Supports nested paths via RFC6570 wildcard expansion.
|
|
||||||
- Typical examples: `index.md`, `usage.md`, `skills/<skill-id>/SKILL.md`, `skills/<skill-id>/references/<file>.md`.
|
|
||||||
|
|
||||||
### Template Parameter and Validation Rules
|
|
||||||
|
|
||||||
`skill_id`
|
|
||||||
|
|
||||||
- Lowercase kebab-case.
|
|
||||||
- Must satisfy Step 1 stable id rules.
|
|
||||||
|
|
||||||
`ref_id`
|
|
||||||
|
|
||||||
- Lowercase kebab-case.
|
|
||||||
- Must be declared in the skill’s references manifest.
|
|
||||||
|
|
||||||
`path*`
|
|
||||||
|
|
||||||
- Relative POSIX path only.
|
|
||||||
- No leading slash.
|
|
||||||
- No `..` traversal segments.
|
|
||||||
- Resolves only inside `docs/`.
|
|
||||||
- This surface is markdown-only in end state (`.md` files).
|
|
||||||
|
|
||||||
### URI Versioning Policy
|
|
||||||
|
|
||||||
Default rule:
|
|
||||||
|
|
||||||
- Keep URIs unversioned by default.
|
|
||||||
- Allow URI and payload updates when they improve clarity or implementation simplicity.
|
|
||||||
|
|
||||||
Breaking-change rule:
|
|
||||||
|
|
||||||
- Breaking changes use direct replacement of the canonical URI family.
|
|
||||||
- No compatibility aliases or dual URI families are maintained in this greenfield phase.
|
|
||||||
|
|
||||||
FastMCP version metadata usage:
|
|
||||||
|
|
||||||
- Resource `version` metadata MAY be used for implementation/version discovery.
|
|
||||||
- URI readability and maintainability remain the primary contract.
|
|
||||||
|
|
||||||
### Reference ID Compatibility Policy
|
|
||||||
|
|
||||||
`ref_id` is the public identifier for a reference document, separate from file path.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Prefer keeping `ref_id` stable when practical.
|
|
||||||
- File paths may change without URI churn as long as the mapped `ref_id` resolves.
|
|
||||||
- If a reference is renamed, introduce a new `ref_id` and treat the old one as retired.
|
|
||||||
- Avoid reusing retired `ref_id` values for unrelated content.
|
|
||||||
|
|
||||||
### Invariants This Contract Guarantees
|
|
||||||
|
|
||||||
- One canonical URI pattern per core capability surface.
|
|
||||||
- Fast, low-friction URI evolution through direct replacement of canonical URIs.
|
|
||||||
- A single canonical catalog URI family with no alias maintenance overhead.
|
|
||||||
- Reference mappings can evolve with minimal churn.
|
|
||||||
|
|
||||||
### Non-goals For Step 3
|
|
||||||
|
|
||||||
- No implementation-specific transform wiring details (`VersionFilter`, mounts, provider composition).
|
|
||||||
- No migration script mechanics for auto-generating aliases.
|
|
||||||
- No authorization policy design for URI-level access control.
|
|
||||||
@@ -1,248 +0,0 @@
|
|||||||
**Step 4 Results: Docs Registry Loader Design (importlib.resources + Fail-Fast Validation)**
|
|
||||||
|
|
||||||
This section finalizes Step 4 by defining a production-ready docs registry loader that reads packaged docs through Python resource APIs, parses SKILL.md frontmatter, validates schema and cross-links, and builds an immutable in-memory registry keyed by skill_id.
|
|
||||||
|
|
||||||
### Greenfield Framing (Normative)
|
|
||||||
|
|
||||||
This Step 4 design is for the greenfield target state:
|
|
||||||
|
|
||||||
1. No legacy metadata sidecars (`metadata.yaml`) are part of the runtime contract.
|
|
||||||
2. No dual-loader compatibility path is required.
|
|
||||||
3. Registry loading from packaged resources is the only runtime source of truth.
|
|
||||||
4. Compatibility shims are prohibited.
|
|
||||||
|
|
||||||
### Research Baseline (Python + Design Guidance)
|
|
||||||
|
|
||||||
Authoritative references used for this step:
|
|
||||||
|
|
||||||
1. Python `importlib.resources` docs (`files`, `as_file`, `Traversable` APIs)
|
|
||||||
2. Python `importlib.resources.abc` docs (`Traversable`, path traversal semantics, joinpath compatibility notes)
|
|
||||||
3. Pydantic v2 model/validation docs (`model_validate`, `ValidationError`, strictness and extra handling)
|
|
||||||
4. Python packaging guidance for including package data in wheels/sdists
|
|
||||||
|
|
||||||
Best-practice conclusions applied to this design:
|
|
||||||
|
|
||||||
1. Prefer `importlib.resources.files(<package>).joinpath(...)` over filesystem assumptions so stdio deployments from installed wheels work.
|
|
||||||
2. Treat resources as potentially non-filesystem artifacts (zip-import compatible); only use `as_file(...)` when an actual OS path is required.
|
|
||||||
3. Validate metadata with explicit Pydantic models and fail startup on contract violations.
|
|
||||||
4. Keep registry load deterministic (sorted traversal, stable error messages, no hidden fallback mutations).
|
|
||||||
5. Resolve references via manifest ids declared in frontmatter, not by global file conventions.
|
|
||||||
|
|
||||||
### Loader Responsibilities (Normative)
|
|
||||||
|
|
||||||
The Step 4 loader MUST:
|
|
||||||
|
|
||||||
1. Read canonical docs from package resources (not repo-root paths).
|
|
||||||
2. Discover all skill directories under `docs/skills/` in packaged resources.
|
|
||||||
3. For each skill, read and parse `SKILL.md` frontmatter.
|
|
||||||
4. Validate frontmatter using the Step 2 schema contract.
|
|
||||||
5. Validate directory/id invariants from Step 1 (directory name equals frontmatter id).
|
|
||||||
6. Validate URI/reference semantics from Step 3 assumptions.
|
|
||||||
7. Build a single in-memory registry keyed by `skill_id`.
|
|
||||||
8. Fail fast on any integrity error before FastMCP resource registration.
|
|
||||||
9. Precompute compact discovery projections so index resources can be served without reading full markdown bodies at request time.
|
|
||||||
|
|
||||||
### Package Resource Contract
|
|
||||||
|
|
||||||
Runtime anchor:
|
|
||||||
|
|
||||||
1. The loader resolves content from an importable package anchor, for example `personal_mcp`.
|
|
||||||
2. Docs root is located as `files(anchor).joinpath("docs")` when docs are packaged at package root, or an equivalent configured subpath.
|
|
||||||
3. Skill root is `docs/skills`.
|
|
||||||
|
|
||||||
Resource assumptions:
|
|
||||||
|
|
||||||
1. `SKILL.md` is UTF-8 text.
|
|
||||||
2. Reference files declared in frontmatter are UTF-8 markdown by default unless otherwise declared.
|
|
||||||
3. Path resolution always remains inside the same skill directory.
|
|
||||||
|
|
||||||
### Registry Data Model
|
|
||||||
|
|
||||||
Build immutable runtime records with explicit structure:
|
|
||||||
|
|
||||||
1. `SkillRecord`
|
|
||||||
- `skill_id`
|
|
||||||
- `name`
|
|
||||||
- `description`
|
|
||||||
- `version`
|
|
||||||
- `tags`
|
|
||||||
- `capabilities`
|
|
||||||
- `depends_on`
|
|
||||||
- `document_uri`
|
|
||||||
- `document_relpath` (canonical resource-relative path)
|
|
||||||
- `references` map keyed by `ref_id`
|
|
||||||
2. `ReferenceRecord`
|
|
||||||
- `ref_id`
|
|
||||||
- `uri`
|
|
||||||
- `relpath`
|
|
||||||
- `mime_type`
|
|
||||||
- `title`
|
|
||||||
3. `DocsRegistry`
|
|
||||||
- `skills_by_id: dict[str, SkillRecord]`
|
|
||||||
- `skills_in_load_order: list[str]` (deterministic ordering)
|
|
||||||
- helper indexes for catalog payload generation
|
|
||||||
- `skills_summary_in_load_order: list[SkillSummaryRecord]` for progressive discovery responses
|
|
||||||
- filter indexes (for example by tag/capability) derived once at startup
|
|
||||||
|
|
||||||
4. `SkillSummaryRecord`
|
|
||||||
- `skill_id`
|
|
||||||
- `name`
|
|
||||||
- `description`
|
|
||||||
- `tags`
|
|
||||||
- `capabilities`
|
|
||||||
- `document_uri`
|
|
||||||
- optional `version`
|
|
||||||
|
|
||||||
Immutability rule:
|
|
||||||
|
|
||||||
1. Once built, registry records are treated as read-only for the process lifetime.
|
|
||||||
2. No runtime mutation during requests; refresh only via process restart.
|
|
||||||
|
|
||||||
### Frontmatter Parsing Contract
|
|
||||||
|
|
||||||
`SKILL.md` parse steps:
|
|
||||||
|
|
||||||
1. Read full markdown text from resource.
|
|
||||||
2. Parse YAML frontmatter block at file start (between the first two `---` delimiters).
|
|
||||||
3. Parse YAML with safe loader semantics.
|
|
||||||
4. Validate parsed object with Step 2 Pydantic model(s).
|
|
||||||
5. Preserve markdown body as document content payload.
|
|
||||||
|
|
||||||
Parsing failure behavior:
|
|
||||||
|
|
||||||
1. Missing frontmatter block: startup error.
|
|
||||||
2. Invalid YAML: startup error with skill path and YAML parser detail.
|
|
||||||
3. Missing required fields (`name`, `description`, `x-personal-mcp` contract fields): startup error.
|
|
||||||
|
|
||||||
### Validation Pipeline (Fail-Fast)
|
|
||||||
|
|
||||||
Validation happens in this order:
|
|
||||||
|
|
||||||
1. Structural discovery validation
|
|
||||||
- skill directory exists under `docs/skills`
|
|
||||||
- required `SKILL.md` exists for each discovered skill
|
|
||||||
2. Schema validation
|
|
||||||
- Pydantic frontmatter validation for all required and constrained fields
|
|
||||||
3. Identity validation
|
|
||||||
- frontmatter `name` equals `x-personal-mcp.id`
|
|
||||||
- frontmatter id equals skill directory name
|
|
||||||
4. Reference manifest validation
|
|
||||||
- unique `ref_id` keys per skill
|
|
||||||
- each manifest path is relative, in-skill, and under `references/`
|
|
||||||
- each manifest target exists and is a file
|
|
||||||
5. Dependency graph validation
|
|
||||||
- every `depends_on` target exists in discovered skill set
|
|
||||||
- no self-dependency
|
|
||||||
- cycle detection enabled (hard error on cycle)
|
|
||||||
6. Capability sanity checks
|
|
||||||
- required primary capability `resource://skills/{skill_id}/document` is present
|
|
||||||
7. Global uniqueness checks
|
|
||||||
- no duplicate `skill_id`
|
|
||||||
- no duplicate canonical resource URIs generated from registry
|
|
||||||
8. Discovery payload checks
|
|
||||||
- summary fields required by catalog index are present and non-empty
|
|
||||||
- summary generation does not require reading markdown body content during request handling
|
|
||||||
|
|
||||||
### Error Model and Reporting
|
|
||||||
|
|
||||||
Error handling contract:
|
|
||||||
|
|
||||||
1. Collect errors per validation phase for clarity, then raise one startup exception containing all findings.
|
|
||||||
2. Error messages must include:
|
|
||||||
- skill id (when known)
|
|
||||||
- packaged relative path
|
|
||||||
- violated rule
|
|
||||||
- actionable fix hint
|
|
||||||
3. If any error exists, registry is not published and FastMCP resource registration does not proceed.
|
|
||||||
|
|
||||||
Recommended exception shape:
|
|
||||||
|
|
||||||
1. `DocsRegistryValidationError(errors: list[RegistryIssue])`
|
|
||||||
2. `RegistryIssue` fields: `code`, `message`, `skill_id`, `path`, `hint`
|
|
||||||
|
|
||||||
### Determinism and Runtime Safety
|
|
||||||
|
|
||||||
Determinism rules:
|
|
||||||
|
|
||||||
1. Traverse directories in sorted order.
|
|
||||||
2. Normalize all stored relative paths to POSIX form.
|
|
||||||
3. Normalize ids/tags exactly once at parse boundary.
|
|
||||||
4. Produce stable catalog ordering to reduce client churn.
|
|
||||||
5. Produce stable summary projections and filter indexes from the same normalized source records.
|
|
||||||
|
|
||||||
Runtime safety rules:
|
|
||||||
|
|
||||||
1. No dependence on `Path(__file__)` or repository root.
|
|
||||||
2. No ad-hoc fallback probing across multiple locations.
|
|
||||||
3. No lazy validation deferred until first request.
|
|
||||||
|
|
||||||
### Integration Plan for Existing Modules
|
|
||||||
|
|
||||||
Primary integration target:
|
|
||||||
|
|
||||||
1. Implement the canonical package-resource-based registry loader in `src/personal_mcp/skills/document_loader.py` as the only supported runtime loader path.
|
|
||||||
|
|
||||||
Catalog integration:
|
|
||||||
|
|
||||||
1. Update `src/personal_mcp/catalog/server.py` to consume the shared in-memory registry as the only catalog data source.
|
|
||||||
2. Keep catalog payload normalization deterministic and sourced from registry records only.
|
|
||||||
|
|
||||||
Startup wiring:
|
|
||||||
|
|
||||||
1. Initialize registry once during app/server startup in `src/personal_mcp/main.py` or equivalent composition point.
|
|
||||||
2. Pass registry to resource registration step (Step 5).
|
|
||||||
|
|
||||||
### Proposed Loader API Surface
|
|
||||||
|
|
||||||
Use a small, testable API:
|
|
||||||
|
|
||||||
1. `load_docs_registry(*, package_anchor: str, docs_root: str = "docs") -> DocsRegistry`
|
|
||||||
2. `read_skill_document(registry: DocsRegistry, skill_id: str) -> DocumentPayload`
|
|
||||||
3. `read_skill_reference(registry: DocsRegistry, skill_id: str, ref_id: str) -> DocumentPayload`
|
|
||||||
|
|
||||||
Design constraints:
|
|
||||||
|
|
||||||
1. Loader functions are pure relative to package resources and input args.
|
|
||||||
2. No global mutable singleton required for unit tests.
|
|
||||||
3. Caching is explicit and owned by startup composition.
|
|
||||||
|
|
||||||
### Test and Validation Plan (Step 4 Scope)
|
|
||||||
|
|
||||||
Unit tests:
|
|
||||||
|
|
||||||
1. valid multi-skill registry load from packaged test fixtures
|
|
||||||
2. duplicate id detection
|
|
||||||
3. missing SKILL.md detection
|
|
||||||
4. invalid frontmatter field constraints
|
|
||||||
5. broken reference target detection
|
|
||||||
6. invalid depends_on target detection
|
|
||||||
7. cycle detection in depends_on graph
|
|
||||||
8. deterministic output ordering across runs
|
|
||||||
|
|
||||||
Packaging/runtime tests:
|
|
||||||
|
|
||||||
1. install built wheel in isolated env
|
|
||||||
2. load registry via `importlib.resources.files(...)`
|
|
||||||
3. assert representative skill document/reference are readable
|
|
||||||
|
|
||||||
Expected command path in this repo:
|
|
||||||
|
|
||||||
1. `uv run pytest -q`
|
|
||||||
|
|
||||||
### Acceptance Criteria for Step 4 Completion
|
|
||||||
|
|
||||||
Step 4 is complete when all are true:
|
|
||||||
|
|
||||||
1. Registry loads exclusively from packaged resources.
|
|
||||||
2. All Step 2 and Step 3 dependent validations are enforced at startup.
|
|
||||||
3. Invalid docs state blocks startup with actionable diagnostics.
|
|
||||||
4. Registry is deterministic and immutable for runtime use.
|
|
||||||
5. Catalog and later resource registration can consume registry without direct filesystem scanning.
|
|
||||||
|
|
||||||
### Non-goals for Step 4
|
|
||||||
|
|
||||||
1. No FastMCP resource registration wiring details (Step 5).
|
|
||||||
2. No discovery-tool fallback behavior design (Step 6).
|
|
||||||
3. No final packaging/build-system migration mechanics (Step 7).
|
|
||||||
4. No backward-compat alias rollout mechanics in the greenfield baseline.
|
|
||||||
5. No compatibility layer of any kind (URI aliases, dual reads, adapter shims, or legacy schema bridges).
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
**Step 5 Results: Registry-Driven FastMCP Resource Registration (RFC6570 + Startup Safety)**
|
|
||||||
|
|
||||||
This section finalizes Step 5 by defining how FastMCP resources are registered from the Step 4 docs registry using RFC6570 URI templates, explicit metadata, and strict duplicate-registration safety.
|
|
||||||
|
|
||||||
### Greenfield Framing (Normative)
|
|
||||||
|
|
||||||
This Step 5 design is for the greenfield target state:
|
|
||||||
|
|
||||||
1. Registry-driven resources are the primary and authoritative discovery/read surface.
|
|
||||||
2. No legacy per-skill hardcoded resource registration is retained.
|
|
||||||
3. Resource contracts are defined for net-new clients and replace prior contracts without transition shims.
|
|
||||||
4. Step 6 tool fallback layers on top of this resource contract, not as a competing source of truth.
|
|
||||||
5. Breaking changes are intentional in this full-refactor phase.
|
|
||||||
|
|
||||||
### Research Baseline (FastMCP + URI Templates)
|
|
||||||
|
|
||||||
Authoritative references used for this step:
|
|
||||||
|
|
||||||
1. FastMCP Resources and Templates docs (resource decorator, template behavior)
|
|
||||||
2. FastMCP RFC6570 support docs (simple params, wildcard params, query params)
|
|
||||||
3. FastMCP duplicate handling docs (`on_duplicate_resources`)
|
|
||||||
4. FastMCP annotations guidance (`readOnlyHint`, `idempotentHint`)
|
|
||||||
|
|
||||||
Best-practice conclusions applied to this design:
|
|
||||||
|
|
||||||
1. Use URI templates for parameterized resources instead of generating N static resource handlers.
|
|
||||||
2. Use wildcard template parameters (`{path*}`) for hierarchical docs paths.
|
|
||||||
3. Set startup duplicate policy to `on_duplicate_resources="error"` to fail fast on contract collisions.
|
|
||||||
4. Set explicit `mime_type` and resource annotations for all docs resources.
|
|
||||||
5. Keep registration deterministic and sourced only from the validated Step 4 registry.
|
|
||||||
|
|
||||||
### Registration Responsibilities (Normative)
|
|
||||||
|
|
||||||
The Step 5 registration layer MUST:
|
|
||||||
|
|
||||||
1. Consume only the validated in-memory registry produced by Step 4.
|
|
||||||
2. Register canonical resource discovery surfaces and skill document/reference surfaces.
|
|
||||||
3. Use RFC6570 templates where URI patterns are parameterized.
|
|
||||||
4. Use wildcard templates where path depth is variable.
|
|
||||||
5. Attach read-only/idempotent annotations to documentation resources.
|
|
||||||
6. Set explicit MIME types for all registered resources.
|
|
||||||
7. Fail startup if duplicate URI/template keys are encountered.
|
|
||||||
|
|
||||||
### Canonical Resource Surface (from Registry)
|
|
||||||
|
|
||||||
The preferred resources registered in this phase are:
|
|
||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
|
||||||
2. `resource://catalog/skills_index{?q,tag,capability,cursor,limit}` (optional filtered/paginated discovery template)
|
|
||||||
3. `resource://catalog/skills/{skill_id}`
|
|
||||||
4. `resource://skills/{skill_id}/document`
|
|
||||||
5. `resource://skills/{skill_id}/references/{ref_id}`
|
|
||||||
6. `resource://docs/{path*}`
|
|
||||||
|
|
||||||
Registration decision rules:
|
|
||||||
|
|
||||||
1. Use static resource registration for fixed singleton endpoints (for example `skills_index`).
|
|
||||||
2. Use template registration for parameterized endpoints (`{skill_id}`, `{ref_id}`) and optional discovery query templates.
|
|
||||||
3. Use wildcard template registration for hierarchical docs routing (`{path*}`).
|
|
||||||
4. Keep the singleton and query-template discovery surfaces semantically equivalent (same schema, query template adds filtering/pagination only).
|
|
||||||
|
|
||||||
### Progressive Discovery Contract
|
|
||||||
|
|
||||||
Discovery-first behavior for Step 5 resources:
|
|
||||||
|
|
||||||
1. `skills_index` returns summaries only (no embedded full SKILL.md bodies).
|
|
||||||
2. Each summary includes canonical follow-up URIs so clients can progressively fetch detail (`catalog/skills/{skill_id}` then `skills/{skill_id}/document`).
|
|
||||||
3. Filtered/paginated discovery uses RFC6570 query params (`q`, `tag`, `capability`, `cursor`, `limit`) with deterministic ordering.
|
|
||||||
4. Handlers should enforce bounded page size and return explicit continuation metadata when pagination is active.
|
|
||||||
5. Errors for unsupported filter params or invalid cursor/limit are explicit and actionable.
|
|
||||||
|
|
||||||
### RFC6570 Template Contract
|
|
||||||
|
|
||||||
Path parameters:
|
|
||||||
|
|
||||||
1. `{skill_id}` and `{ref_id}` are single-segment template params.
|
|
||||||
2. `{path*}` is a wildcard param and may capture multi-segment paths separated by `/`.
|
|
||||||
|
|
||||||
Validation contract at resource-read time:
|
|
||||||
|
|
||||||
1. `skill_id` must exist in registry.
|
|
||||||
2. `ref_id` must exist in that skill’s reference manifest.
|
|
||||||
3. wildcard `path*` must normalize to an allowed docs-relative markdown path.
|
|
||||||
4. invalid params return explicit not-found or validation errors (no silent fallback).
|
|
||||||
|
|
||||||
Template function signature contract:
|
|
||||||
|
|
||||||
1. Required URI params must exist as function parameters.
|
|
||||||
2. Avoid hidden implicit params not represented in template.
|
|
||||||
3. Keep template handlers side-effect free.
|
|
||||||
|
|
||||||
### Metadata and Annotation Contract
|
|
||||||
|
|
||||||
Each docs/resource registration should specify explicit metadata:
|
|
||||||
|
|
||||||
1. `mime_type`
|
|
||||||
- skill docs and references: `text/markdown`
|
|
||||||
- catalog payloads: `application/json`
|
|
||||||
2. `annotations`
|
|
||||||
- `readOnlyHint: true`
|
|
||||||
- `idempotentHint: true`
|
|
||||||
3. `tags`
|
|
||||||
- include stable categories such as `catalog`, `skill-doc`, `reference`, `docs`
|
|
||||||
4. `version`
|
|
||||||
- project-defined version from registry metadata where applicable
|
|
||||||
5. `meta`
|
|
||||||
- include normalized identifiers (for example `skill_id`, `ref_id`, `source_relpath`) when useful
|
|
||||||
|
|
||||||
### Startup Safety and Duplicate Policy
|
|
||||||
|
|
||||||
FastMCP initialization contract for this phase:
|
|
||||||
|
|
||||||
1. Construct the root server with `on_duplicate_resources="error"`.
|
|
||||||
2. Register all Step 5 resources during startup composition before serving traffic.
|
|
||||||
3. Treat duplicate registration as a hard startup failure.
|
|
||||||
|
|
||||||
Duplicate conflict classes covered:
|
|
||||||
|
|
||||||
1. static URI vs static URI collision
|
|
||||||
2. static URI vs template key collision
|
|
||||||
3. template URI vs template URI collision
|
|
||||||
4. conflicting registrations introduced by future aliases without explicit migration handling
|
|
||||||
|
|
||||||
### Registration Architecture
|
|
||||||
|
|
||||||
Use one dedicated registration module that converts registry records into FastMCP resources.
|
|
||||||
|
|
||||||
Recommended API:
|
|
||||||
|
|
||||||
1. `register_docs_resources(mcp: FastMCP, registry: DocsRegistry) -> None`
|
|
||||||
|
|
||||||
Responsibilities of `register_docs_resources`:
|
|
||||||
|
|
||||||
1. register singleton catalog resources
|
|
||||||
2. register parameterized catalog/detail templates
|
|
||||||
3. register skill document and reference templates
|
|
||||||
4. register docs wildcard template
|
|
||||||
5. apply shared annotations and MIME defaults consistently
|
|
||||||
|
|
||||||
Separation of concerns:
|
|
||||||
|
|
||||||
1. Step 4 validates and normalizes docs state.
|
|
||||||
2. Step 5 only registers handlers and reads from validated registry state.
|
|
||||||
3. Request handlers do not re-discover filesystem/package structure.
|
|
||||||
|
|
||||||
### Handler Behavior Contract
|
|
||||||
|
|
||||||
Catalog handlers:
|
|
||||||
|
|
||||||
1. `skills_index` returns compact deterministic discovery payload (summary records only) and supports progressive follow-up links.
|
|
||||||
2. `skills/{skill_id}` returns one normalized detail record or not-found.
|
|
||||||
|
|
||||||
Skill document handlers:
|
|
||||||
|
|
||||||
1. `skills/{skill_id}/document` returns canonical SKILL markdown content.
|
|
||||||
2. MIME type is always `text/markdown`.
|
|
||||||
|
|
||||||
Reference handlers:
|
|
||||||
|
|
||||||
1. `skills/{skill_id}/references/{ref_id}` resolves via frontmatter manifest mapping.
|
|
||||||
2. MIME type is explicit from manifest or defaults to `text/markdown`.
|
|
||||||
|
|
||||||
Wildcard docs handler:
|
|
||||||
|
|
||||||
1. `docs/{path*}` serves markdown docs under canonical packaged docs tree.
|
|
||||||
2. traversal outside docs root is blocked.
|
|
||||||
|
|
||||||
### Integration Plan for Existing Modules
|
|
||||||
|
|
||||||
Primary composition updates:
|
|
||||||
|
|
||||||
1. Implement registry-driven registration in [src/personal_mcp/mcp.py](src/personal_mcp/mcp.py) as the canonical resource composition path.
|
|
||||||
2. Keep [src/personal_mcp/main.py](src/personal_mcp/main.py) responsible for startup wiring order (load registry first, then register resources).
|
|
||||||
3. Use [src/personal_mcp/catalog/server.py](src/personal_mcp/catalog/server.py) as registry-backed handlers only.
|
|
||||||
|
|
||||||
Lifecycle order (required):
|
|
||||||
|
|
||||||
1. load and validate registry (Step 4)
|
|
||||||
2. initialize FastMCP with duplicate error policy
|
|
||||||
3. register all Step 5 resources/templates
|
|
||||||
4. start server
|
|
||||||
|
|
||||||
### Testing Plan (Step 5 Scope)
|
|
||||||
|
|
||||||
Unit/integration tests:
|
|
||||||
|
|
||||||
1. resource registration succeeds with valid registry
|
|
||||||
2. duplicate resource registration fails at startup
|
|
||||||
3. `skills/{skill_id}` template resolves expected record
|
|
||||||
4. `skills/{skill_id}/document` returns markdown with correct MIME
|
|
||||||
5. `skills/{skill_id}/references/{ref_id}` resolves manifest-mapped file
|
|
||||||
6. `docs/{path*}` resolves nested docs paths and blocks traversal attempts
|
|
||||||
7. all registered docs resources include `readOnlyHint` and `idempotentHint`
|
|
||||||
8. catalog payload order is deterministic
|
|
||||||
9. filtered/paginated `skills_index{?q,tag,capability,cursor,limit}` responses are deterministic and schema-compatible with the singleton index response
|
|
||||||
10. catalog index payload excludes full markdown bodies and includes follow-up URIs for progressive reads
|
|
||||||
|
|
||||||
Smoke tests:
|
|
||||||
|
|
||||||
1. list resources includes singleton and template entries
|
|
||||||
2. read representative skill doc URI and reference URI successfully
|
|
||||||
3. read representative wildcard docs URI successfully
|
|
||||||
|
|
||||||
### Acceptance Criteria for Step 5 Completion
|
|
||||||
|
|
||||||
Step 5 is complete when all are true:
|
|
||||||
|
|
||||||
1. Resource registration is fully registry-driven (no per-skill hardcoded decorators required for core docs surfaces).
|
|
||||||
2. RFC6570 templates are used for parameterized URI families, including wildcard where needed.
|
|
||||||
3. All docs resources declare explicit MIME types and read-only/idempotent annotations.
|
|
||||||
4. `on_duplicate_resources="error"` is enabled and verified by tests.
|
|
||||||
5. Startup fails safely on registration conflicts.
|
|
||||||
|
|
||||||
### Non-goals for Step 5
|
|
||||||
|
|
||||||
1. No tool fallback discovery behavior implementation (Step 6).
|
|
||||||
2. No packaging build inclusion mechanics (Step 7).
|
|
||||||
3. No CI gate expansion details (Step 9).
|
|
||||||
4. No migration shims for legacy URI aliases in the greenfield baseline.
|
|
||||||
5. No ranking-strategy implementation for discovery tools beyond what is needed to preserve deterministic resource-first discovery contracts.
|
|
||||||
6. No backward-compat resource aliases, adapter handlers, or dual registration paths.
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
**Step 6 Results: Resource-First Discovery and Tool Fallback Contract**
|
|
||||||
|
|
||||||
This section finalizes Step 6 by defining discovery behavior for clients that can attach MCP resources and the fallback behavior for clients or chat surfaces that must rely on MCP tools.
|
|
||||||
|
|
||||||
### Step Deliverable
|
|
||||||
|
|
||||||
- Update the current `docs/` directory with the finalized Step 6 discovery and fallback contract content from this document.
|
|
||||||
|
|
||||||
### Primary Source Baseline (Repository Docs)
|
|
||||||
|
|
||||||
Step 6 is based on the current project contracts in:
|
|
||||||
|
|
||||||
1. `docs/architecture.md` (resource-first architecture and catalog role)
|
|
||||||
2. `docs/usage.md` (operating flows, bounded loading, and fallback sequence)
|
|
||||||
3. `docs/copilot.md` (client capability lanes and practical fallback behavior)
|
|
||||||
4. `docs/mcp_layout.md` (shared content source and thin-tool fallback position)
|
|
||||||
5. `docs/securing.md` (read-only/public-docs security invariant)
|
|
||||||
|
|
||||||
Normative conclusions from those sources:
|
|
||||||
|
|
||||||
1. Discovery stays resource-first.
|
|
||||||
2. Tool fallback is allowed, thin, and read-only.
|
|
||||||
3. Resources and tools must resolve to the same canonical authored markdown.
|
|
||||||
4. Fallback behavior should keep context bounded and deterministic.
|
|
||||||
|
|
||||||
### FastMCP Source Baseline (Authoritative References)
|
|
||||||
|
|
||||||
Step 6 fallback behavior and compatibility-layer expectations align with:
|
|
||||||
|
|
||||||
1. [FastMCP server concepts](https://gofastmcp.com/servers/server)
|
|
||||||
2. [FastMCP resources and resource templates](https://gofastmcp.com/servers/resources)
|
|
||||||
3. [FastMCP resources-as-tools transform](https://gofastmcp.com/servers/transforms/resources-as-tools)
|
|
||||||
4. [MCP specification: resources](https://modelcontextprotocol.io/specification/latest/server/resources)
|
|
||||||
|
|
||||||
Applied conclusions for this step:
|
|
||||||
|
|
||||||
1. Resource contracts remain canonical and should be surfaced directly when clients support resource attachment.
|
|
||||||
2. Tool-first compatibility layers should wrap canonical resource reads rather than creating alternate authored-content stores.
|
|
||||||
3. URI-template-backed resource identity remains stable across direct-resource and tool-compatibility access paths.
|
|
||||||
|
|
||||||
### Client Tool-Naming Research Baseline
|
|
||||||
|
|
||||||
Authoritative and client-specific references to verify during implementation:
|
|
||||||
|
|
||||||
1. [MCP specification: tools](https://modelcontextprotocol.io/specification/latest/server/tools)
|
|
||||||
2. [MCP client concepts](https://modelcontextprotocol.io/docs/learn/client-concepts)
|
|
||||||
3. [FastMCP tools](https://gofastmcp.com/servers/tools)
|
|
||||||
4. [FastMCP resources-as-tools transform](https://gofastmcp.com/servers/transforms/resources-as-tools)
|
|
||||||
5. [VS Code MCP servers](https://code.visualstudio.com/docs/agent-customization/mcp-servers)
|
|
||||||
6. [VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
|
|
||||||
7. [Cursor MCP documentation](https://docs.cursor.com/context/model-context-protocol)
|
|
||||||
8. [Claude Desktop local MCP server setup](https://support.anthropic.com/en/articles/10949351-getting-started-with-local-mcp-servers-on-claude-desktop)
|
|
||||||
|
|
||||||
Baseline naming conclusions:
|
|
||||||
|
|
||||||
1. MCP protocol tool identity is the server-advertised `name` returned by `tools/list` and used in `tools/call`.
|
|
||||||
2. FastMCP tool identity should be treated as the canonical server contract unless a tool is intentionally registered with an explicit alternate name.
|
|
||||||
3. Clients and host integrations may display, namespace, or internally route tool names with provider-specific prefixes, but those wrappers are not canonical server tool names.
|
|
||||||
4. Compatibility should be validated by observed `tools/list` and successful `tools/call` behavior in each target client rather than by assuming one global host naming convention.
|
|
||||||
|
|
||||||
### Discovery Priority Contract (Normative)
|
|
||||||
|
|
||||||
Preferred sequence for skill discovery and loading:
|
|
||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
|
||||||
2. `resource://catalog/skills/{skill_id}`
|
|
||||||
3. `resource://skills/{skill_id}/document`
|
|
||||||
4. `resource://skills/{skill_id}/references/{ref_id}` only when needed
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. Start from catalog discovery before loading any skill document.
|
|
||||||
2. Do not skip straight to broad document loading when catalog metadata can narrow choices first.
|
|
||||||
3. Use `resource://docs/{path*}` only for direct authored-doc access outside skill-specific flows.
|
|
||||||
|
|
||||||
### Fallback Activation Rule
|
|
||||||
|
|
||||||
Fallback is used only when the active client path cannot reliably attach MCP resources (for example, tool-only chat surfaces).
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. Keep the same discovery order semantics as the resource path.
|
|
||||||
2. If resource attachment is available, prefer resources over tools.
|
|
||||||
3. Tool fallback must never become a second authoritative content source.
|
|
||||||
|
|
||||||
### Tool Fallback Surface (Normative)
|
|
||||||
|
|
||||||
The fallback tool surface includes:
|
|
||||||
|
|
||||||
1. `list_resources`
|
|
||||||
2. `read_resource`
|
|
||||||
3. `search_patterns`
|
|
||||||
4. `get_pattern_by_id`
|
|
||||||
5. `get_skill_document_by_id`
|
|
||||||
|
|
||||||
Canonical naming rule:
|
|
||||||
|
|
||||||
1. The server-level tool contract uses the exact registered FastMCP tool names above.
|
|
||||||
2. Clients that expose provider-prefixed names (for example, namespaced wrappers) must map those names to the canonical server tool name before invocation.
|
|
||||||
3. `catalog_get_skill_document_by_id` is not a canonical server tool name for this contract unless an explicit alias is intentionally registered.
|
|
||||||
|
|
||||||
Compatibility alias policy:
|
|
||||||
|
|
||||||
1. Prefer canonical server tool names over aliases.
|
|
||||||
2. Add server-side aliases only when a major client cannot reliably map its wrapper name back to the canonical name.
|
|
||||||
3. Any alias must be read-only, delegate to the same payload builder as the canonical tool, and be documented as compatibility-only.
|
|
||||||
4. If aliases are added, canonical and alias tools must return byte-for-byte equivalent payloads for the same input.
|
|
||||||
|
|
||||||
Fallback order:
|
|
||||||
|
|
||||||
1. call `list_resources` to inspect canonical static/template resource surfaces
|
|
||||||
2. call `read_resource` for catalog URIs and selected skill URIs
|
|
||||||
3. use thin catalog tools only when additional metadata-first narrowing is needed
|
|
||||||
|
|
||||||
Tool behavior requirements:
|
|
||||||
|
|
||||||
1. read-only and idempotent semantics
|
|
||||||
2. deterministic ordering and bounded pagination
|
|
||||||
3. explicit not-found responses (`found: false` style) where applicable
|
|
||||||
4. payloads remain schema-aligned with catalog resources
|
|
||||||
5. tool invocation examples and Copilot guidance must use canonical server tool names to avoid unknown-tool errors
|
|
||||||
|
|
||||||
### Major Client Compatibility Plan
|
|
||||||
|
|
||||||
Target clients and expected validation:
|
|
||||||
|
|
||||||
1. GitHub Copilot in VS Code
|
|
||||||
- primary path: attach MCP resources when `MCP Resources...` is available
|
|
||||||
- fallback path: call `list_resources`, `read_resource`, then canonical thin tools only when needed
|
|
||||||
- validation: confirm Copilot-visible tool inventory includes or can invoke `list_resources`, `read_resource`, `search_patterns`, `get_pattern_by_id`, and `get_skill_document_by_id`
|
|
||||||
- compatibility risk: host-generated wrapper names may differ from canonical FastMCP names; document any observed wrapper-to-canonical mapping
|
|
||||||
2. Cursor
|
|
||||||
- primary path: use the client MCP server configuration and resource/tool surfaces supported by the active Cursor version
|
|
||||||
- fallback path: prefer resource-backed tools first, then canonical thin tools
|
|
||||||
- validation: capture Cursor `tools/list` equivalent behavior and verify the canonical tool names or required host mappings
|
|
||||||
- compatibility risk: Cursor may present MCP tools through its own UI labels or internal routing names
|
|
||||||
3. Claude Desktop
|
|
||||||
- primary path: configure the local MCP server and inspect advertised tools/resources in Claude Desktop
|
|
||||||
- fallback path: invoke canonical server tool names exactly as returned by `tools/list`
|
|
||||||
- validation: run a local smoke prompt that reads `resource://catalog/skills_index` and loads one skill document through `read_resource` or `get_skill_document_by_id`
|
|
||||||
- compatibility risk: local server configuration and transport setup may fail before tool-name compatibility is tested
|
|
||||||
4. Generic MCP clients and SDK-based tests
|
|
||||||
- primary path: protocol-level `resources/list`, `resources/read`, `tools/list`, and `tools/call`
|
|
||||||
- fallback path: none beyond the canonical tool contract
|
|
||||||
- validation: automated smoke tests assert exact tool names returned by `tools/list` and successful calls for canonical names
|
|
||||||
- compatibility risk: SDK/client libraries may expose helper names that differ from raw protocol names
|
|
||||||
|
|
||||||
Implementation checklist:
|
|
||||||
|
|
||||||
1. Capture each target client's advertised tool names before adding aliases.
|
|
||||||
2. Prefer fixing documentation or client-side mapping when the server already advertises canonical names correctly.
|
|
||||||
3. Add a server-side alias only for a confirmed major-client incompatibility.
|
|
||||||
4. Add regression tests for canonical names, resource-backed tools, and any intentionally supported aliases.
|
|
||||||
5. Keep public examples centered on `list_resources`/`read_resource` and canonical thin tool names.
|
|
||||||
|
|
||||||
### Resources-As-Tools Compatibility Layer
|
|
||||||
|
|
||||||
Step 6 includes a resources-as-tools compatibility layer for clients that can call tools but not attach resources.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. It wraps canonical resource reads rather than re-implementing content transforms.
|
|
||||||
2. It preserves canonical URIs and metadata semantics.
|
|
||||||
3. It does not replace the minimal catalog tools listed above.
|
|
||||||
4. It is interoperability-driven and remains read-only.
|
|
||||||
|
|
||||||
### Resource/Tool Parity Contract
|
|
||||||
|
|
||||||
Resources and fallback tools must agree on identity and routing metadata.
|
|
||||||
|
|
||||||
Parity requirements:
|
|
||||||
|
|
||||||
1. `skill_id` and `ref_id` are identical across both paths.
|
|
||||||
2. canonical URIs in payloads match Step 3 URI rules.
|
|
||||||
3. skill metadata (`id`, `name`, `description`, `tags`, `capabilities`, `version`) remains consistent.
|
|
||||||
4. document payload returned by `get_skill_document_by_id` resolves to the same canonical `SKILL.md` content as `resource://skills/{skill_id}/document`.
|
|
||||||
|
|
||||||
### Relevance and Ranking Contract
|
|
||||||
|
|
||||||
Baseline matching behavior is metadata-first and deterministic.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. Search primarily over normalized skill metadata (id, name, description, tags).
|
|
||||||
2. Keep deterministic ordering and deterministic pagination behavior.
|
|
||||||
3. Keep ranking logic transparent and bounded for predictable client behavior.
|
|
||||||
|
|
||||||
Optional extension policy:
|
|
||||||
|
|
||||||
1. BM25/regex augmentation is allowed only when catalog/tool volume meaningfully harms token efficiency or precision.
|
|
||||||
2. Any augmentation must preserve canonical ids, URIs, and deterministic tie-breaking.
|
|
||||||
3. Any augmentation remains discovery-only and does not create alternate content payloads.
|
|
||||||
|
|
||||||
### Context-Bounding and Clarification Policy
|
|
||||||
|
|
||||||
To prevent context bloat and improve answer quality:
|
|
||||||
|
|
||||||
1. load only the most relevant skill document by default
|
|
||||||
2. load at most two skill documents in one pass unless the user explicitly asks for more
|
|
||||||
3. if confidence is low after catalog discovery, ask one clarifying question before loading additional skill documents
|
|
||||||
4. fetch references lazily and only when required
|
|
||||||
|
|
||||||
### Security and Safety Constraints
|
|
||||||
|
|
||||||
Fallback tools must preserve the project security invariant.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. tool surfaces stay documentation-only and read-only
|
|
||||||
2. no mutation, shell execution, secret access, or private filesystem exposure
|
|
||||||
3. all returned content remains safe to publish publicly
|
|
||||||
|
|
||||||
### Integration Boundaries
|
|
||||||
|
|
||||||
Step 6 integrates with prior steps as follows:
|
|
||||||
|
|
||||||
1. Step 4 provides the validated in-memory registry.
|
|
||||||
2. Step 5 provides canonical resource registration.
|
|
||||||
3. Step 6 adds fallback discovery/read behavior that reuses the same registry and canonical markdown sources.
|
|
||||||
|
|
||||||
Separation-of-concerns rule:
|
|
||||||
|
|
||||||
1. Catalog/resource contracts remain canonical.
|
|
||||||
2. Fallback tools are interoperability adapters, not a parallel architecture.
|
|
||||||
|
|
||||||
### Acceptance Criteria for Step 6 Completion
|
|
||||||
|
|
||||||
Step 6 is complete when all are true:
|
|
||||||
|
|
||||||
1. Resource-first discovery remains the documented and implemented default path.
|
|
||||||
2. `list_resources` and `read_resource` are available for tool-only clients.
|
|
||||||
3. Thin catalog tools remain minimal, read-only parity surfaces.
|
|
||||||
4. Fallback tool outputs map to canonical skill identities and URIs.
|
|
||||||
5. Context loading is bounded and clarifying-question behavior is documented for low-confidence cases.
|
|
||||||
6. No second content source is introduced; resources and tools resolve the same authored markdown.
|
|
||||||
|
|
||||||
### Non-goals for Step 6
|
|
||||||
|
|
||||||
1. No write or side-effecting tools.
|
|
||||||
2. No alternate authored markdown stores or duplicated skill content pipelines.
|
|
||||||
3. No guarantee that every client session exposes MCP resource attachment UI.
|
|
||||||
4. No packaging/build contract changes (handled in Step 7).
|
|
||||||
5. No CI gate expansion details (handled in later validation/governance steps).
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
name: Pytest Fill Scaffold
|
||||||
|
description: Fill scaffolded pytest test methods with assertions, fixtures, and minimal test data while preserving concise test names and one-line intent docstrings.
|
||||||
|
argument-hint: Target test file(s) under tests plus stack (pure-python, fastapi, sqlalchemy-sync, sqlalchemy-async, or mixed)
|
||||||
|
agent: agent
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pytest Fill Scaffold
|
||||||
|
|
||||||
|
Use this prompt after test scaffolding exists and method names/docstrings are already in place.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Target test file(s) under `tests/`.
|
||||||
|
- Stack type:
|
||||||
|
- `pure-python`
|
||||||
|
- `fastapi`
|
||||||
|
- `sqlalchemy-sync`
|
||||||
|
- `sqlalchemy-async`
|
||||||
|
- `mixed`
|
||||||
|
- Optional constraints:
|
||||||
|
- keep implementation minimal vs comprehensive
|
||||||
|
- marker lane target (`unit`, `integration`, `smoke`)
|
||||||
|
|
||||||
|
## Required References
|
||||||
|
|
||||||
|
Load these in order and use only what matches the task:
|
||||||
|
|
||||||
|
1. Core defaults: [pytest scaffolding skill](../../docs/skills/pytesting/SKILL.md)
|
||||||
|
2. Naming/hierarchy preservation: [naming and organization](../../docs/skills/pytesting/references/naming-and-organization.md)
|
||||||
|
3. Baseline pytest fixtures/markers: [pytest docs notes](../../docs/skills/pytesting/references/pytest-docs.md)
|
||||||
|
4. FastAPI-specific behavior (only when needed): [fastapi testing](../../docs/skills/pytesting/references/fastapi-testing.md)
|
||||||
|
5. SQLAlchemy-specific behavior (only when needed): [sqlalchemy testing](../../docs/skills/pytesting/references/sqlalchemy-testing.md)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect target files and treat human-reviewed docstring-only scaffolds as invariant.
|
||||||
|
2. Convert each scaffolded method into an executable test with a single behavior focus.
|
||||||
|
3. Keep one-line docstrings for class and method intent.
|
||||||
|
4. Add or refine fixtures at the nearest useful scope:
|
||||||
|
- global in `tests/conftest.py` only when broadly reusable
|
||||||
|
- subtree `conftest.py` for domain-specific fixtures
|
||||||
|
5. Assign markers consistent with cost and dependencies:
|
||||||
|
- `unit` for pure logic
|
||||||
|
- `integration` for framework/DB contracts
|
||||||
|
- `smoke` for thin critical-path checks
|
||||||
|
6. Validate in this order:
|
||||||
|
- `uv run pytest --collect-only -q`
|
||||||
|
- `uv run pytest -m unit -q` when unit tests are touched
|
||||||
|
- `uv run pytest -q` if dependencies are available
|
||||||
|
|
||||||
|
## Authoring Rules
|
||||||
|
|
||||||
|
- Prefer deterministic tests and explicit setup/teardown.
|
||||||
|
- Keep assertions precise and readable.
|
||||||
|
- Do not overfit tests to private implementation details.
|
||||||
|
- If a scaffolded class or method has only a docstring body, treat its name and hierarchy as locked.
|
||||||
|
- Do not rename, move, merge, split, or re-nest docstring-only scaffolded tests unless explicitly requested.
|
||||||
|
- Preserve existing one-line docstrings on scaffolded classes and methods unless they are factually incorrect.
|
||||||
|
- If stack details are missing and would change fixture strategy, ask one concise clarifying question before editing.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Return:
|
||||||
|
1. Files updated.
|
||||||
|
2. Fixture and marker decisions.
|
||||||
|
3. Which references were used and why.
|
||||||
|
4. Validation command results.
|
||||||
|
5. Risks or open questions.
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
name: Pytest Scaffold
|
||||||
|
description: Plan and scaffold pytest test files, class hierarchy, and concise method names for selected Python modules in this repository.
|
||||||
|
argument-hint: Target module path(s) in src plus scope (plan-only or scaffold)
|
||||||
|
agent: agent
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pytest Scaffold
|
||||||
|
|
||||||
|
Use this prompt to do in one run what we have been doing manually in chat:
|
||||||
|
1. Build a naming and hierarchy plan for tests.
|
||||||
|
2. Scaffold test files and class/method skeletons.
|
||||||
|
3. Keep test method names concise because intent is carried by one-line docstrings.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Target module path(s) under `src/`.
|
||||||
|
- Scope mode:
|
||||||
|
- `plan-only`
|
||||||
|
- `scaffold`
|
||||||
|
- Optional constraints:
|
||||||
|
- flattening preferences for path mapping under `tests/`
|
||||||
|
- method naming style preference
|
||||||
|
|
||||||
|
## Repository Rules To Apply
|
||||||
|
|
||||||
|
- Use [pytest scaffolding skill](../../docs/skills/pytesting/SKILL.md) for strategy and defaults.
|
||||||
|
- Use [naming and organization reference](../../docs/skills/pytesting/references/naming-and-organization.md) before finalizing hierarchy.
|
||||||
|
- Use `uv run pytest --collect-only -q` as structural validation.
|
||||||
|
- Default to a source-mirror style adapted to this repository:
|
||||||
|
- map selected modules to `tests/` with concise path segments when requested
|
||||||
|
- keep one test module per source module
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
|
||||||
|
1. Inspect current `tests/` layout and identify existing naming patterns.
|
||||||
|
2. Propose a concise hierarchy plan first:
|
||||||
|
- test file paths
|
||||||
|
- class hierarchy
|
||||||
|
- method naming pattern
|
||||||
|
- fixture placement (`tests/conftest.py` vs subtree `conftest.py`)
|
||||||
|
3. If scope mode is `scaffold`, implement the skeleton:
|
||||||
|
- create missing test modules
|
||||||
|
- create class hierarchy
|
||||||
|
- add one-line docstrings to every class and test method
|
||||||
|
- keep test method names short and behavior-focused
|
||||||
|
- treat resulting docstring-only scaffolds as human-reviewed baseline for future fill-in work
|
||||||
|
4. Validate collection with `uv run pytest --collect-only -q`.
|
||||||
|
5. Report results:
|
||||||
|
- files created or updated
|
||||||
|
- collection outcome
|
||||||
|
- any ambiguities or follow-up choices
|
||||||
|
|
||||||
|
## Class And Method Shape Defaults
|
||||||
|
|
||||||
|
- Class shape:
|
||||||
|
- `Test<PrimarySubject>` as the top-level subject class
|
||||||
|
- nested `Test<MethodOrArea>` classes when it improves context
|
||||||
|
- top-level `Test<FunctionName>` classes for standalone module functions
|
||||||
|
- Method shape:
|
||||||
|
- `test_<short_outcome>` naming
|
||||||
|
- one behavior target per method name
|
||||||
|
- one-line docstring that states the full intent
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Return:
|
||||||
|
1. Discovery summary and references consulted.
|
||||||
|
2. Proposed or applied test tree.
|
||||||
|
3. Class and method naming map.
|
||||||
|
4. Validation command results.
|
||||||
|
5. Open questions only if they block confident completion.
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
## Goal
|
||||||
|
|
||||||
|
Build a local, self-hosted documentation knowledge base that can ingest software docs, generate embeddings, store them in SQLite, and expose high-quality retrieval through MCP tools and resources.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Architectural Decisions
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
* SQLite for metadata and document storage
|
||||||
|
* FTS5 for keyword search
|
||||||
|
* sqlite-vec for vector similarity search
|
||||||
|
|
||||||
|
Avoid a separate vector database unless scale requirements emerge.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Embeddings
|
||||||
|
|
||||||
|
Use local embedding models via:
|
||||||
|
|
||||||
|
* sentence-transformers
|
||||||
|
|
||||||
|
Recommended model:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BAAI/bge-base-en-v1.5
|
||||||
|
```
|
||||||
|
|
||||||
|
Store embeddings alongside document chunks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Ingestion
|
||||||
|
|
||||||
|
Primary sources:
|
||||||
|
|
||||||
|
1. Git repositories containing Markdown docs
|
||||||
|
2. Documentation websites via Crawl4AI
|
||||||
|
3. Sitemap-driven crawls when available
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Source
|
||||||
|
↓
|
||||||
|
Extract
|
||||||
|
↓
|
||||||
|
Normalize
|
||||||
|
↓
|
||||||
|
Chunk by headings
|
||||||
|
↓
|
||||||
|
Embed
|
||||||
|
↓
|
||||||
|
Store
|
||||||
|
```
|
||||||
|
|
||||||
|
Track content hashes so unchanged documents are skipped during reindexing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Retrieval
|
||||||
|
|
||||||
|
Implement hybrid retrieval:
|
||||||
|
|
||||||
|
```text
|
||||||
|
FTS5 keyword search
|
||||||
|
+
|
||||||
|
sqlite-vec similarity search
|
||||||
|
↓
|
||||||
|
Candidate set
|
||||||
|
↓
|
||||||
|
Reranker
|
||||||
|
↓
|
||||||
|
Final results
|
||||||
|
```
|
||||||
|
|
||||||
|
Reranker:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BAAI/bge-reranker-v2
|
||||||
|
```
|
||||||
|
|
||||||
|
The retriever owns all ranking logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Public Interface
|
||||||
|
|
||||||
|
Do not expose vector search directly.
|
||||||
|
|
||||||
|
Expose a retrieval service through MCP:
|
||||||
|
|
||||||
|
```python
|
||||||
|
search_docs(query)
|
||||||
|
|
||||||
|
get_context(query)
|
||||||
|
|
||||||
|
get_doc(path)
|
||||||
|
```
|
||||||
|
|
||||||
|
The MCP layer becomes the stable API.
|
||||||
|
|
||||||
|
Clients never interact with embeddings or vectors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
├── knowledge/
|
||||||
|
│ ├── models.py
|
||||||
|
│ ├── chunking.py
|
||||||
|
│ ├── embeddings.py
|
||||||
|
│ ├── ingestion.py
|
||||||
|
│ ├── sqlite_store.py
|
||||||
|
│ ├── hybrid_search.py
|
||||||
|
│ ├── reranker.py
|
||||||
|
│ └── retrieval.py
|
||||||
|
│
|
||||||
|
├── sources/
|
||||||
|
│ ├── git_docs.py
|
||||||
|
│ ├── crawl4ai_docs.py
|
||||||
|
│ └── sitemap_docs.py
|
||||||
|
│
|
||||||
|
├── mcp_server/
|
||||||
|
│ ├── tools.py
|
||||||
|
│ └── resources.py
|
||||||
|
│
|
||||||
|
└── cli/
|
||||||
|
├── ingest.py
|
||||||
|
└── reindex.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Retrieval Flow
|
||||||
|
|
||||||
|
```text
|
||||||
|
User Query
|
||||||
|
↓
|
||||||
|
Embed Query
|
||||||
|
↓
|
||||||
|
FTS5 Search
|
||||||
|
+
|
||||||
|
Vector Search
|
||||||
|
↓
|
||||||
|
Merge Results
|
||||||
|
↓
|
||||||
|
Rerank
|
||||||
|
↓
|
||||||
|
Return Context Bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
Where a context bundle contains:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ContextBundle(
|
||||||
|
passages=[...],
|
||||||
|
citations=[...],
|
||||||
|
related_docs=[...],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Extensions
|
||||||
|
|
||||||
|
Without changing the architecture:
|
||||||
|
|
||||||
|
* Multiple documentation corpora
|
||||||
|
* Version-aware retrieval
|
||||||
|
* Code snippet indexing
|
||||||
|
* MCP resources for specific topics
|
||||||
|
* LangGraph integration
|
||||||
|
* Docker deployment
|
||||||
|
* Scheduled reindexing
|
||||||
|
|
||||||
|
The key design principle is: **treat the vector store as an internal implementation detail and expose a retrieval-oriented MCP interface instead.**
|
||||||
+62
-207
@@ -6,249 +6,104 @@ icon: lucide/library
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The platform is implemented as a resource-first MCP system with an integrated static documentation surface. The same methodology content powers both MCP resources and the published docs site.
|
The application combines a FastMCP server with a pre-built Zensical documentation site. Markdown under `docs/` is the single authored content tree, while native FastMCP providers own skill and prompt discovery.
|
||||||
|
|
||||||
An MCP server is a runtime that exposes machine-readable resources and tools through stable interfaces so AI clients can discover and consume context consistently. Here, the server's role is intentionally narrow: publish canonical methodology documents as resources, keep discovery predictable through a catalog layer, and serve the same source material as pre-built static documentation.
|
The runtime has four content paths:
|
||||||
|
|
||||||
The system is complete in three layers:
|
1. `SkillsDirectoryProvider` publishes native `skill://` resources from packaged skill directories.
|
||||||
|
2. `FileSystemProvider` discovers typed `@prompt` functions from packaged Python modules.
|
||||||
|
3. The general docs registry publishes non-skill Markdown through `resource://docs/{path*}`.
|
||||||
|
4. FastAPI serves the pre-built `site/` directory.
|
||||||
|
|
||||||
1. Canonical methodology is maintained in Markdown skill documents.
|
There is no custom skill catalog, prompt catalog, or prompt registry model.
|
||||||
2. Catalog resources provide normalized discovery.
|
|
||||||
3. Zensical builds a static site from those same Markdown sources and the FastAPI app serves it in the FastMCP runtime process.
|
|
||||||
|
|
||||||
Prompt documents under `docs/prompts/` are also indexed and exposed as first-class catalog and prompt surfaces.
|
## Source Ownership
|
||||||
|
|
||||||
This architecture is anchored by three contracts:
|
### Skills
|
||||||
|
|
||||||
1. Docs-first authored content contract under `docs/` with strict per-skill ownership.
|
Each skill owns one directory:
|
||||||
2. Standard `SKILL.md` frontmatter consumed directly by FastMCP.
|
|
||||||
3. Native `skill://` resource URIs with break-and-replace policy for contract changes.
|
|
||||||
|
|
||||||
Detailed contract pages:
|
|
||||||
|
|
||||||
1. [Content Contract](./contracts/index.md#content-contract)
|
|
||||||
2. [Frontmatter Contract](./contracts/frontmatter.md)
|
|
||||||
3. [URI Contract](./contracts/uris.md)
|
|
||||||
|
|
||||||
This architecture keeps authored content human-friendly while preserving machine-stable contracts.
|
|
||||||
|
|
||||||
## Intent
|
|
||||||
|
|
||||||
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, 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
|
|
||||||
|
|
||||||
### Pattern Modules
|
|
||||||
|
|
||||||
Each skill encapsulates one methodology domain in a docs-owned directory:
|
|
||||||
|
|
||||||
1. `docs/skills/<skill-id>/SKILL.md`
|
1. `docs/skills/<skill-id>/SKILL.md`
|
||||||
2. `docs/skills/<skill-id>/references/...`
|
2. `docs/skills/<skill-id>/<supporting-path>`
|
||||||
|
|
||||||
The skill document and references are the authored source of truth; runtime code indexes and serves these files without becoming a second authored source.
|
`SkillsDirectoryProvider` publishes:
|
||||||
|
|
||||||
Each skill publishes three native resource families:
|
1. `skill://<name>/SKILL.md`
|
||||||
|
2. `skill://<name>/_manifest`
|
||||||
|
3. `skill://<name>/{path*}`
|
||||||
|
|
||||||
1. `skill://<name>/SKILL.md` for primary instructions
|
The provider parses standard skill frontmatter and generates the manifest. The general docs registry excludes `skills/**`, so only the native provider owns this namespace.
|
||||||
2. `skill://<name>/_manifest` for file discovery and integrity metadata
|
|
||||||
3. `skill://<name>/{path*}` for supporting files
|
|
||||||
|
|
||||||
The main resource returns canonical Markdown. The generated manifest lists real relative paths, sizes, and SHA256 hashes so clients can load supporting material selectively.
|
### Prompts
|
||||||
|
|
||||||
### Prompt Modules
|
Each prompt has two coordinated sources:
|
||||||
|
|
||||||
Prompt guidance can be authored in `docs/prompts/` using either canonical prompt directories (`docs/prompts/<prompt-id>/PROMPT.md`) or legacy markdown files during migration.
|
1. `src/personal_mcp/prompts/components/<module>.py` owns the typed signature and runtime metadata.
|
||||||
|
2. `docs/prompts/<prompt-id>/PROMPT.md` owns the canonical prompt prose.
|
||||||
|
|
||||||
Prompt modules publish two additive surfaces:
|
The component loads Markdown with `importlib.resources`. The renderer strips documentation frontmatter, requires exact placeholder-to-argument equality, and substitutes typed values. `FileSystemProvider(reload=False)` discovers the components during server construction.
|
||||||
|
|
||||||
1. prompt resources for catalog and document retrieval
|
FastMCP exposes prompts through native `prompts/list` and `prompts/get` operations.
|
||||||
2. MCP prompt objects for prompt-list/get-prompt style client workflows
|
|
||||||
|
|
||||||
This keeps authored markdown as source-of-truth while allowing clients to discover and invoke prompts directly.
|
### General Docs
|
||||||
|
|
||||||
### Catalog Module
|
The docs registry indexes packaged Markdown for `resource://docs/{path*}`. It rejects `skills/**` because skills are provider-owned. Prompt Markdown can remain visible as general documentation, but prompt invocation is owned by the native prompt provider.
|
||||||
|
|
||||||
The catalog publishes normalized records for prompts. Skills use FastMCP's native resource discovery and client utilities instead of a parallel catalog.
|
## Runtime Composition
|
||||||
|
|
||||||
Typical catalog resources:
|
|
||||||
|
|
||||||
1. resource://catalog/prompts_index
|
|
||||||
2. resource://catalog/prompts_index{?q,tag,cursor,limit}
|
|
||||||
3. resource://catalog/prompts/{prompt_id}
|
|
||||||
|
|
||||||
Only canonical catalog resources are part of the runtime contract in this phase.
|
|
||||||
|
|
||||||
### Registry Loader
|
|
||||||
|
|
||||||
Importing the package does not read or parse documentation. The MCP server and FastAPI application factories initialize content when constructing a runnable server. The prompt/docs registry reads packaged resources through `importlib.resources.files(...)` and `Traversable` APIs; the native skills provider receives the packaged `personal_mcp/docs/skills` filesystem path.
|
|
||||||
|
|
||||||
Loader responsibilities:
|
|
||||||
|
|
||||||
1. Parse and validate prompt frontmatter.
|
|
||||||
2. Build the prompt catalog and MCP prompt objects.
|
|
||||||
3. Index authored Markdown for `resource://docs/{path*}`.
|
|
||||||
|
|
||||||
Skill loading is owned by `SkillsDirectoryProvider`, which scans the packaged skills directory and constructs native resources before the server starts serving requests.
|
|
||||||
|
|
||||||
The immutable registry is cached for the process lifetime. Each Uvicorn worker constructs and retains its own registry because worker processes do not share Python objects. Registry load failure is a server-factory startup error, not a package-import error or partial runtime warning.
|
|
||||||
|
|
||||||
### Content Sources
|
|
||||||
|
|
||||||
Content is authored in markdown under `docs/` and managed as long-form reference material. Skill documents and companion references now live under `docs/skills/`, while project-authored pages remain alongside them in the docs tree. Resource handlers expose the same authored documents through stable resource URIs.
|
|
||||||
|
|
||||||
The repository root `docs/` directory is the only authored source. The `src/personal_mcp/docs` path is a relative symlink to that directory for source-checkout and editable-install workflows; it is not a second content tree and packaging does not depend on traversing it.
|
|
||||||
|
|
||||||
For wheel builds, Hatchling's normal `src/personal_mcp` package traversal follows the relative `docs` symlink and archives its targets as regular files under `personal_mcp/docs/`. No `force-include` mapping is used because that would add the same archive paths twice. The prompt/docs registry uses [`importlib.resources.files`](https://docs.python.org/3/library/importlib.resources.html#importlib.resources.files), while `SkillsDirectoryProvider` scans the package-relative filesystem path. Neither path depends on the current working directory.
|
|
||||||
|
|
||||||
### Static Docs Surface
|
|
||||||
|
|
||||||
Static docs are built directly from two markdown source streams:
|
|
||||||
|
|
||||||
1. Project-authored docs pages
|
|
||||||
2. Skill and reference markdown pages
|
|
||||||
|
|
||||||
The merged docs tree is built by Zensical into static files and served by the FastAPI app.
|
|
||||||
|
|
||||||
Generated `site/` files are deployment assets for the human-facing static site. They are separate from the authored Markdown resources packaged under `personal_mcp/docs/`.
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Authored Skill Directories] --> B[SkillsDirectoryProvider]
|
A[Packaged Skill Directories] --> B[SkillsDirectoryProvider]
|
||||||
B --> C[Native Skill Resources]
|
C[Typed Prompt Components] --> D[FileSystemProvider]
|
||||||
D[Authored Prompts and Docs] --> E[Prompt and Docs Registry]
|
E[Packaged Prompt Markdown] --> C
|
||||||
E --> F[Prompt Catalog and Docs Resources]
|
F[General Markdown] --> G[Docs Registry]
|
||||||
A --> G[Zensical Static Build]
|
B --> H[FastMCP Server]
|
||||||
D --> G
|
D --> H
|
||||||
G --> H[FastAPI Static Mount]
|
G --> H
|
||||||
|
H --> K[MCP Transport]
|
||||||
|
L[Zensical Site Output] --> M[FastAPI Static Mount]
|
||||||
|
K --> M
|
||||||
```
|
```
|
||||||
|
|
||||||
## Contracts
|
Server construction is lazy with respect to package import. Each application process creates its providers and docs snapshot when the server factory runs. Production providers use `reload=False`; content changes require a process restart.
|
||||||
|
|
||||||
### Metadata Contract
|
## Packaging
|
||||||
|
|
||||||
Each skill declares standard frontmatter in `docs/skills/<skill-id>/SKILL.md`.
|
The repository root `docs/` directory is the only authored Markdown source. `src/personal_mcp/docs` is a relative symlink used by source checkouts and editable installs. Hatchling follows it and stores regular files beneath `personal_mcp/docs/` in the wheel.
|
||||||
|
|
||||||
For the full field-level contract, validation model, and FastMCP metadata mapping, see [Frontmatter Contract](./contracts/frontmatter.md).
|
Runtime reads are package-relative:
|
||||||
|
|
||||||
Required fields:
|
1. Prompt content and general docs use `importlib.resources` and `Traversable` APIs.
|
||||||
|
2. `SkillsDirectoryProvider` receives the packaged `personal_mcp/docs/skills` filesystem path.
|
||||||
|
3. No runtime content lookup depends on the current working directory.
|
||||||
|
|
||||||
1. name
|
## Public Contracts
|
||||||
2. description
|
|
||||||
|
|
||||||
The directory name is the provider identity and must match `name`. There is no skill catalog metadata or sidecar.
|
The machine-facing surfaces are:
|
||||||
|
|
||||||
### URI Contract
|
1. Native skill resources under `skill://<name>/...`.
|
||||||
|
2. Native MCP prompt list and get operations.
|
||||||
|
3. `resource://docs/{path*}` for general Markdown.
|
||||||
|
|
||||||
Canonical resource URIs are:
|
Canonical contracts are documented in:
|
||||||
|
|
||||||
For the full URI semantics, parameter validation rules, and compatibility policy, see [URI Contract](./contracts/uris.md).
|
1. [Prompt Contract](./contracts/prompt.md)
|
||||||
|
2. [Skill Contract](./contracts/skill_contract.md)
|
||||||
|
3. [Frontmatter Contract](./contracts/frontmatter.md)
|
||||||
|
4. [URI Contract](./contracts/uris.md)
|
||||||
|
|
||||||
1. skill://<skill_name>/SKILL.md
|
Only these canonical provider and protocol surfaces are registered.
|
||||||
2. skill://<skill_name>/_manifest
|
|
||||||
3. skill://<skill_name>/<supporting_path>
|
|
||||||
4. resource://docs/{path*}
|
|
||||||
5. resource://catalog/prompts_index
|
|
||||||
6. resource://catalog/prompts_index{?q,tag,cursor,limit}
|
|
||||||
7. resource://catalog/prompts/{prompt_id}
|
|
||||||
8. resource://prompts/{prompt_id}/document
|
|
||||||
|
|
||||||
Validation rules:
|
## Static Documentation
|
||||||
|
|
||||||
1. `skill_name` is the lowercase kebab-case skill directory name.
|
Zensical builds `docs/` into `site/` before deployment. FastAPI mounts that immutable output in the same process that hosts FastMCP. Generated `site/` files are deployment assets and are never an authored source.
|
||||||
2. `supporting_path` is a provider-validated relative path within that skill.
|
|
||||||
3. Docs `path*` resolves only to normalized Markdown paths under `docs/`.
|
|
||||||
|
|
||||||
### Resource Registration Contract
|
## Validation
|
||||||
|
|
||||||
Skill resources are registered by one `SkillsDirectoryProvider`; prompt and docs resources remain registered from the validated registry.
|
Changes are accepted only after:
|
||||||
|
|
||||||
Registration rules:
|
1. focused provider and protocol tests
|
||||||
|
2. Ruff and ty checks
|
||||||
1. Use RFC6570 URI templates where appropriate.
|
3. a Zensical build
|
||||||
2. Mark documentation resources as read-only and idempotent.
|
4. the full pytest suite
|
||||||
3. Set explicit mime types for resource responses.
|
5. an installed-wheel smoke test when packaging or provider paths change
|
||||||
4. Configure duplicate URI handling with `on_duplicate="error"` for startup safety.
|
|
||||||
|
|
||||||
This keeps runtime behavior deterministic and prevents accidental URI collisions.
|
|
||||||
|
|
||||||
### Versioning Rule
|
|
||||||
|
|
||||||
URIs are unversioned and canonical in this phase.
|
|
||||||
|
|
||||||
1. Breaking URI changes are handled as direct replacement.
|
|
||||||
2. No compatibility aliases or dual URI families are maintained.
|
|
||||||
|
|
||||||
## Static Hosting Pattern
|
|
||||||
|
|
||||||
The docs site is pre-built and served by the same FastAPI runtime process used by the MCP app.
|
|
||||||
|
|
||||||
Runtime behavior:
|
|
||||||
|
|
||||||
1. App starts.
|
|
||||||
2. FastAPI mounts the static docs output directory.
|
|
||||||
3. Requests to docs paths are served as static assets.
|
|
||||||
|
|
||||||
This provides a single deployment artifact with no runtime markdown rendering dependency.
|
|
||||||
|
|
||||||
## Advantages
|
|
||||||
|
|
||||||
### Single Source of Truth
|
|
||||||
|
|
||||||
Methodology is authored once and reused in both MCP resources and docs pages.
|
|
||||||
|
|
||||||
### High-Fidelity Agent Context
|
|
||||||
|
|
||||||
Resources expose the same canonical Markdown that humans author and review.
|
|
||||||
|
|
||||||
### Operational Simplicity
|
|
||||||
|
|
||||||
A single app process serves MCP and docs surfaces.
|
|
||||||
|
|
||||||
### Long-Term Maintainability
|
|
||||||
|
|
||||||
Markdown remains easy to review, while contracts remain stable for clients.
|
|
||||||
|
|
||||||
### Client Independence
|
|
||||||
|
|
||||||
Clients can use Ask, Edit, or Agent modes without requiring prompt-first orchestration. Prompt objects are available as an additive MCP surface, while resource retrieval remains the canonical source path. 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
|
|
||||||
|
|
||||||
1. Update markdown reference content.
|
|
||||||
2. Keep skill `name` and directory identity aligned.
|
|
||||||
3. Build static docs with Zensical and run provider tests.
|
|
||||||
4. Package authored docs into `personal_mcp/docs/`.
|
|
||||||
5. Serve native MCP resources and the static docs mount.
|
|
||||||
|
|
||||||
## Scope and Non-Goals
|
|
||||||
|
|
||||||
In-scope:
|
|
||||||
|
|
||||||
1. Resource-first methodology delivery
|
|
||||||
2. Native FastMCP skill discovery
|
|
||||||
3. Pre-built static docs hosting in app runtime
|
|
||||||
|
|
||||||
Out-of-scope:
|
|
||||||
|
|
||||||
1. Prompt-first orchestration as the primary interface
|
|
||||||
2. Large tool inventories duplicating static guidance across skill modules
|
|
||||||
3. Separate dynamic docs service at runtime
|
|
||||||
|
|
||||||
The prompt catalog remains an independent surface. Tool-only skill clients use generic resource tools rather than a skill-specific compatibility layer.
|
|
||||||
|
|
||||||
## Example Content Inputs
|
|
||||||
|
|
||||||
Existing markdown reference sets are valid examples of authored source material for this architecture:
|
|
||||||
|
|
||||||
1. docs/skills/pytesting/references/pytest-docs.md
|
|
||||||
2. docs/skills/python-logging/references/python-logging-docs.md
|
|
||||||
3. docs/skills/python-logging/references/json-file-logging.md
|
|
||||||
4. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
|
||||||
|
|
||||||
These inputs are treated as content sources, while native skill URIs and generated manifests form the machine-facing skill contract.
|
|
||||||
|
|||||||
+10
-9
@@ -72,22 +72,23 @@ Recommended sequence:
|
|||||||
|
|
||||||
## Prompt Authoring
|
## Prompt Authoring
|
||||||
|
|
||||||
Prompts remain registry-backed:
|
Prompts pair typed Python metadata with canonical Markdown prose:
|
||||||
|
|
||||||
1. Keep one canonical `PROMPT.md`.
|
1. Add one `@prompt` function under `src/personal_mcp/prompts/components/`.
|
||||||
2. Align directory name, `name`, and `x-personal-mcp.id`.
|
2. Use the function signature for arguments, requiredness, and literal constraints.
|
||||||
3. Include `resource://prompts/<prompt-id>/document` in capabilities.
|
3. Set name, description, tags, and version on the decorator.
|
||||||
4. Define arguments beneath `x-personal-mcp.arguments`.
|
4. Keep the canonical body in `docs/prompts/<prompt-id>/PROMPT.md`.
|
||||||
5. Keep long rationale and sources in `references/`.
|
5. Keep Markdown placeholders exactly equal to the Python argument names.
|
||||||
|
6. Use only documentation-site fields in Markdown frontmatter.
|
||||||
|
|
||||||
Prompt argument names must be valid Python identifiers. Each argument accepts optional `title`, `description`, and `required`; unknown fields fail strict validation.
|
The production `FileSystemProvider` discovers component modules. Do not add prompt registry models, catalog resources, or dynamic signature generation.
|
||||||
|
|
||||||
## Frontmatter Safety
|
## Frontmatter Safety
|
||||||
|
|
||||||
1. Quote scalar values containing `:`.
|
1. Quote scalar values containing `:`.
|
||||||
2. Quote values with reserved YAML characters such as `#`, `{}`, `[]`, or leading `*`.
|
2. Quote values with reserved YAML characters such as `#`, `{}`, `[]`, or leading `*`.
|
||||||
3. Use block scalars for punctuation-heavy multiline text.
|
3. Use block scalars for punctuation-heavy multiline text.
|
||||||
4. Keep fields within the applicable skill or prompt contract.
|
4. Keep fields within the applicable skill or documentation contract.
|
||||||
|
|
||||||
## Writing Quality
|
## Writing Quality
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ Active instructions should point directly to native main resources:
|
|||||||
2. `skill://pytesting/SKILL.md`
|
2. `skill://pytesting/SKILL.md`
|
||||||
3. `skill://vscode-configuration/SKILL.md`
|
3. `skill://vscode-configuration/SKILL.md`
|
||||||
|
|
||||||
When deeper guidance is needed, read the selected skill's `_manifest` and fetch supporting files by their listed path. Tool-only clients use `list_resources` and `read_resource` over the same URIs.
|
When deeper guidance is needed, read the selected skill's `_manifest` and fetch supporting files by their listed path.
|
||||||
|
|
||||||
## Validation Checklist
|
## Validation Checklist
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ icon: lucide/braces
|
|||||||
|
|
||||||
# Frontmatter Contract
|
# Frontmatter Contract
|
||||||
|
|
||||||
This page defines the authored frontmatter contracts for native FastMCP skills and registry-backed prompts.
|
This page defines frontmatter ownership for native skills and prompt documentation.
|
||||||
|
|
||||||
## Skill Frontmatter
|
## Skill Frontmatter
|
||||||
|
|
||||||
@@ -27,38 +27,22 @@ Rules:
|
|||||||
|
|
||||||
The provider uses the directory name as the URI identity and the frontmatter `description` as the main resource description. Repository tests enforce directory/name parity and reject extra skill frontmatter fields.
|
The provider uses the directory name as the URI identity and the frontmatter `description` as the main resource description. Repository tests enforce directory/name parity and reject extra skill frontmatter fields.
|
||||||
|
|
||||||
## Prompt Frontmatter
|
## Prompt Documentation Frontmatter
|
||||||
|
|
||||||
Prompts remain registry-backed and retain repository metadata:
|
Prompt runtime metadata is defined by typed Python components, not Markdown frontmatter. A prompt document may retain only fields consumed by the static documentation site:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
---
|
---
|
||||||
name: <prompt-id>
|
icon: lucide/messages-square
|
||||||
description: <what the prompt does and when to use it>
|
|
||||||
x-personal-mcp:
|
|
||||||
id: <prompt-id>
|
|
||||||
version: <semver>
|
|
||||||
tags:
|
|
||||||
- <tag>
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/<prompt-id>/document
|
|
||||||
arguments:
|
|
||||||
<argument-name>:
|
|
||||||
title: <display title>
|
|
||||||
description: <input guidance>
|
|
||||||
required: true
|
|
||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
Prompt rules:
|
Prompt rules:
|
||||||
|
|
||||||
1. `name`, `description`, and `x-personal-mcp` are required.
|
1. Do not duplicate prompt names, descriptions, versions, tags, or arguments in Markdown YAML.
|
||||||
2. `x-personal-mcp.id`, `name`, and the prompt directory name must match.
|
2. Argument names must be valid Python identifiers in the component signature.
|
||||||
3. `version` must be semantic version text.
|
3. Literal value constraints belong in Python type annotations.
|
||||||
4. `capabilities` must include `resource://prompts/<prompt-id>/document`.
|
4. Markdown placeholders must exactly match the component argument names.
|
||||||
5. Argument names must be valid Python identifiers.
|
|
||||||
6. Argument entries accept optional `title`, `description`, and `required` fields.
|
|
||||||
7. Unknown prompt fields are rejected by the strict Pydantic registry models.
|
|
||||||
|
|
||||||
See the MCP [prompts concept documentation](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) and [schema reference](https://modelcontextprotocol.io/specification/latest/schema) for the protocol-level prompt shape.
|
See the MCP [prompts concept documentation](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) and [schema reference](https://modelcontextprotocol.io/specification/latest/schema) for the protocol-level prompt shape.
|
||||||
|
|
||||||
@@ -70,11 +54,11 @@ Skill validation is file- and provider-oriented:
|
|||||||
2. FastMCP parses the description and scans all files when the provider is created.
|
2. FastMCP parses the description and scans all files when the provider is created.
|
||||||
3. Repository tests enforce the stricter standard-only frontmatter and directory/name rules.
|
3. Repository tests enforce the stricter standard-only frontmatter and directory/name rules.
|
||||||
|
|
||||||
Prompt validation remains registry-oriented and fails server startup for invalid metadata, duplicate prompt ids, or malformed arguments.
|
Prompt validation is provider- and renderer-oriented. Provider discovery validates decorated functions, while focused tests render every prompt and reject placeholder drift.
|
||||||
|
|
||||||
## Invariants
|
## Invariants
|
||||||
|
|
||||||
1. Skills remain directly portable to tools that understand standard Agent Skills directories.
|
1. Skills remain directly portable to tools that understand standard Agent Skills directories.
|
||||||
2. Native skill discovery has no parallel catalog metadata source.
|
2. Native skill discovery has no parallel catalog metadata source.
|
||||||
3. Prompts retain the richer metadata required by their catalog and MCP prompt-object surfaces.
|
3. Prompts use FastMCP's native component metadata and protocol surface without a parallel catalog.
|
||||||
4. All authored content remains under `docs/`.
|
4. All authored content remains under `docs/`.
|
||||||
|
|||||||
+27
-20
@@ -4,11 +4,11 @@ icon: lucide/messages-square
|
|||||||
|
|
||||||
# Prompt Contract
|
# Prompt Contract
|
||||||
|
|
||||||
This page defines the canonical contract for prompts in the docs-first MCP architecture.
|
This page defines the canonical contract for typed prompts discovered by FastMCP's `FileSystemProvider`.
|
||||||
|
|
||||||
## Canonical Prompt Shape
|
## Canonical Prompt Shape
|
||||||
|
|
||||||
Each prompt is one directory under `docs/prompts/`:
|
Each prompt has a Python component and one canonical Markdown document:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
---
|
---
|
||||||
@@ -22,27 +22,31 @@ config:
|
|||||||
lineColor: '#FFFFFF'
|
lineColor: '#FFFFFF'
|
||||||
---
|
---
|
||||||
treeView-beta
|
treeView-beta
|
||||||
"docs/"
|
"src/personal_mcp/prompts/"
|
||||||
"... (other docs)"
|
"components/"
|
||||||
"prompts/"
|
"<prompt_module>.py"
|
||||||
|
"content.py"
|
||||||
|
"provider.py"
|
||||||
|
"docs/prompts/"
|
||||||
"<prompt-id>/"
|
"<prompt-id>/"
|
||||||
"PROMPT.md"
|
"PROMPT.md"
|
||||||
"references/"
|
|
||||||
"... (one or more markdown files, optional nested folders)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
1. `PROMPT.md` is required for every prompt.
|
1. Each component exports one typed function decorated with `@prompt`.
|
||||||
2. `references/` is the only place for prompt-specific supporting docs.
|
2. Function parameters define the MCP argument names, requiredness, and accepted values.
|
||||||
3. Nested folders inside `references/` are allowed so a prompt can reorganize internals without changing global architecture.
|
3. Decorator fields define runtime name, description, tags, and version.
|
||||||
4. Prompt directories are independent ownership boundaries; no cross-prompt file writes.
|
4. The function loads its matching `docs/prompts/<prompt-id>/PROMPT.md` through `importlib.resources`.
|
||||||
|
5. `PROMPT.md` owns the rendered prompt prose and uses `{argument_name}` placeholders.
|
||||||
|
6. The renderer requires exact equality between the function's arguments and the Markdown placeholders.
|
||||||
|
|
||||||
## Metadata Location Constraint
|
## Ownership Boundary
|
||||||
|
|
||||||
1. Prompt metadata is embedded in YAML frontmatter in `PROMPT.md`.
|
1. Python owns runtime metadata and the callable schema.
|
||||||
2. No `metadata.yaml` sidecar exists in the end state.
|
2. Markdown owns prompt prose and may contain only documentation-site frontmatter.
|
||||||
3. Reference lookup metadata is documented and explicit: top-level `references/*.md` are auto-discovered from filenames, while `PROMPT.md` frontmatter declares overrides and nested mappings when needed.
|
3. There is no custom prompt catalog, prompt registry model, or metadata sidecar.
|
||||||
|
4. `FileSystemProvider(reload=False)` discovers components when the server is created.
|
||||||
|
|
||||||
## Prompt Id Contract
|
## Prompt Id Contract
|
||||||
|
|
||||||
@@ -53,8 +57,8 @@ Rules:
|
|||||||
3. Must start with a letter.
|
3. Must start with a letter.
|
||||||
4. No underscores, spaces, dots, or uppercase characters.
|
4. No underscores, spaces, dots, or uppercase characters.
|
||||||
5. Directory name should equal `prompt-id` in each committed revision.
|
5. Directory name should equal `prompt-id` in each committed revision.
|
||||||
6. Frontmatter `id` should equal directory name in each committed revision.
|
6. The `@prompt` name and Markdown directory name must equal `prompt-id`.
|
||||||
7. Treat `prompt-id` as immutable after release; any rename is a breaking replacement and clients must move to the new id.
|
7. Treat `prompt-id` as immutable after release; a rename is a breaking replacement.
|
||||||
|
|
||||||
Valid examples:
|
Valid examples:
|
||||||
|
|
||||||
@@ -68,8 +72,11 @@ Invalid examples:
|
|||||||
2. `Prompt-Template`
|
2. `Prompt-Template`
|
||||||
3. `docs.prompt`
|
3. `docs.prompt`
|
||||||
|
|
||||||
## Direct Documentation Inclusion
|
## Rendering Contract
|
||||||
|
|
||||||
1. For direct API documentation, use mkdocstrings directives rather than pasting large code blocks.
|
1. The renderer strips one leading YAML frontmatter block before returning prompt content.
|
||||||
2. Keep manually-authored code examples short and task-focused; large implementation excerpts are out of scope for this contract.
|
2. Required values are supplied by the typed function signature.
|
||||||
|
3. An omitted optional value renders as `Not provided`.
|
||||||
|
4. Unknown prompt ids and mismatched placeholders fail immediately.
|
||||||
|
5. Prompt content is read from packaged resources and does not depend on the working directory.
|
||||||
|
|
||||||
|
|||||||
+8
-13
@@ -4,7 +4,7 @@ icon: lucide/link
|
|||||||
|
|
||||||
# URI Contract
|
# URI Contract
|
||||||
|
|
||||||
This page defines the public resource URI contract for native skills, registry-backed prompts, and general authored documentation.
|
This page defines the public resource URI contract for native skills and general authored documentation.
|
||||||
|
|
||||||
## Native Skill URIs
|
## Native Skill URIs
|
||||||
|
|
||||||
@@ -44,17 +44,11 @@ skill://pytesting/references/pytest-docs.md
|
|||||||
|
|
||||||
FastMCP confines reads to the selected skill directory. Absolute paths, traversal outside the directory, missing files, directories, and symlinks that resolve outside the skill root are rejected.
|
FastMCP confines reads to the selected skill directory. Absolute paths, traversal outside the directory, missing files, directories, and symlinks that resolve outside the skill root are rejected.
|
||||||
|
|
||||||
## Prompt And Docs URIs
|
## General Docs URI
|
||||||
|
|
||||||
Prompts and general documentation retain the existing registry-backed resource surface:
|
General authored documentation is exposed through `resource://docs/{path*}`. The wildcard accepts normalized relative POSIX Markdown paths beneath `docs/`, excludes the provider-owned `skills/` subtree, and rejects absolute paths, traversal segments, backslashes, and non-Markdown targets.
|
||||||
|
|
||||||
1. `resource://catalog/prompts_index`
|
Prompts are MCP prompt components rather than resources. Clients discover them with the protocol `prompts/list` operation and render them with `prompts/get`.
|
||||||
2. `resource://catalog/prompts_index{?q,tag,cursor,limit}`
|
|
||||||
3. `resource://catalog/prompts/{prompt_id}`
|
|
||||||
4. `resource://prompts/{prompt_id}/document`
|
|
||||||
5. `resource://docs/{path*}`
|
|
||||||
|
|
||||||
Prompt ids remain lowercase kebab-case. The docs wildcard accepts normalized relative POSIX Markdown paths beneath `docs/` and rejects absolute paths, traversal segments, backslashes, and non-Markdown targets.
|
|
||||||
|
|
||||||
## Discovery Order
|
## Discovery Order
|
||||||
|
|
||||||
@@ -66,11 +60,11 @@ For skills:
|
|||||||
4. read `_manifest` when supporting material may be needed
|
4. read `_manifest` when supporting material may be needed
|
||||||
5. fetch only the supporting paths relevant to the task
|
5. fetch only the supporting paths relevant to the task
|
||||||
|
|
||||||
For prompts, use the prompt catalog or MCP prompt-object APIs.
|
For prompts, use the native MCP prompt APIs or their generic tool projection.
|
||||||
|
|
||||||
## Compatibility Policy
|
## Stability Policy
|
||||||
|
|
||||||
The native `skill://` family directly replaces the repository's former custom skill URI and catalog surfaces. No compatibility aliases or dual registrations are maintained. Prompt and general-doc URIs are unaffected.
|
The provider and protocol surfaces documented here are the complete public contract. Contract changes replace the affected surface directly.
|
||||||
|
|
||||||
Skill renames are breaking because the directory name is part of every native skill URI. Supporting-file renames change the corresponding manifest path and URI.
|
Skill renames are breaking because the directory name is part of every native skill URI. Supporting-file renames change the corresponding manifest path and URI.
|
||||||
|
|
||||||
@@ -80,3 +74,4 @@ Skill renames are breaking because the directory name is part of every native sk
|
|||||||
2. [MCP resources](https://modelcontextprotocol.io/specification/latest/server/resources)
|
2. [MCP resources](https://modelcontextprotocol.io/specification/latest/server/resources)
|
||||||
3. [RFC 3986 URI syntax](https://www.rfc-editor.org/rfc/rfc3986)
|
3. [RFC 3986 URI syntax](https://www.rfc-editor.org/rfc/rfc3986)
|
||||||
4. [RFC 6570 URI templates](https://www.rfc-editor.org/rfc/rfc6570)
|
4. [RFC 6570 URI templates](https://www.rfc-editor.org/rfc/rfc6570)
|
||||||
|
5. [FastMCP prompts](https://gofastmcp.com/servers/prompts)
|
||||||
|
|||||||
+3
-21
@@ -6,7 +6,7 @@ icon: lucide/bot
|
|||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This page explains how GitHub Copilot in VS Code consumes native skill resources from `personal-mcp`, including sessions where tools are visible but resource attachment is not.
|
This page explains how GitHub Copilot in VS Code consumes native skill resources and prompts from `personal-mcp`.
|
||||||
|
|
||||||
## Capability Lanes
|
## Capability Lanes
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ Copilot interacts with MCP servers through independently exposed lanes:
|
|||||||
2. resources attached as read-only context
|
2. resources attached as read-only context
|
||||||
3. server-provided prompts
|
3. server-provided prompts
|
||||||
|
|
||||||
This server publishes skills as native `skill://` resources, prompts through registry-backed resources and MCP prompt objects, and generic resource fallback tools through FastMCP.
|
This server publishes skills as native `skill://` resources and prompts as native MCP prompt objects.
|
||||||
|
|
||||||
## Native Skill Resources
|
## Native Skill Resources
|
||||||
|
|
||||||
@@ -39,22 +39,11 @@ A successful `resources/list` response does not guarantee the picker appears in
|
|||||||
|
|
||||||
## Recommended Workflow
|
## Recommended Workflow
|
||||||
|
|
||||||
When resource attachment is available:
|
|
||||||
|
|
||||||
1. browse the server's resources
|
1. browse the server's resources
|
||||||
2. attach one relevant `skill://<name>/SKILL.md`
|
2. attach one relevant `skill://<name>/SKILL.md`
|
||||||
3. attach `_manifest` only if supporting detail may be needed
|
3. attach `_manifest` only if supporting detail may be needed
|
||||||
4. attach only selected supporting files
|
4. attach only selected supporting files
|
||||||
|
|
||||||
When only tools are available:
|
|
||||||
|
|
||||||
1. call `list_resources`
|
|
||||||
2. select a native main skill URI by name and description
|
|
||||||
3. call `read_resource` for that URI
|
|
||||||
4. read `_manifest` and supporting files only as needed
|
|
||||||
|
|
||||||
Both paths resolve through the same FastMCP provider.
|
|
||||||
|
|
||||||
## Prompt Examples
|
## Prompt Examples
|
||||||
|
|
||||||
Resource attachment:
|
Resource attachment:
|
||||||
@@ -63,12 +52,6 @@ Resource attachment:
|
|||||||
Use the attached personal-mcp skill as guidance, then reconcile it with the repository before proposing changes.
|
Use the attached personal-mcp skill as guidance, then reconcile it with the repository before proposing changes.
|
||||||
```
|
```
|
||||||
|
|
||||||
Tool-only discovery:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Call list_resources, choose the best matching skill://.../SKILL.md resource, and read it. Inspect its _manifest only if a supporting file is needed. Load at most two candidate skills.
|
|
||||||
```
|
|
||||||
|
|
||||||
Direct loading:
|
Direct loading:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -89,7 +72,7 @@ A repo-level instruction should name the native retrieval order and context budg
|
|||||||
When a task matches a personal-mcp skill:
|
When a task matches a personal-mcp skill:
|
||||||
|
|
||||||
1. Prefer an already attached native skill resource.
|
1. Prefer an already attached native skill resource.
|
||||||
2. Otherwise use `list_resources` and select one `skill://<name>/SKILL.md` resource by description.
|
2. Otherwise browse MCP resources and select one `skill://<name>/SKILL.md` resource by description.
|
||||||
3. Read `_manifest` only when supporting material is needed.
|
3. Read `_manifest` only when supporting material is needed.
|
||||||
4. Load at most two candidate main files and only the relevant supporting paths.
|
4. Load at most two candidate main files and only the relevant supporting paths.
|
||||||
5. Reconcile guidance with the current repository before editing.
|
5. Reconcile guidance with the current repository before editing.
|
||||||
@@ -107,7 +90,6 @@ Prompt modules remain separate from skills. When the client supports MCP prompt
|
|||||||
2. Use `MCP: Browse Resources` to confirm native skill resources exist.
|
2. Use `MCP: Browse Resources` to confirm native skill resources exist.
|
||||||
3. Restart the MCP server after changing skill files because production uses `reload=False`.
|
3. Restart the MCP server after changing skill files because production uses `reload=False`.
|
||||||
4. Reload the VS Code window if the server is healthy but the resource picker remains stale.
|
4. Reload the VS Code window if the server is healthy but the resource picker remains stale.
|
||||||
5. In tool-only sessions, verify `list_resources` and `read_resource` are visible.
|
|
||||||
|
|
||||||
## Further Reading
|
## Further Reading
|
||||||
|
|
||||||
|
|||||||
+52
-157
@@ -2,199 +2,94 @@
|
|||||||
icon: lucide/server
|
icon: lucide/server
|
||||||
---
|
---
|
||||||
|
|
||||||
# Static Docs Hosting Pattern
|
# Runtime And Static Docs Layout
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
This document describes the completed layout and runtime pattern used to host a pre-built static documentation site from the same FastAPI app process that runs the FastMCP server.
|
The project serves native MCP content and a pre-built documentation site from one FastAPI process. Markdown is authored once under `docs/`; runtime providers and Zensical consume that same packaged tree for different purposes.
|
||||||
|
|
||||||
This design intentionally avoids runtime docs rendering and avoids a separate docs hosting service.
|
## Repository Layout
|
||||||
|
|
||||||
It also treats Markdown as the single source of truth for both MCP resources and published docs.
|
|
||||||
|
|
||||||
## Completed-State Layout
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
---
|
---
|
||||||
config:
|
config:
|
||||||
treeView:
|
treeView:
|
||||||
rowIndent: 40
|
rowIndent: 32
|
||||||
lineThickness: 2
|
lineThickness: 2
|
||||||
themeVariables:
|
|
||||||
treeView:
|
|
||||||
labelColor: '#FFFFFF'
|
|
||||||
lineColor: '#FFFFFF'
|
|
||||||
---
|
---
|
||||||
treeView-beta
|
treeView-beta
|
||||||
"project-root"
|
"project-root"
|
||||||
"pyproject.toml"
|
|
||||||
"uv.lock"
|
|
||||||
"zensical.toml"
|
|
||||||
"docs"
|
"docs"
|
||||||
"index.md"
|
"prompts/<prompt-id>/PROMPT.md"
|
||||||
"<project-docs>.md"
|
"skills/<skill-id>/SKILL.md"
|
||||||
"contracts"
|
"skills/<skill-id>/<supporting-files>"
|
||||||
"index.md"
|
"<general-pages>.md"
|
||||||
"<contract-pages>.md"
|
|
||||||
"mcp_layout.md"
|
|
||||||
"prompts"
|
|
||||||
"<prompt-id>"
|
|
||||||
"PROMPT.md"
|
|
||||||
"references"
|
|
||||||
"skills"
|
|
||||||
"<skill-id>"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"<reference>.md"
|
|
||||||
"site"
|
"site"
|
||||||
"static build output"
|
"static build output"
|
||||||
"src"
|
"src/personal_mcp"
|
||||||
"personal_mcp"
|
|
||||||
"__init__.py"
|
|
||||||
"main.py"
|
|
||||||
"mcp.py"
|
"mcp.py"
|
||||||
"catalog"
|
"prompts/components/*.py"
|
||||||
"<catalog-modules>.py"
|
"prompts/content.py"
|
||||||
"registry"
|
"prompts/provider.py"
|
||||||
"<registry-modules>.py"
|
"registry/"
|
||||||
"web"
|
"skills/provider.py"
|
||||||
"<web-modules>.py"
|
"web/"
|
||||||
"skills"
|
|
||||||
"<skills-modules>.py"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Ownership rules:
|
||||||
|
|
||||||
1. docs contains both project-authored pages and the canonical skill Markdown tree.
|
1. `docs/skills/` is owned exclusively by `SkillsDirectoryProvider` at runtime.
|
||||||
2. site contains static build output only.
|
2. `docs/prompts/` owns prompt prose; Python components own prompt metadata and argument schemas.
|
||||||
3. docs/skills contains canonical skill Markdown and reference Markdown.
|
3. The docs registry owns only general Markdown resources and explicitly excludes skills.
|
||||||
4. docs/prompts contains canonical prompt Markdown used for prompt catalog and document surfaces.
|
4. `site/` is generated output.
|
||||||
5. MCP resources and docs site read from the same Markdown sources.
|
5. The deleted custom `catalog/` package is not part of the runtime.
|
||||||
|
|
||||||
## Runtime Composition
|
## Runtime Composition
|
||||||
|
|
||||||
The runtime process serves two surfaces:
|
|
||||||
|
|
||||||
1. MCP protocol surface from FastMCP
|
|
||||||
2. Static docs surface from FastAPI static mount
|
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
A[Packaged Skill Directory] --> B[SkillsDirectoryProvider]
|
A[Packaged Skills] --> B[SkillsDirectoryProvider]
|
||||||
C[Packaged Prompts and Docs] --> D[Validated Registry]
|
C[Prompt Components] --> D[FileSystemProvider]
|
||||||
B --> E[FastMCP Server]
|
E[Packaged Markdown] --> F[Docs Registry]
|
||||||
D --> E
|
B --> G[FastMCP]
|
||||||
E --> F[MCP Transport]
|
D --> G
|
||||||
E --> G[FastAPI Application]
|
F --> G
|
||||||
G --> H[Static Mount /docs]
|
G --> H[MCP Transport]
|
||||||
H --> I[Zensical Site Output]
|
H --> K[FastAPI Application]
|
||||||
|
L[Pre-built site] --> M[Static /docs Mount]
|
||||||
|
K --> M
|
||||||
```
|
```
|
||||||
|
|
||||||
Runtime guarantees:
|
Runtime guarantees:
|
||||||
|
|
||||||
1. The skills provider and prompt/docs registry initialize before resource exposure.
|
1. Providers are installed before serving requests.
|
||||||
2. Duplicate resource and template registration fails startup (`on_duplicate="error"`).
|
2. Production provider discovery uses `reload=False`.
|
||||||
3. Skill resources come directly from `SkillsDirectoryProvider` directory discovery.
|
3. Duplicate components fail according to FastMCP's configured duplicate policy.
|
||||||
4. Legacy per-skill Python servers, custom skill catalogs, and metadata sidecars are not part of the runtime.
|
4. Skills and prompts use native FastMCP component surfaces.
|
||||||
|
5. General docs path parsing rejects traversal, backslashes, non-Markdown paths, and the skill namespace.
|
||||||
|
|
||||||
## Build and Publish Flow
|
## Build And Publish Flow
|
||||||
|
|
||||||
The docs flow is pre-build only.
|
1. Author Markdown under `docs/` and typed prompts under `src/personal_mcp/prompts/components/`.
|
||||||
|
2. Run `uv run zensical build` to produce `site/`.
|
||||||
|
3. Build the wheel, which packages the authored docs under `personal_mcp/docs/`.
|
||||||
|
4. Start the app and serve MCP plus the static site.
|
||||||
|
|
||||||
1. Read authored docs pages and skill markdown sources.
|
No runtime Markdown-to-HTML conversion occurs.
|
||||||
2. Build static site with Zensical into site.
|
|
||||||
3. Start app and serve site directory as static files.
|
|
||||||
|
|
||||||
No runtime markdown conversion is required.
|
## Machine-Facing Mapping
|
||||||
|
|
||||||
## Content Merge Pattern
|
1. `docs/skills/<skill-id>/SKILL.md` maps to `skill://<skill-id>/SKILL.md`.
|
||||||
|
2. Skill supporting files map to `skill://<skill-id>/<path>`.
|
||||||
|
3. Typed prompt components map to native MCP prompt names.
|
||||||
|
4. General `docs/<path>.md` maps to `resource://docs/{path*}`.
|
||||||
|
|
||||||
The published docs site always contains both:
|
The server publishes no tool projections of resources or prompts.
|
||||||
|
|
||||||
1. Project-authored docs pages
|
## Public Surface Policy
|
||||||
2. Skill Markdown content from docs/skills/*/SKILL.md and references
|
|
||||||
|
|
||||||
This ensures the public docs reflect architectural guidance and the exact Markdown served by MCP.
|
Canonical provider and protocol surfaces are the only public interfaces.
|
||||||
|
|
||||||
## Markdown-to-Resource Mapping
|
## Static Mount Expectations
|
||||||
|
|
||||||
MCP resources map directly to canonical Markdown documents.
|
The FastAPI app mounts the Zensical output, serves index and asset files, and returns a clear unavailable response when the static output is absent. The site directory is immutable for a given build and remains separate from packaged authored Markdown.
|
||||||
|
|
||||||
Example mapping model:
|
|
||||||
|
|
||||||
1. docs/skills/<skill-id>/SKILL.md -> skill://<skill-id>/SKILL.md
|
|
||||||
2. docs/skills/<skill-id>/<path> -> skill://<skill-id>/<path>
|
|
||||||
3. docs/<path>.md -> resource://docs/{path*}
|
|
||||||
|
|
||||||
Catalog discovery resources are:
|
|
||||||
|
|
||||||
1. resource://catalog/prompts_index
|
|
||||||
2. resource://catalog/prompts_index{?q,tag,cursor,limit}
|
|
||||||
3. resource://catalog/prompts/{prompt_id}
|
|
||||||
|
|
||||||
Resource registration details:
|
|
||||||
|
|
||||||
1. `skill://<skill-id>/SKILL.md` resolves to each skill's main instructions.
|
|
||||||
2. `skill://<skill-id>/_manifest` lists every skill file with size and SHA256 hash.
|
|
||||||
3. Per-skill wildcard templates resolve validated supporting-file paths.
|
|
||||||
4. `resource://docs/{path*}` resolves normalized Markdown paths under `docs/`.
|
|
||||||
|
|
||||||
When clients cannot attach MCP resources directly, `ResourcesAsTools` exposes generic `list_resources` and `read_resource` tools over the same provider resources.
|
|
||||||
|
|
||||||
## URI Compatibility Policy
|
|
||||||
|
|
||||||
1. Canonical URIs are the only supported URIs in this runtime.
|
|
||||||
2. No backward-compatibility aliases or dual registration paths are maintained.
|
|
||||||
3. Contract changes should update clients to canonical URIs directly.
|
|
||||||
|
|
||||||
## Why This Pattern
|
|
||||||
|
|
||||||
### Operational Simplicity
|
|
||||||
|
|
||||||
One application process serves both protocol and static docs surfaces.
|
|
||||||
|
|
||||||
### Deterministic Docs
|
|
||||||
|
|
||||||
Published docs are immutable static assets for a given build.
|
|
||||||
|
|
||||||
### Documentation Fidelity
|
|
||||||
|
|
||||||
The docs site and MCP resources resolve from the same Markdown sources.
|
|
||||||
|
|
||||||
### Maintainer Experience
|
|
||||||
|
|
||||||
Authors continue to work in markdown while resource contracts remain machine-consumable.
|
|
||||||
|
|
||||||
## FastAPI Static Mount Expectations
|
|
||||||
|
|
||||||
The FastAPI app is expected to:
|
|
||||||
|
|
||||||
1. Mount static directory containing Zensical output.
|
|
||||||
2. Serve index and asset files from that directory.
|
|
||||||
3. Keep docs route stable across releases.
|
|
||||||
|
|
||||||
Recommended route conventions:
|
|
||||||
|
|
||||||
1. /docs for static site root
|
|
||||||
2. /docs/* for static assets and page routes
|
|
||||||
|
|
||||||
## Update Lifecycle
|
|
||||||
|
|
||||||
For each documentation update:
|
|
||||||
|
|
||||||
1. Edit authored docs and skill markdown content.
|
|
||||||
2. Rebuild static site.
|
|
||||||
3. Restart runtime if needed.
|
|
||||||
|
|
||||||
This keeps docs publication explicit and predictable.
|
|
||||||
|
|
||||||
## Example Source Material
|
|
||||||
|
|
||||||
Existing reference docs remain valid content inputs in this pattern:
|
|
||||||
|
|
||||||
1. docs/skills/pytesting/references/pytest-docs.md
|
|
||||||
2. docs/skills/python-logging/references/python-logging-docs.md
|
|
||||||
3. docs/skills/python-logging/references/json-file-logging.md
|
|
||||||
4. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
|
||||||
|
|
||||||
These are source documents, not deployment artifacts.
|
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: authoring
|
icon: lucide/messages-square
|
||||||
description: Provide a practical checklist and baseline template for authoring docs-first MCP modules and repository-specific Copilot instruction shims.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: authoring
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- authoring
|
|
||||||
- mcp
|
|
||||||
- fastmcp
|
|
||||||
- copilot
|
|
||||||
- prompts
|
|
||||||
- scaffolding
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/authoring/document
|
|
||||||
arguments:
|
|
||||||
artifact_type:
|
|
||||||
title: Artifact type
|
|
||||||
description: "Enum (case-sensitive): skill | prompt | shim."
|
|
||||||
required: true
|
|
||||||
artifact_id:
|
|
||||||
title: Artifact id
|
|
||||||
description: Lowercase kebab-case id for the module or shim.
|
|
||||||
required: true
|
|
||||||
goal:
|
|
||||||
title: Goal
|
|
||||||
description: One-sentence capability statement describing what to create and when to use it.
|
|
||||||
required: true
|
|
||||||
scope_glob:
|
|
||||||
title: Scope glob
|
|
||||||
description: Optional applyTo glob for shim outputs.
|
|
||||||
required: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Authoring Bootstrap
|
# Authoring Bootstrap
|
||||||
|
|
||||||
Use this prompt to author or update docs-first MCP modules in this repository, including repository-specific Copilot thin shims.
|
Use this prompt to author or update docs-first MCP modules in this repository, including repository-specific Copilot thin shims.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `artifact_type`: {{artifact_type}}
|
||||||
|
- `artifact_id`: {{artifact_id}}
|
||||||
|
- `goal`: {{goal}}
|
||||||
|
- `scope_glob`: {{scope_glob}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
1. artifact_type: one of skill, prompt, shim
|
1. artifact_type: one of skill, prompt, shim
|
||||||
@@ -51,7 +28,7 @@ Load only what matches the requested artifact:
|
|||||||
2. Prompt metadata and structure: [Prompt Contract](../../contracts/prompt.md)
|
2. Prompt metadata and structure: [Prompt Contract](../../contracts/prompt.md)
|
||||||
3. Skill metadata and structure (only for skill outputs): [Skill Contract](../../contracts/skill_contract.md)
|
3. Skill metadata and structure (only for skill outputs): [Skill Contract](../../contracts/skill_contract.md)
|
||||||
4. Thin shim mechanics and path binding: [Skill Usage Mechanics](../../usage.md)
|
4. Thin shim mechanics and path binding: [Skill Usage Mechanics](../../usage.md)
|
||||||
5. Copilot resource attachment and fallback behavior: [Copilot MCP Mechanics](../../copilot.md)
|
5. Copilot resource attachment behavior: [Copilot MCP Mechanics](../../copilot.md)
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -70,11 +47,8 @@ Load only what matches the requested artifact:
|
|||||||
8. Keep guidance deterministic and minimal, with explicit references to source docs.
|
8. Keep guidance deterministic and minimal, with explicit references to source docs.
|
||||||
9. If artifact_type is shim:
|
9. If artifact_type is shim:
|
||||||
- bind one applyTo scope to one `skill://<name>/SKILL.md` resource URI
|
- bind one applyTo scope to one `skill://<name>/SKILL.md` resource URI
|
||||||
- prefer MCP resource attachment first
|
- use MCP resource attachment
|
||||||
- inspect the selected skill's `_manifest` only when supporting material is needed
|
- inspect the selected skill's `_manifest` only when supporting material is needed
|
||||||
- if resource attachment is unavailable, use the generic fallback tools:
|
|
||||||
1. list_resources
|
|
||||||
2. read_resource
|
|
||||||
10. Return created or updated file paths and any validation commands that should be run.
|
10. Return created or updated file paths and any validation commands that should be run.
|
||||||
|
|
||||||
## Output Contract
|
## Output Contract
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: greenfield-architecture
|
icon: lucide/messages-square
|
||||||
description: Research established patterns and design a high-level architecture for a new app or library with explicit tradeoffs and test strategy.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: greenfield-architecture
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- architecture
|
|
||||||
- planning
|
|
||||||
- greenfield
|
|
||||||
- design
|
|
||||||
- testing
|
|
||||||
- prompts
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/greenfield-architecture/document
|
|
||||||
arguments:
|
|
||||||
scope_type:
|
|
||||||
title: Scope type
|
|
||||||
description: "Scope type: app or library."
|
|
||||||
required: true
|
|
||||||
intent_document:
|
|
||||||
title: Intent document
|
|
||||||
description: Optional full document describing goals, context, and desired outcomes.
|
|
||||||
required: false
|
|
||||||
problem_domain:
|
|
||||||
title: Problem domain
|
|
||||||
description: Domain and business goal for the new app or library when no full intent document is provided.
|
|
||||||
required: false
|
|
||||||
constraints:
|
|
||||||
title: Constraints
|
|
||||||
description: Runtime, deployment, and non-functional constraints.
|
|
||||||
required: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Greenfield Architecture Planner
|
# Greenfield Architecture Planner
|
||||||
|
|
||||||
Use this prompt to design a new software app or library architecture in generic terms.
|
Use this prompt to design a new software app or library architecture in generic terms.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `scope_type`: {{scope_type}}
|
||||||
|
- `intent_document`: {{intent_document}}
|
||||||
|
- `problem_domain`: {{problem_domain}}
|
||||||
|
- `constraints`: {{constraints}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
1. intent_document: optional full document that explains goals, context, constraints, and desired outcomes
|
1. intent_document: optional full document that explains goals, context, constraints, and desired outcomes
|
||||||
|
|||||||
@@ -1,25 +1,16 @@
|
|||||||
---
|
---
|
||||||
name: jsfiddle-page-layout
|
icon: lucide/messages-square
|
||||||
description: Create a responsive sample page layout for a user-supplied domain and return paste-ready HTML and CSS for JSFiddle.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: jsfiddle-page-layout
|
|
||||||
version: 1.1.0
|
|
||||||
tags:
|
|
||||||
- frontend
|
|
||||||
- html
|
|
||||||
- css
|
|
||||||
- jsfiddle
|
|
||||||
- layout
|
|
||||||
- prototyping
|
|
||||||
- prompts
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/jsfiddle-page-layout/document
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# JSFiddle Page Layout
|
# JSFiddle Page Layout
|
||||||
|
|
||||||
Create a polished sample page layout for the supplied domain. The result must run by pasting the markup and styles into the [JSFiddle](https://jsfiddle.net/) HTML and CSS panes.
|
Create a polished sample page layout for the supplied domain. The result must run by pasting the markup and styles into the [JSFiddle](https://jsfiddle.net/) HTML and CSS panes.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `domain`: {{domain}}
|
||||||
|
- `layout_brief`: {{layout_brief}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
1. `domain`: the product, service, organization, or subject represented by the page, including its intended audience when known
|
1. `domain`: the product, service, organization, or subject represented by the page, including its intended audience when known
|
||||||
|
|||||||
@@ -1,36 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: mcp-consumer-repo-shim
|
icon: lucide/messages-square
|
||||||
description: Create one repository-specific thin shim instruction file that binds a file scope to a user-selected Personal MCP skill resource and enforces resource-first Copilot retrieval behavior.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: mcp-consumer-repo-shim
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- copilot
|
|
||||||
- mcp
|
|
||||||
- instructions
|
|
||||||
- shims
|
|
||||||
- prompts
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/mcp-consumer-repo-shim/document
|
|
||||||
arguments:
|
|
||||||
apply_to_glob:
|
|
||||||
description: File glob scope for the shim applyTo field, such as tests/** or **/*.md.
|
|
||||||
required: true
|
|
||||||
primary_skill_resource:
|
|
||||||
description: Primary native skill resource URI in the form skill://<skill-name>/SKILL.md.
|
|
||||||
required: true
|
|
||||||
shim_title:
|
|
||||||
description: Human-readable name for the instruction shim frontmatter.
|
|
||||||
required: false
|
|
||||||
companion_docs_page:
|
|
||||||
description: Optional relative docs link for human-facing companion guidance.
|
|
||||||
required: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# MCP Consumer Repository Shim
|
# MCP Consumer Repository Shim
|
||||||
|
|
||||||
Use this prompt to generate exactly one repository-scoped Copilot instruction shim for an MCP consumer repository.
|
Use this prompt to generate exactly one repository-scoped Copilot instruction shim for an MCP consumer repository.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `apply_to_glob`: {{apply_to_glob}}
|
||||||
|
- `primary_skill_resource`: {{primary_skill_resource}}
|
||||||
|
- `shim_title`: {{shim_title}}
|
||||||
|
- `companion_docs_page`: {{companion_docs_page}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- Required:
|
- Required:
|
||||||
@@ -45,7 +27,7 @@ Use this prompt to generate exactly one repository-scoped Copilot instruction sh
|
|||||||
Load only sections relevant to the requested shim:
|
Load only sections relevant to the requested shim:
|
||||||
|
|
||||||
1. Thin shim pattern and scope guidance: [Skill Usage Mechanics](../../usage.md)
|
1. Thin shim pattern and scope guidance: [Skill Usage Mechanics](../../usage.md)
|
||||||
2. VS Code Copilot MCP behavior and fallback mechanics: [Copilot MCP Mechanics](../../copilot.md)
|
2. VS Code Copilot MCP resource behavior: [Copilot MCP Mechanics](../../copilot.md)
|
||||||
3. Authoring workflow and validation checklist: [Authoring Guide](../../authoring.md)
|
3. Authoring workflow and validation checklist: [Authoring Guide](../../authoring.md)
|
||||||
4. Instruction metadata expectations and examples: [Copilot customization skill](../../skills/copilot-customization/SKILL.md)
|
4. Instruction metadata expectations and examples: [Copilot customization skill](../../skills/copilot-customization/SKILL.md)
|
||||||
|
|
||||||
@@ -60,11 +42,8 @@ Load only sections relevant to the requested shim:
|
|||||||
- include a primary rule that uses the selected primary_skill_resource first
|
- include a primary rule that uses the selected primary_skill_resource first
|
||||||
- include a bounded execution pattern (load primary doc, apply only relevant sections, keep edits minimal)
|
- include a bounded execution pattern (load primary doc, apply only relevant sections, keep edits minimal)
|
||||||
6. Include VS Code/Copilot integration mechanics in the shim body:
|
6. Include VS Code/Copilot integration mechanics in the shim body:
|
||||||
- prefer MCP resource attachment when available
|
- use MCP resource attachment
|
||||||
- inspect `_manifest` only when the task needs supporting material
|
- inspect `_manifest` only when the task needs supporting material
|
||||||
- if attachment is unavailable, use the generic fallback tools:
|
|
||||||
1. list_resources
|
|
||||||
2. read_resource
|
|
||||||
- ask one clarifying question when confidence is low
|
- ask one clarifying question when confidence is low
|
||||||
7. If companion_docs_page is provided, include it as a companion docs link line.
|
7. If companion_docs_page is provided, include it as a companion docs link line.
|
||||||
8. Do not generate additional files, code changes, or batch shim packs.
|
8. Do not generate additional files, code changes, or batch shim packs.
|
||||||
@@ -98,8 +77,7 @@ Execution pattern:
|
|||||||
3. Keep edits minimal and aligned with repository conventions.
|
3. Keep edits minimal and aligned with repository conventions.
|
||||||
4. Prefer MCP resource attachment when available in the current chat surface.
|
4. Prefer MCP resource attachment when available in the current chat surface.
|
||||||
5. Read the selected skill's `_manifest` only when supporting material is needed.
|
5. Read the selected skill's `_manifest` only when supporting material is needed.
|
||||||
6. If MCP resource attachment is unavailable, use `list_resources` and `read_resource`.
|
6. If confidence is low, ask one clarifying question before editing.
|
||||||
7. If confidence is low, ask one clarifying question before editing.
|
|
||||||
|
|
||||||
Companion docs page: <optional-relative-doc-link>
|
Companion docs page: <optional-relative-doc-link>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,41 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: nicegui-component-extraction
|
icon: lucide/messages-square
|
||||||
description: Extract a user-selected component from a JSFiddle page layout and implement it as a reusable NiceGUI render function with responsive styling and typed bindable state where needed.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: nicegui-component-extraction
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- nicegui
|
|
||||||
- components
|
|
||||||
- frontend
|
|
||||||
- refactoring
|
|
||||||
- jsfiddle
|
|
||||||
- prompts
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/nicegui-component-extraction/document
|
|
||||||
arguments:
|
|
||||||
component:
|
|
||||||
title: Component
|
|
||||||
description: Component or page region to extract, identified by its visible label, semantic role, or selector.
|
|
||||||
required: true
|
|
||||||
source_layout:
|
|
||||||
title: Source layout
|
|
||||||
description: Optional HTML and CSS from the JSFiddle page layout prompt; when omitted, use the latest applicable output in the conversation.
|
|
||||||
required: false
|
|
||||||
target_location:
|
|
||||||
title: Target location
|
|
||||||
description: Optional target NiceGUI page, module, or package in which to create and integrate the component.
|
|
||||||
required: false
|
|
||||||
behavior_requirements:
|
|
||||||
title: Behavior requirements
|
|
||||||
description: Optional interactions, state, callbacks, or content variations the extracted component must support.
|
|
||||||
required: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# NiceGUI Component Extraction
|
# NiceGUI Component Extraction
|
||||||
|
|
||||||
Extract one user-selected component from the output of the [JSFiddle Page Layout](../jsfiddle-page-layout/PROMPT.md) prompt and implement it as a reusable NiceGUI component in the target repository.
|
Extract one user-selected component from the output of the [JSFiddle Page Layout](../jsfiddle-page-layout/PROMPT.md) prompt and implement it as a reusable NiceGUI component in the target repository.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `component`: {{component}}
|
||||||
|
- `source_layout`: {{source_layout}}
|
||||||
|
- `target_location`: {{target_location}}
|
||||||
|
- `behavior_requirements`: {{behavior_requirements}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
1. `component`: required visible label, semantic role, or selector identifying the component to extract
|
1. `component`: required visible label, semantic role, or selector identifying the component to extract
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: pytest-fill-scaffold
|
icon: lucide/messages-square
|
||||||
description: Fill scaffolded pytest test methods with assertions, fixtures, and minimal test data while preserving concise test names and one-line intent docstrings.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: pytest-fill-scaffold
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- pytest
|
|
||||||
- testing
|
|
||||||
- scaffolding
|
|
||||||
- prompts
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/pytest-fill-scaffold/document
|
|
||||||
arguments:
|
|
||||||
target_files:
|
|
||||||
description: Target test file paths under tests/.
|
|
||||||
required: true
|
|
||||||
stack:
|
|
||||||
description: Runtime stack type for fixture and marker choices.
|
|
||||||
required: true
|
|
||||||
strategy:
|
|
||||||
description: Balance between minimal and comprehensive implementation.
|
|
||||||
required: false
|
|
||||||
marker_lane:
|
|
||||||
description: Preferred marker lane when applicable.
|
|
||||||
required: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pytest Fill Scaffold
|
# Pytest Fill Scaffold
|
||||||
|
|
||||||
Use this prompt after test scaffolding exists and method names/docstrings are already in place.
|
Use this prompt after test scaffolding exists and method names/docstrings are already in place.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `target_files`: {{target_files}}
|
||||||
|
- `stack`: {{stack}}
|
||||||
|
- `strategy`: {{strategy}}
|
||||||
|
- `marker_lane`: {{marker_lane}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- Target test file(s) under tests/.
|
- Target test file(s) under tests/.
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
---
|
---
|
||||||
name: pytest-scaffold
|
icon: lucide/messages-square
|
||||||
description: Plan and optionally scaffold pytest file and class structure for selected Python modules while preserving concise behavior-focused test names and one-line intent docstrings.
|
|
||||||
x-personal-mcp:
|
|
||||||
id: pytest-scaffold
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- pytest
|
|
||||||
- testing
|
|
||||||
- scaffolding
|
|
||||||
- prompts
|
|
||||||
capabilities:
|
|
||||||
- resource://prompts/pytest-scaffold/document
|
|
||||||
arguments:
|
|
||||||
target_modules:
|
|
||||||
description: Target module path(s) under src/.
|
|
||||||
required: true
|
|
||||||
mode:
|
|
||||||
description: Execution mode, either plan-only or scaffold.
|
|
||||||
required: true
|
|
||||||
path_strategy:
|
|
||||||
description: Optional mapping preference for src to tests paths.
|
|
||||||
required: false
|
|
||||||
naming_style:
|
|
||||||
description: Optional preference for concise method naming style.
|
|
||||||
required: false
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Pytest Scaffold
|
# Pytest Scaffold
|
||||||
|
|
||||||
Use this prompt to consistently plan and scaffold pytest test modules for selected Python source modules.
|
Use this prompt to consistently plan and scaffold pytest test modules for selected Python source modules.
|
||||||
|
|
||||||
|
## Supplied Inputs
|
||||||
|
|
||||||
|
- `target_modules`: {{target_modules}}
|
||||||
|
- `mode`: {{mode}}
|
||||||
|
- `path_strategy`: {{path_strategy}}
|
||||||
|
- `naming_style`: {{naming_style}}
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
- Required:
|
- Required:
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ Choose one of these patterns:
|
|||||||
- Read selected supporting files at `skill://<skill-name>/<supporting-path>`.
|
- Read selected supporting files at `skill://<skill-name>/<supporting-path>`.
|
||||||
2. Discovery-first strategy:
|
2. Discovery-first strategy:
|
||||||
- List resources, compare native main-resource names and descriptions, then load the best matching `SKILL.md`.
|
- List resources, compare native main-resource names and descriptions, then load the best matching `SKILL.md`.
|
||||||
- In tool-only clients, use only `list_resources` and `read_resource` for the same sequence.
|
|
||||||
|
|
||||||
### Authoring guidance for shims
|
### Authoring guidance for shims
|
||||||
|
|
||||||
|
|||||||
+8
-7
@@ -29,14 +29,13 @@ tests/
|
|||||||
registry/
|
registry/
|
||||||
test_read.py
|
test_read.py
|
||||||
ingest/
|
ingest/
|
||||||
conftest.py
|
|
||||||
test_current_docs.py
|
test_current_docs.py
|
||||||
test_document.py
|
test_document.py
|
||||||
test_prompt.py
|
|
||||||
models/
|
models/
|
||||||
test_document_validation.py
|
test_document_validation.py
|
||||||
test_prompt_validation.py
|
prompts/
|
||||||
test_registry_payload_models.py
|
test_content_renderer.py
|
||||||
|
test_filesystem_provider.py
|
||||||
skills/
|
skills/
|
||||||
test_provider.py
|
test_provider.py
|
||||||
web/
|
web/
|
||||||
@@ -49,6 +48,7 @@ tests/
|
|||||||
Source-to-test alignment today:
|
Source-to-test alignment today:
|
||||||
- `src/personal_mcp/registry/ingest/` -> `tests/registry/ingest/`
|
- `src/personal_mcp/registry/ingest/` -> `tests/registry/ingest/`
|
||||||
- `src/personal_mcp/registry/models/` -> `tests/registry/models/`
|
- `src/personal_mcp/registry/models/` -> `tests/registry/models/`
|
||||||
|
- `src/personal_mcp/prompts/` -> `tests/prompts/`
|
||||||
- `src/personal_mcp/skills/provider.py` -> `tests/skills/test_provider.py`
|
- `src/personal_mcp/skills/provider.py` -> `tests/skills/test_provider.py`
|
||||||
- `src/personal_mcp/web/` and MCP HTTP surface -> `tests/web/`
|
- `src/personal_mcp/web/` and MCP HTTP surface -> `tests/web/`
|
||||||
|
|
||||||
@@ -65,8 +65,7 @@ Pytest runs with `--strict-markers`, so any unregistered marker fails the test r
|
|||||||
|
|
||||||
Fixture placement follows test scope:
|
Fixture placement follows test scope:
|
||||||
1. `tests/conftest.py` for cross-suite defaults.
|
1. `tests/conftest.py` for cross-suite defaults.
|
||||||
2. `tests/registry/ingest/conftest.py` for ingest-specific setup.
|
2. `tests/web/conftest.py` for web and endpoint client setup.
|
||||||
3. `tests/web/conftest.py` for web and endpoint client setup.
|
|
||||||
|
|
||||||
Prefer adding fixtures at the narrowest scope that serves more than one test.
|
Prefer adding fixtures at the narrowest scope that serves more than one test.
|
||||||
|
|
||||||
@@ -90,9 +89,11 @@ uv run pytest -m smoke -q
|
|||||||
## Adding New Tests
|
## Adding New Tests
|
||||||
|
|
||||||
When adding coverage:
|
When adding coverage:
|
||||||
1. Place tests under the nearest existing module subtree (`registry/`, `skills/`, or `web/`).
|
1. Place tests under the nearest existing module subtree (`prompts/`, `registry/`, `skills/`, or `web/`).
|
||||||
2. Mirror the source path where practical.
|
2. Mirror the source path where practical.
|
||||||
3. Reuse existing `conftest.py` files before adding new fixture layers.
|
3. Reuse existing `conftest.py` files before adding new fixture layers.
|
||||||
4. Add markers only when they convey execution intent, and register new markers in `pyproject.toml` first.
|
4. Add markers only when they convey execution intent, and register new markers in `pyproject.toml` first.
|
||||||
|
|
||||||
This keeps the suite aligned with the current architecture while preserving a fast local test loop.
|
This keeps the suite aligned with the current architecture while preserving a fast local test loop.
|
||||||
|
|
||||||
|
Prefer durable boundaries over implementation details: provider discovery, prompt rendering, traversal rejection, protocol behavior, and installed-package path resolution. Do not test deleted catalog projections, Pydantic immutability internals, or helper delegation.
|
||||||
+5
-28
@@ -22,7 +22,7 @@ The server uses `supporting_files="template"`. Main files and manifests appear i
|
|||||||
|
|
||||||
The manifest contains every skill-relative path, byte size, and SHA256 hash. References do not have synthetic ids or a separate catalog record.
|
The manifest contains every skill-relative path, byte size, and SHA256 hash. References do not have synthetic ids or a separate catalog record.
|
||||||
|
|
||||||
Prompts remain available through prompt catalog resources, prompt document resources, and MCP prompt objects.
|
Prompts are available through native MCP prompt discovery and rendering.
|
||||||
|
|
||||||
## Discovery Workflow
|
## Discovery Workflow
|
||||||
|
|
||||||
@@ -48,41 +48,19 @@ FastMCP provides native utilities in `fastmcp.utilities.skills`:
|
|||||||
|
|
||||||
These utilities operate directly on the native `skill://` contract and require no repository-specific adapter.
|
These utilities operate directly on the native `skill://` contract and require no repository-specific adapter.
|
||||||
|
|
||||||
## Tool-Only Clients
|
|
||||||
|
|
||||||
The server installs [`ResourcesAsTools`](https://gofastmcp.com/servers/transforms/resources-as-tools), which exposes generic tools:
|
|
||||||
|
|
||||||
1. `list_resources`
|
|
||||||
2. `read_resource`
|
|
||||||
|
|
||||||
A tool-only client should list resources, select a `skill://<name>/SKILL.md` URI, and read it. It can then read `_manifest` and selected supporting paths through the same tool.
|
|
||||||
|
|
||||||
There are no skill-specific search, detail, or document tools. This avoids maintaining a second discovery implementation.
|
|
||||||
|
|
||||||
## Optional Tool Search
|
|
||||||
|
|
||||||
For large tool inventories, FastMCP search transforms can reduce tool-list noise:
|
|
||||||
|
|
||||||
1. `PERSONAL_MCP_TOOL_SEARCH=none|regex|bm25` defaults to `none`.
|
|
||||||
2. `PERSONAL_MCP_TOOL_SEARCH_MAX_RESULTS=<positive int>` defaults to `5`.
|
|
||||||
3. `list_resources` and `read_resource` remain visible in search modes.
|
|
||||||
|
|
||||||
These settings filter tools, not native skill resources.
|
|
||||||
|
|
||||||
## Copilot Invocation
|
## Copilot Invocation
|
||||||
|
|
||||||
In VS Code, skills can arrive through:
|
In VS Code, skills can arrive through:
|
||||||
|
|
||||||
1. explicit attachment from `Add Context > MCP Resources` or `MCP: Browse Resources`
|
1. explicit attachment from `Add Context > MCP Resources` or `MCP: Browse Resources`
|
||||||
2. generic `list_resources` and `read_resource` tool calls
|
2. a slash-command prompt that names a specific native skill URI
|
||||||
3. a slash-command prompt that names a specific native skill URI
|
|
||||||
|
|
||||||
Instructions can steer retrieval, but they do not guarantee automatic resource attachment in every chat surface.
|
Instructions can steer retrieval, but they do not guarantee automatic resource attachment in every chat surface.
|
||||||
|
|
||||||
A reliable prompt for a tool-only session is:
|
A reliable prompt is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Use personal-mcp list_resources to find the best matching skill://.../SKILL.md resource. Read one selected skill, inspect its _manifest only if supporting detail is needed, and reconcile the guidance with this workspace.
|
Browse personal-mcp resources and select the best matching skill://.../SKILL.md resource. Read one selected skill, inspect its _manifest only if supporting detail is needed, and reconcile the guidance with this workspace.
|
||||||
```
|
```
|
||||||
|
|
||||||
## Thin Shim Pattern
|
## Thin Shim Pattern
|
||||||
@@ -124,5 +102,4 @@ When a supporting path fails, refresh `_manifest`; file paths are the public sup
|
|||||||
2. Confirm at least one `skill://<name>/SKILL.md` resource is listed.
|
2. Confirm at least one `skill://<name>/SKILL.md` resource is listed.
|
||||||
3. Read its `_manifest` and verify `SKILL.md` appears with a SHA256 hash.
|
3. Read its `_manifest` and verify `SKILL.md` appears with a SHA256 hash.
|
||||||
4. Read one supporting file through its manifest path.
|
4. Read one supporting file through its manifest path.
|
||||||
5. Confirm `list_resources` and `read_resource` are available for tool-only clients.
|
5. Keep loaded context bounded to the selected skill and relevant files.
|
||||||
6. Keep loaded context bounded to the selected skill and relevant files.
|
|
||||||
|
|||||||
+8
-4
@@ -1,17 +1,19 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "prompts"
|
name = "prompts"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi>=0.115.0",
|
"fastapi>=0.133.0",
|
||||||
"fastmcp>=3.4.4",
|
"fastmcp==4.0.0b1",
|
||||||
"pydantic-settings>=2",
|
"pydantic-settings>=2",
|
||||||
"python-json-logger>=4",
|
"python-json-logger>=4",
|
||||||
"pyyaml>=6.0.2",
|
|
||||||
"uvicorn[standard]>=0.34.0",
|
"uvicorn[standard]>=0.34.0",
|
||||||
"zensical>=0.0.45",
|
"zensical>=0.0.45",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
constraint-dependencies = ["fastmcp-slim==4.0.0b1"]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
personal-mcp = "personal_mcp.main:main"
|
personal-mcp = "personal_mcp.main:main"
|
||||||
|
|
||||||
@@ -30,9 +32,11 @@ dev = [
|
|||||||
"ty>=0.0.51",
|
"ty>=0.0.51",
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
|
"httpx2>=2.9.1",
|
||||||
"pytest>=9.1.1",
|
"pytest>=9.1.1",
|
||||||
"pytest-asyncio>=1.4.0",
|
"pytest-asyncio>=1.4.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
|
"pyyaml>=6.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
from personal_mcp.catalog.server import build_prompt_detail_payload
|
|
||||||
from personal_mcp.catalog.server import build_prompts_index_payload
|
|
||||||
from personal_mcp.catalog.server import get_prompt_by_id_payload
|
|
||||||
from personal_mcp.catalog.server import search_prompts_payload
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"build_prompt_detail_payload",
|
|
||||||
"build_prompts_index_payload",
|
|
||||||
"get_prompt_by_id_payload",
|
|
||||||
"search_prompts_payload",
|
|
||||||
]
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
|
||||||
|
|
||||||
DEFAULT_LIMIT = 20
|
|
||||||
MAX_LIMIT = 100
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_matches(
|
|
||||||
prompt: PromptRecord,
|
|
||||||
*,
|
|
||||||
query: str | None,
|
|
||||||
tag: str | None,
|
|
||||||
) -> bool:
|
|
||||||
if query:
|
|
||||||
lowered = query.strip().lower()
|
|
||||||
if lowered:
|
|
||||||
haystack = " ".join(
|
|
||||||
[
|
|
||||||
prompt.prompt_id,
|
|
||||||
prompt.name,
|
|
||||||
prompt.description,
|
|
||||||
" ".join(prompt.tags),
|
|
||||||
" ".join(sorted(prompt.arguments)),
|
|
||||||
]
|
|
||||||
).lower()
|
|
||||||
terms = [term for term in lowered.replace("-", " ").split() if term]
|
|
||||||
if any(term not in haystack for term in terms):
|
|
||||||
return False
|
|
||||||
|
|
||||||
return not (tag and tag not in prompt.tags)
|
|
||||||
|
|
||||||
|
|
||||||
def build_prompts_index_payload(
|
|
||||||
registry: DocsRegistry,
|
|
||||||
*,
|
|
||||||
query: str | None = None,
|
|
||||||
tag: str | None = None,
|
|
||||||
cursor: str | None = None,
|
|
||||||
limit: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
normalized_limit = DEFAULT_LIMIT if limit is None else max(1, min(limit, MAX_LIMIT))
|
|
||||||
try:
|
|
||||||
start = 0 if cursor is None else max(0, int(cursor))
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError("cursor must be an integer string") from exc
|
|
||||||
|
|
||||||
ordered = [registry.prompts_by_id[prompt_id] for prompt_id in registry.prompts_in_load_order]
|
|
||||||
matches = [prompt for prompt in ordered if _prompt_matches(prompt, query=query, tag=tag)]
|
|
||||||
|
|
||||||
page = matches[start : start + normalized_limit]
|
|
||||||
next_cursor = start + normalized_limit
|
|
||||||
|
|
||||||
return {
|
|
||||||
"prompts": [PromptSummaryPayload.from_record(prompt).model_dump() for prompt in page],
|
|
||||||
"total": len(matches),
|
|
||||||
"cursor": str(start),
|
|
||||||
"limit": normalized_limit,
|
|
||||||
"next_cursor": str(next_cursor) if next_cursor < len(matches) else None,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_prompt_detail_payload(registry: DocsRegistry, prompt_id: str) -> dict[str, Any]:
|
|
||||||
if prompt_id not in registry.prompts_by_id:
|
|
||||||
raise KeyError(prompt_id)
|
|
||||||
|
|
||||||
prompt = registry.prompts_by_id[prompt_id]
|
|
||||||
return {
|
|
||||||
"id": prompt.prompt_id,
|
|
||||||
"name": prompt.name,
|
|
||||||
"description": prompt.description,
|
|
||||||
"version": prompt.version,
|
|
||||||
"tags": list(prompt.tags),
|
|
||||||
"capabilities": list(prompt.capabilities),
|
|
||||||
"resources": {
|
|
||||||
"document": prompt.document_uri,
|
|
||||||
},
|
|
||||||
"arguments": {
|
|
||||||
arg_name: arg.model_dump(exclude_none=True) for arg_name, arg in sorted(prompt.arguments.items())
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def search_prompts_payload(
|
|
||||||
registry: DocsRegistry,
|
|
||||||
*,
|
|
||||||
query: str = "",
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = DEFAULT_LIMIT,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
normalized_skip = max(skip, 0)
|
|
||||||
normalized_limit = max(1, min(limit, MAX_LIMIT))
|
|
||||||
|
|
||||||
requested_tags = [tag.strip() for tag in (tags or []) if tag and tag.strip()]
|
|
||||||
|
|
||||||
matches: list[PromptRecord] = []
|
|
||||||
for prompt_id in registry.prompts_in_load_order:
|
|
||||||
prompt = registry.prompts_by_id[prompt_id]
|
|
||||||
if not _prompt_matches(prompt, query=query, tag=None):
|
|
||||||
continue
|
|
||||||
if requested_tags and any(tag not in prompt.tags for tag in requested_tags):
|
|
||||||
continue
|
|
||||||
matches.append(prompt)
|
|
||||||
|
|
||||||
page = matches[normalized_skip : normalized_skip + normalized_limit]
|
|
||||||
return {
|
|
||||||
"prompts": [PromptSummaryPayload.from_record(prompt).model_dump() for prompt in page],
|
|
||||||
"total": len(matches),
|
|
||||||
"skip": normalized_skip,
|
|
||||||
"limit": normalized_limit,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_prompt_by_id_payload(registry: DocsRegistry, prompt_id: str) -> dict[str, Any]:
|
|
||||||
if prompt_id not in registry.prompts_by_id:
|
|
||||||
return {"found": False, "id": prompt_id}
|
|
||||||
return {
|
|
||||||
"found": True,
|
|
||||||
"prompt": build_prompt_detail_payload(registry, prompt_id),
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from functools import cache
|
from functools import cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic import DirectoryPath
|
from pydantic import DirectoryPath
|
||||||
@@ -29,7 +28,6 @@ class Settings(BaseSettings):
|
|||||||
debug: bool = False
|
debug: bool = False
|
||||||
log_level: str = "info"
|
log_level: str = "info"
|
||||||
mounts: Mounts = Field(default_factory=Mounts)
|
mounts: Mounts = Field(default_factory=Mounts)
|
||||||
mcp_transport: Literal["http", "sse"] = "http"
|
|
||||||
site_dir: DirectoryPath = Field(default=_REPO_ROOT / "site")
|
site_dir: DirectoryPath = Field(default=_REPO_ROOT / "site")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+2
-172
@@ -1,66 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from inspect import Parameter
|
|
||||||
from inspect import Signature
|
|
||||||
from typing import Any
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
from fastmcp.server.transforms import ResourcesAsTools
|
|
||||||
from fastmcp.server.transforms.search import BM25SearchTransform
|
|
||||||
from fastmcp.server.transforms.search import RegexSearchTransform
|
|
||||||
|
|
||||||
from personal_mcp.catalog.server import build_prompt_detail_payload
|
from personal_mcp.prompts import create_prompts_provider
|
||||||
from personal_mcp.catalog.server import build_prompts_index_payload
|
|
||||||
from personal_mcp.catalog.server import get_prompt_by_id_payload
|
|
||||||
from personal_mcp.catalog.server import search_prompts_payload
|
|
||||||
from personal_mcp.registry.load import get_docs_registry
|
from personal_mcp.registry.load import get_docs_registry
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.read import read_docs_markdown_path
|
from personal_mcp.registry.read import read_docs_markdown_path
|
||||||
from personal_mcp.registry.read import read_prompt_document
|
|
||||||
from personal_mcp.skills import create_skills_provider
|
from personal_mcp.skills import create_skills_provider
|
||||||
|
|
||||||
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
|
|
||||||
TOOL_SEARCH_MAX_RESULTS = os.getenv("PERSONAL_MCP_TOOL_SEARCH_MAX_RESULTS", "5")
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_positive_int(value: str, *, env_name: str) -> int:
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise ValueError(f"{env_name} must be an integer") from exc
|
|
||||||
if parsed <= 0:
|
|
||||||
raise ValueError(f"{env_name} must be greater than zero")
|
|
||||||
return parsed
|
|
||||||
|
|
||||||
|
|
||||||
def _install_tool_fallback_transforms(mcp: FastMCP) -> None:
|
|
||||||
# Expose list_resources/read_resource for tool-only clients.
|
|
||||||
mcp.add_transform(ResourcesAsTools(mcp))
|
|
||||||
|
|
||||||
if TOOL_SEARCH_MODE in {"", "none"}:
|
|
||||||
return
|
|
||||||
|
|
||||||
max_results = _parse_positive_int(
|
|
||||||
TOOL_SEARCH_MAX_RESULTS,
|
|
||||||
env_name="PERSONAL_MCP_TOOL_SEARCH_MAX_RESULTS",
|
|
||||||
)
|
|
||||||
kwargs: dict[str, Any] = {
|
|
||||||
"max_results": max_results,
|
|
||||||
"always_visible": ["list_resources", "read_resource"],
|
|
||||||
}
|
|
||||||
|
|
||||||
if TOOL_SEARCH_MODE == "regex":
|
|
||||||
mcp.add_transform(RegexSearchTransform(**kwargs))
|
|
||||||
return
|
|
||||||
if TOOL_SEARCH_MODE == "bm25":
|
|
||||||
mcp.add_transform(BM25SearchTransform(**kwargs))
|
|
||||||
return
|
|
||||||
|
|
||||||
raise ValueError("PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25")
|
|
||||||
|
|
||||||
|
|
||||||
def _ro_annotations() -> dict[str, bool]:
|
def _ro_annotations() -> dict[str, bool]:
|
||||||
return {
|
return {
|
||||||
@@ -69,54 +16,6 @@ def _ro_annotations() -> dict[str, bool]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _render_prompt_markdown(content: str, arguments: dict[str, Any]) -> str:
|
|
||||||
rendered = content
|
|
||||||
for key, value in arguments.items():
|
|
||||||
rendered = rendered.replace(f"{{{{{key}}}}}", str(value))
|
|
||||||
return rendered
|
|
||||||
|
|
||||||
|
|
||||||
def _make_prompt_handler(content: str):
|
|
||||||
def prompt_handler(**kwargs: Any) -> str:
|
|
||||||
return _render_prompt_markdown(content, kwargs)
|
|
||||||
|
|
||||||
return prompt_handler
|
|
||||||
|
|
||||||
|
|
||||||
def _register_prompt_objects(mcp: FastMCP, registry: DocsRegistry) -> None:
|
|
||||||
for prompt_id in registry.prompts_in_load_order:
|
|
||||||
prompt = registry.prompts_by_id[prompt_id]
|
|
||||||
annotations: dict[str, Any] = {}
|
|
||||||
params: list[Parameter] = []
|
|
||||||
|
|
||||||
for arg_name, arg in sorted(prompt.arguments.items()):
|
|
||||||
annotations[arg_name] = str
|
|
||||||
default = Parameter.empty if arg.required else None
|
|
||||||
params.append(
|
|
||||||
Parameter(
|
|
||||||
arg_name,
|
|
||||||
kind=Parameter.KEYWORD_ONLY,
|
|
||||||
default=default,
|
|
||||||
annotation=str,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
signature = Signature(parameters=params, return_annotation=str)
|
|
||||||
|
|
||||||
prompt_handler = _make_prompt_handler(prompt.document_content)
|
|
||||||
|
|
||||||
prompt_handler.__name__ = re.sub(r"[^a-zA-Z0-9_]", "_", prompt_id)
|
|
||||||
prompt_handler.__doc__ = prompt.description
|
|
||||||
prompt_handler.__annotations__ = annotations
|
|
||||||
cast(Any, prompt_handler).__signature__ = signature
|
|
||||||
mcp.prompt(
|
|
||||||
prompt_handler,
|
|
||||||
name=prompt_id,
|
|
||||||
description=prompt.description,
|
|
||||||
tags=set(prompt.tags),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://docs/{path*}",
|
"resource://docs/{path*}",
|
||||||
@@ -127,80 +26,11 @@ def _register_components(mcp: FastMCP, registry: DocsRegistry) -> None:
|
|||||||
def docs_markdown(path: str) -> dict[str, str]:
|
def docs_markdown(path: str) -> dict[str, str]:
|
||||||
return read_docs_markdown_path(registry, path)
|
return read_docs_markdown_path(registry, path)
|
||||||
|
|
||||||
@mcp.resource(
|
|
||||||
"resource://catalog/prompts_index",
|
|
||||||
mime_type="application/json",
|
|
||||||
tags={"catalog"},
|
|
||||||
annotations=_ro_annotations(),
|
|
||||||
)
|
|
||||||
def prompts_index() -> dict[str, Any]:
|
|
||||||
return build_prompts_index_payload(registry)
|
|
||||||
|
|
||||||
@mcp.resource(
|
|
||||||
"resource://catalog/prompts_index{?q,tag,cursor,limit}",
|
|
||||||
mime_type="application/json",
|
|
||||||
tags={"catalog"},
|
|
||||||
annotations=_ro_annotations(),
|
|
||||||
)
|
|
||||||
def prompts_index_query(
|
|
||||||
q: str | None = None,
|
|
||||||
tag: str | None = None,
|
|
||||||
cursor: str | None = None,
|
|
||||||
limit: int | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return build_prompts_index_payload(
|
|
||||||
registry,
|
|
||||||
query=q,
|
|
||||||
tag=tag,
|
|
||||||
cursor=cursor,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.resource(
|
|
||||||
"resource://catalog/prompts/{prompt_id}",
|
|
||||||
mime_type="application/json",
|
|
||||||
tags={"catalog"},
|
|
||||||
annotations=_ro_annotations(),
|
|
||||||
)
|
|
||||||
def prompt_detail(prompt_id: str) -> dict[str, Any]:
|
|
||||||
return build_prompt_detail_payload(registry, prompt_id)
|
|
||||||
|
|
||||||
@mcp.resource(
|
|
||||||
"resource://prompts/{prompt_id}/document",
|
|
||||||
mime_type="text/markdown",
|
|
||||||
tags={"prompt-doc"},
|
|
||||||
annotations=_ro_annotations(),
|
|
||||||
)
|
|
||||||
def prompt_document(prompt_id: str) -> dict[str, str]:
|
|
||||||
return read_prompt_document(registry, prompt_id)
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
def search_prompts(
|
|
||||||
query: str = "",
|
|
||||||
tags: list[str] | None = None,
|
|
||||||
skip: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Search prompt metadata with optional tags and pagination."""
|
|
||||||
return search_prompts_payload(
|
|
||||||
registry,
|
|
||||||
query=query,
|
|
||||||
tags=tags,
|
|
||||||
skip=skip,
|
|
||||||
limit=limit,
|
|
||||||
)
|
|
||||||
|
|
||||||
@mcp.tool
|
|
||||||
def get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
|
|
||||||
"""Return one prompt by stable id."""
|
|
||||||
return get_prompt_by_id_payload(registry, prompt_id)
|
|
||||||
|
|
||||||
|
|
||||||
def create_mcp() -> FastMCP:
|
def create_mcp() -> FastMCP:
|
||||||
registry = get_docs_registry()
|
registry = get_docs_registry()
|
||||||
mcp = FastMCP("personal-mcp", on_duplicate="error")
|
mcp = FastMCP("personal-mcp", on_duplicate="error")
|
||||||
_register_components(mcp, registry)
|
_register_components(mcp, registry)
|
||||||
_register_prompt_objects(mcp, registry)
|
mcp.add_provider(create_prompts_provider())
|
||||||
mcp.add_provider(create_skills_provider())
|
mcp.add_provider(create_skills_provider())
|
||||||
_install_tool_fallback_transforms(mcp)
|
|
||||||
return mcp
|
return mcp
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .provider import create_prompts_provider
|
||||||
|
|
||||||
|
__all__ = ["create_prompts_provider"]
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="authoring",
|
||||||
|
version="1.0.0",
|
||||||
|
description=(
|
||||||
|
"Provide a practical checklist and baseline template for authoring docs-first MCP modules and "
|
||||||
|
"repository-specific Copilot instruction shims."
|
||||||
|
),
|
||||||
|
tags={"authoring", "mcp", "fastmcp", "copilot", "prompts", "scaffolding"},
|
||||||
|
)
|
||||||
|
def authoring(
|
||||||
|
artifact_type: Annotated[
|
||||||
|
Literal["skill", "prompt", "shim"],
|
||||||
|
Field(description="Artifact type to create."),
|
||||||
|
],
|
||||||
|
artifact_id: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Lowercase kebab-case id for the module or shim."),
|
||||||
|
],
|
||||||
|
goal: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="One-sentence capability statement."),
|
||||||
|
],
|
||||||
|
scope_glob: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional applyTo glob for shim outputs."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"authoring",
|
||||||
|
{
|
||||||
|
"artifact_type": artifact_type,
|
||||||
|
"artifact_id": artifact_id,
|
||||||
|
"goal": goal,
|
||||||
|
"scope_glob": scope_glob,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="greenfield-architecture",
|
||||||
|
version="1.0.0",
|
||||||
|
description=(
|
||||||
|
"Research established patterns and design a high-level architecture for a "
|
||||||
|
"new app or library with explicit tradeoffs and test strategy."
|
||||||
|
),
|
||||||
|
tags={"architecture", "planning", "greenfield", "design", "testing", "prompts"},
|
||||||
|
)
|
||||||
|
def greenfield_architecture(
|
||||||
|
scope_type: Annotated[
|
||||||
|
Literal["app", "library"],
|
||||||
|
Field(description="Scope type to design."),
|
||||||
|
],
|
||||||
|
intent_document: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional full document describing goals and context."),
|
||||||
|
] = None,
|
||||||
|
problem_domain: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Problem domain and business goal."),
|
||||||
|
] = None,
|
||||||
|
constraints: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Runtime, deployment, and non-functional constraints."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"greenfield-architecture",
|
||||||
|
{
|
||||||
|
"scope_type": scope_type,
|
||||||
|
"intent_document": intent_document,
|
||||||
|
"problem_domain": problem_domain,
|
||||||
|
"constraints": constraints,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="jsfiddle-page-layout",
|
||||||
|
version="1.1.0",
|
||||||
|
description=(
|
||||||
|
"Create a responsive sample page layout for a user-supplied domain and return paste-ready HTML and CSS "
|
||||||
|
"for JSFiddle."
|
||||||
|
),
|
||||||
|
tags={"frontend", "html", "css", "jsfiddle", "layout", "prototyping", "prompts"},
|
||||||
|
)
|
||||||
|
def jsfiddle_page_layout(
|
||||||
|
domain: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Product, service, organization, or subject represented by the page."),
|
||||||
|
],
|
||||||
|
layout_brief: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional page type, sections, priorities, or visual constraints."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"jsfiddle-page-layout",
|
||||||
|
{"domain": domain, "layout_brief": layout_brief},
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="mcp-consumer-repo-shim",
|
||||||
|
version="1.0.0",
|
||||||
|
description=(
|
||||||
|
"Create one repository-specific thin shim instruction file that binds a file scope to a user-selected "
|
||||||
|
"Personal MCP skill resource."
|
||||||
|
),
|
||||||
|
tags={"copilot", "mcp", "instructions", "shims", "prompts"},
|
||||||
|
)
|
||||||
|
def mcp_consumer_repo_shim(
|
||||||
|
apply_to_glob: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="File glob scope for the shim applyTo field."),
|
||||||
|
],
|
||||||
|
primary_skill_resource: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Primary native skill:// resource URI."),
|
||||||
|
],
|
||||||
|
shim_title: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional human-readable instruction shim name."),
|
||||||
|
] = None,
|
||||||
|
companion_docs_page: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional relative companion documentation link."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"mcp-consumer-repo-shim",
|
||||||
|
{
|
||||||
|
"apply_to_glob": apply_to_glob,
|
||||||
|
"primary_skill_resource": primary_skill_resource,
|
||||||
|
"shim_title": shim_title,
|
||||||
|
"companion_docs_page": companion_docs_page,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="nicegui-component-extraction",
|
||||||
|
version="1.0.0",
|
||||||
|
description=(
|
||||||
|
"Extract a user-selected component from a JSFiddle page layout and implement it as a reusable NiceGUI "
|
||||||
|
"render function."
|
||||||
|
),
|
||||||
|
tags={"nicegui", "components", "frontend", "refactoring", "jsfiddle", "prompts"},
|
||||||
|
)
|
||||||
|
def nicegui_component_extraction(
|
||||||
|
component: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Visible label, semantic role, or selector identifying the component."),
|
||||||
|
],
|
||||||
|
source_layout: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional source HTML and CSS."),
|
||||||
|
] = None,
|
||||||
|
target_location: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional target NiceGUI page, module, or package."),
|
||||||
|
] = None,
|
||||||
|
behavior_requirements: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional interactions, state, callbacks, or variations."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"nicegui-component-extraction",
|
||||||
|
{
|
||||||
|
"component": component,
|
||||||
|
"source_layout": source_layout,
|
||||||
|
"target_location": target_location,
|
||||||
|
"behavior_requirements": behavior_requirements,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="pytest-fill-scaffold",
|
||||||
|
version="1.0.0",
|
||||||
|
description=(
|
||||||
|
"Fill scaffolded pytest methods with assertions, fixtures, and minimal test data while preserving reviewed "
|
||||||
|
"structure."
|
||||||
|
),
|
||||||
|
tags={"pytest", "testing", "scaffolding", "prompts"},
|
||||||
|
)
|
||||||
|
def pytest_fill_scaffold(
|
||||||
|
target_files: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Target test file paths under tests/."),
|
||||||
|
],
|
||||||
|
stack: Annotated[
|
||||||
|
Literal["pure-python", "fastapi", "sqlalchemy-sync", "sqlalchemy-async", "mixed"],
|
||||||
|
Field(description="Runtime stack type for fixture and marker choices."),
|
||||||
|
],
|
||||||
|
strategy: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional minimal or comprehensive implementation preference."),
|
||||||
|
] = None,
|
||||||
|
marker_lane: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional pytest marker lane."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"pytest-fill-scaffold",
|
||||||
|
{
|
||||||
|
"target_files": target_files,
|
||||||
|
"stack": stack,
|
||||||
|
"strategy": strategy,
|
||||||
|
"marker_lane": marker_lane,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastmcp.prompts import prompt
|
||||||
|
from pydantic import Field
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
|
||||||
|
@prompt(
|
||||||
|
name="pytest-scaffold",
|
||||||
|
version="1.0.0",
|
||||||
|
description="Plan and optionally scaffold pytest file and class structure for selected Python modules.",
|
||||||
|
tags={"pytest", "testing", "scaffolding", "prompts"},
|
||||||
|
)
|
||||||
|
def pytest_scaffold(
|
||||||
|
target_modules: Annotated[
|
||||||
|
str,
|
||||||
|
Field(description="Target module paths under src/."),
|
||||||
|
],
|
||||||
|
mode: Annotated[
|
||||||
|
Literal["plan-only", "scaffold"],
|
||||||
|
Field(description="Whether to plan only or create scaffold files."),
|
||||||
|
],
|
||||||
|
path_strategy: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional src-to-tests path mapping preference."),
|
||||||
|
] = None,
|
||||||
|
naming_style: Annotated[
|
||||||
|
str | None,
|
||||||
|
Field(description="Optional concise test naming preference."),
|
||||||
|
] = None,
|
||||||
|
) -> str:
|
||||||
|
return render_prompt(
|
||||||
|
"pytest-scaffold",
|
||||||
|
{
|
||||||
|
"target_modules": target_modules,
|
||||||
|
"mode": mode,
|
||||||
|
"path_strategy": path_strategy,
|
||||||
|
"naming_style": naming_style,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import re
|
||||||
|
from importlib.resources import files
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
||||||
|
_FRONTMATTER_RE = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n?", re.DOTALL)
|
||||||
|
_PLACEHOLDER_RE = re.compile(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}")
|
||||||
|
|
||||||
|
|
||||||
|
def render_prompt(prompt_id: str, arguments: dict[str, Any]) -> str:
|
||||||
|
if not _PROMPT_ID_RE.fullmatch(prompt_id):
|
||||||
|
raise ValueError("prompt_id must be lowercase kebab-case")
|
||||||
|
|
||||||
|
resource = files("personal_mcp").joinpath("docs", "prompts", prompt_id, "PROMPT.md")
|
||||||
|
if not resource.is_file():
|
||||||
|
raise FileNotFoundError(f"prompt document does not exist: {prompt_id}")
|
||||||
|
|
||||||
|
content = _FRONTMATTER_RE.sub("", resource.read_text(encoding="utf-8"), count=1)
|
||||||
|
placeholders = set(_PLACEHOLDER_RE.findall(content))
|
||||||
|
argument_names = set(arguments)
|
||||||
|
if placeholders != argument_names:
|
||||||
|
missing = sorted(argument_names - placeholders)
|
||||||
|
unknown = sorted(placeholders - argument_names)
|
||||||
|
raise ValueError(f"prompt placeholders do not match arguments; missing={missing}, unknown={unknown}")
|
||||||
|
|
||||||
|
rendered = content
|
||||||
|
for name, value in arguments.items():
|
||||||
|
replacement = "Not provided" if value is None else str(value)
|
||||||
|
rendered = rendered.replace(f"{{{{{name}}}}}", replacement)
|
||||||
|
return rendered
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastmcp.server.providers import FileSystemProvider
|
||||||
|
|
||||||
|
|
||||||
|
def create_prompts_provider() -> FileSystemProvider:
|
||||||
|
components_root = Path(__file__).parent / "components"
|
||||||
|
if not components_root.is_dir():
|
||||||
|
raise FileNotFoundError(f"prompt components root does not exist or is not a directory: {components_root}")
|
||||||
|
return FileSystemProvider(root=components_root, reload=False)
|
||||||
@@ -1,9 +1,3 @@
|
|||||||
from .models.registry import DocsRegistry
|
from .models.registry import DocsRegistry
|
||||||
from .models.registry import PromptRecord
|
|
||||||
from .models.registry import PromptSummaryRecord
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = ["DocsRegistry"]
|
||||||
"DocsRegistry",
|
|
||||||
"PromptRecord",
|
|
||||||
"PromptSummaryRecord",
|
|
||||||
]
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from collections.abc import Generator
|
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from dataclasses import field
|
|
||||||
from importlib.resources.abc import Traversable
|
from importlib.resources.abc import Traversable
|
||||||
from itertools import starmap
|
from itertools import starmap
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
@@ -17,10 +15,8 @@ class MarkdownDocument:
|
|||||||
|
|
||||||
relpath: DocsPath
|
relpath: DocsPath
|
||||||
"""The relative path of the document within the package resources."""
|
"""The relative path of the document within the package resources."""
|
||||||
content: str = field(repr=False)
|
content: str
|
||||||
"""The raw markdown content of the document."""
|
"""The raw markdown content of the document."""
|
||||||
frontmatter: str | None = field(repr=False, default=None)
|
|
||||||
"""The raw YAML frontmatter of the document, if present."""
|
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
object.__setattr__(self, "relpath", parse_docs_path(self.relpath))
|
object.__setattr__(self, "relpath", parse_docs_path(self.relpath))
|
||||||
@@ -34,15 +30,7 @@ class MarkdownDocument:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self:
|
def from_resource(cls, relpath: DocsPath, resource: Traversable) -> Self:
|
||||||
"""Load a markdown document from a package resource."""
|
"""Load a markdown document from a package resource."""
|
||||||
raw = resource.read_text(encoding="utf-8")
|
return cls(relpath=relpath, content=resource.read_text(encoding="utf-8"))
|
||||||
frontmatter = get_raw_frontmatter(raw)
|
|
||||||
return cls(relpath=relpath, content=raw, frontmatter=frontmatter)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def prompt_slug(self) -> str | None:
|
|
||||||
parts = self.relpath.parts
|
|
||||||
if parts[0] == "prompts" and len(parts) >= 3:
|
|
||||||
return parts[1]
|
|
||||||
|
|
||||||
|
|
||||||
def walk_resources(
|
def walk_resources(
|
||||||
@@ -59,19 +47,3 @@ def walk_resources(
|
|||||||
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
||||||
elif child.is_file() and child.name.lower().endswith(suffix):
|
elif child.is_file() and child.name.lower().endswith(suffix):
|
||||||
yield relpath, child
|
yield relpath, child
|
||||||
|
|
||||||
|
|
||||||
def get_raw_frontmatter(raw: str) -> str | None:
|
|
||||||
delimiter = iter(get_frontmatter_delim_idx(raw, delimiter="---"))
|
|
||||||
try:
|
|
||||||
start = next(delimiter) + 1
|
|
||||||
end = next(delimiter)
|
|
||||||
except StopIteration:
|
|
||||||
return None
|
|
||||||
return "\n".join(raw.splitlines()[start:end])
|
|
||||||
|
|
||||||
|
|
||||||
def get_frontmatter_delim_idx(raw: str, *, delimiter: str = "---") -> Generator[int]:
|
|
||||||
for i, line in enumerate(raw.splitlines()):
|
|
||||||
if line.strip().startswith(delimiter):
|
|
||||||
yield i
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
from collections.abc import Iterable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from importlib.resources.abc import Traversable
|
|
||||||
from itertools import starmap
|
|
||||||
from typing import Self
|
|
||||||
|
|
||||||
from .document import MarkdownDocument
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class PromptFilesBundle:
|
|
||||||
"""Represents a prompt and all of its associated markdown files."""
|
|
||||||
|
|
||||||
slug: str
|
|
||||||
prompt: MarkdownDocument
|
|
||||||
other: tuple[MarkdownDocument, ...]
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_root(cls, root: Traversable) -> list[Self]:
|
|
||||||
# Should only be used for testing
|
|
||||||
return list(cls.from_docs(MarkdownDocument.from_root(root).values()))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_docs(cls, docs: Iterable[MarkdownDocument]) -> tuple[Self, ...]:
|
|
||||||
return tuple(starmap(cls.from_paths, group_prompt_paths(docs).items()))
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
|
||||||
prompt = next(iter(p for p in paths if p.relpath.name == "PROMPT.md"))
|
|
||||||
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath))
|
|
||||||
other = tuple(p for p in sorted_paths if p != prompt)
|
|
||||||
return cls(
|
|
||||||
slug=slug,
|
|
||||||
prompt=prompt,
|
|
||||||
other=other,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def group_prompt_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[MarkdownDocument]]:
|
|
||||||
"""Group prompts from a list of markdown documents by their prompt slug."""
|
|
||||||
grouped: dict[str, set[MarkdownDocument]] = {}
|
|
||||||
for doc in sorted(
|
|
||||||
filter(lambda d: d.prompt_slug is not None, docs),
|
|
||||||
key=lambda d: d.relpath,
|
|
||||||
):
|
|
||||||
if doc.prompt_slug:
|
|
||||||
grouped.setdefault(doc.prompt_slug, set()).add(doc)
|
|
||||||
return grouped
|
|
||||||
@@ -1,44 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
from functools import cache
|
from functools import cache
|
||||||
from importlib.resources import files
|
from importlib.resources import files
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
|
||||||
from personal_mcp.registry.models.prompt import StoredPrompt
|
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt_record(*, bundle: PromptFilesBundle) -> PromptRecord:
|
|
||||||
stored = StoredPrompt.from_bundle(bundle)
|
|
||||||
metadata = stored.frontmatter.x_personal_mcp
|
|
||||||
|
|
||||||
return PromptRecord(
|
|
||||||
prompt_id=metadata.id,
|
|
||||||
name=stored.frontmatter.name,
|
|
||||||
description=stored.frontmatter.description,
|
|
||||||
version=metadata.version,
|
|
||||||
tags=tuple(metadata.tags),
|
|
||||||
capabilities=tuple(metadata.capabilities),
|
|
||||||
arguments=dict(metadata.arguments),
|
|
||||||
document_uri=f"resource://prompts/{metadata.id}/document",
|
|
||||||
document_relpath=stored.relpath,
|
|
||||||
document_content=stored.content,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _build_tag_index_prompts(
|
|
||||||
prompts_in_order: tuple[str, ...],
|
|
||||||
prompts_by_id: dict[str, PromptRecord],
|
|
||||||
) -> dict[str, tuple[str, ...]]:
|
|
||||||
tag_index: defaultdict[str, list[str]] = defaultdict(list)
|
|
||||||
for prompt_id in prompts_in_order:
|
|
||||||
for tag in prompts_by_id[prompt_id].tags:
|
|
||||||
tag_index[tag].append(prompt_id)
|
|
||||||
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
|
|
||||||
|
|
||||||
|
|
||||||
@cache
|
@cache
|
||||||
@@ -48,28 +14,9 @@ def get_docs_registry() -> DocsRegistry:
|
|||||||
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}")
|
raise FileNotFoundError(f"docs root does not exist or is not a directory: {root}")
|
||||||
docs = MarkdownDocument.from_root(root)
|
docs = MarkdownDocument.from_root(root)
|
||||||
|
|
||||||
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items()}
|
docs_markdown_by_path = {relpath: doc.content for relpath, doc in docs.items() if relpath.parts[0] != "skills"}
|
||||||
|
|
||||||
prompt_bundles = PromptFilesBundle.from_docs(docs.values())
|
|
||||||
|
|
||||||
prompts_by_id: dict[str, PromptRecord] = {}
|
|
||||||
prompts_in_load_order: list[str] = []
|
|
||||||
for bundle in prompt_bundles:
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
if record.prompt_id in prompts_by_id:
|
|
||||||
raise ValueError(f"duplicate prompt_id detected: {record.prompt_id}")
|
|
||||||
prompts_by_id[record.prompt_id] = record
|
|
||||||
prompts_in_load_order.append(record.prompt_id)
|
|
||||||
|
|
||||||
prompts_in_order_tuple = tuple(prompts_in_load_order)
|
|
||||||
|
|
||||||
return DocsRegistry(
|
return DocsRegistry(
|
||||||
docs_markdown_by_path=docs_markdown_by_path,
|
docs_markdown_by_path=docs_markdown_by_path,
|
||||||
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
||||||
prompts_by_id=prompts_by_id,
|
|
||||||
prompts_in_load_order=prompts_in_order_tuple,
|
|
||||||
prompts_summary_in_load_order=tuple(
|
|
||||||
PromptSummaryRecord.from_record(prompts_by_id[prompt_id]) for prompt_id in prompts_in_order_tuple
|
|
||||||
),
|
|
||||||
tag_to_prompt_ids=_build_tag_index_prompts(prompts_in_order_tuple, prompts_by_id),
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import re
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from types import MappingProxyType
|
from types import MappingProxyType
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
from typing import Final
|
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from pydantic import BeforeValidator
|
from pydantic import BeforeValidator
|
||||||
from pydantic import ConfigDict
|
from pydantic import ConfigDict
|
||||||
|
|
||||||
SKILL_ID_RE: Final[re.Pattern[str]] = re.compile(r"^[a-z][a-z0-9-]*$")
|
|
||||||
SEMVER_RE: Final[re.Pattern[str]] = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$")
|
|
||||||
|
|
||||||
|
|
||||||
class StrictFrozenModel(BaseModel):
|
class StrictFrozenModel(BaseModel):
|
||||||
"""Immutable base model with strict field validation rules."""
|
"""Immutable base model with strict field validation rules."""
|
||||||
@@ -20,8 +15,6 @@ class StrictFrozenModel(BaseModel):
|
|||||||
model_config: ClassVar[ConfigDict] = ConfigDict(
|
model_config: ClassVar[ConfigDict] = ConfigDict(
|
||||||
extra="forbid",
|
extra="forbid",
|
||||||
frozen=True,
|
frozen=True,
|
||||||
validate_by_alias=True,
|
|
||||||
validate_by_name=True,
|
|
||||||
str_strip_whitespace=True,
|
str_strip_whitespace=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
import re
|
|
||||||
from collections.abc import Mapping
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from pydantic import Field
|
|
||||||
from pydantic import field_validator
|
|
||||||
from pydantic import model_validator
|
|
||||||
|
|
||||||
from .common import SEMVER_RE
|
|
||||||
from .common import SKILL_ID_RE
|
|
||||||
from .common import DocsPath
|
|
||||||
from .common import StrictFrozenModel
|
|
||||||
from .common import frozen_mapping
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
|
||||||
|
|
||||||
|
|
||||||
class PromptArgumentEntry(StrictFrozenModel):
|
|
||||||
"""Schema for a single prompt argument definition."""
|
|
||||||
|
|
||||||
title: str | None = None
|
|
||||||
description: str | None = None
|
|
||||||
required: bool = False
|
|
||||||
|
|
||||||
|
|
||||||
class PromptMetadata(StrictFrozenModel):
|
|
||||||
"""Canonical metadata describing a prompt contract and arguments."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
version: str
|
|
||||||
tags: tuple[str, ...] = ()
|
|
||||||
capabilities: tuple[str, ...] = Field(min_length=1)
|
|
||||||
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
|
||||||
|
|
||||||
@field_validator("id")
|
|
||||||
@classmethod
|
|
||||||
def validate_id(cls, value: str) -> str:
|
|
||||||
if not SKILL_ID_RE.fullmatch(value):
|
|
||||||
raise ValueError("id must be lowercase kebab-case and start with a letter")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("version")
|
|
||||||
@classmethod
|
|
||||||
def validate_version(cls, value: str) -> str:
|
|
||||||
if not SEMVER_RE.fullmatch(value):
|
|
||||||
raise ValueError("version must be semver")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("tags")
|
|
||||||
@classmethod
|
|
||||||
def validate_tags(cls, value: tuple[str, ...]) -> tuple[str, ...]:
|
|
||||||
for tag in value:
|
|
||||||
if not SKILL_ID_RE.fullmatch(tag):
|
|
||||||
raise ValueError(f"invalid tag: {tag}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@field_validator("arguments", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
|
|
||||||
return frozen_mapping(value)
|
|
||||||
|
|
||||||
@field_validator("arguments")
|
|
||||||
@classmethod
|
|
||||||
def validate_argument_names(cls, value: Mapping[str, PromptArgumentEntry]) -> Mapping[str, PromptArgumentEntry]:
|
|
||||||
for name in value:
|
|
||||||
if not re.fullmatch(r"^[A-Za-z_][A-Za-z0-9_]*$", name):
|
|
||||||
raise ValueError(f"invalid prompt argument name: {name}")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class PromptFrontmatter(StrictFrozenModel):
|
|
||||||
"""Parsed PROMPT frontmatter including personal-mcp metadata."""
|
|
||||||
|
|
||||||
name: str = Field(min_length=1, max_length=64)
|
|
||||||
description: str = Field(min_length=1, max_length=1024)
|
|
||||||
x_personal_mcp: PromptMetadata = Field(alias="x-personal-mcp")
|
|
||||||
|
|
||||||
@field_validator("name")
|
|
||||||
@classmethod
|
|
||||||
def validate_name(cls, value: str) -> str:
|
|
||||||
if not SKILL_ID_RE.fullmatch(value):
|
|
||||||
raise ValueError("name must be lowercase kebab-case and start with a letter")
|
|
||||||
if "anthropic" in value or "claude" in value:
|
|
||||||
raise ValueError("name must not contain reserved words anthropic or claude")
|
|
||||||
return value
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_raw_yaml(cls, raw: str | None) -> "PromptFrontmatter":
|
|
||||||
if raw is None:
|
|
||||||
raise ValueError("missing YAML frontmatter")
|
|
||||||
try:
|
|
||||||
data = yaml.safe_load(raw)
|
|
||||||
except yaml.YAMLError as e:
|
|
||||||
raise ValueError(f"invalid YAML in frontmatter: {e}") from e
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise TypeError("frontmatter must parse to an object")
|
|
||||||
return cls.model_validate(data)
|
|
||||||
|
|
||||||
|
|
||||||
class StoredPrompt(StrictFrozenModel):
|
|
||||||
"""Normalized prompt document content with path and frontmatter for storage in the registry."""
|
|
||||||
|
|
||||||
prompt_id: str
|
|
||||||
relpath: DocsPath
|
|
||||||
content: str
|
|
||||||
frontmatter: PromptFrontmatter
|
|
||||||
|
|
||||||
@field_validator("frontmatter", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def parse_frontmatter_yaml(cls, value: PromptFrontmatter | str | None) -> PromptFrontmatter:
|
|
||||||
if isinstance(value, PromptFrontmatter):
|
|
||||||
return value
|
|
||||||
return PromptFrontmatter.from_raw_yaml(value)
|
|
||||||
|
|
||||||
@model_validator(mode="after")
|
|
||||||
def validate_contract(self) -> "StoredPrompt":
|
|
||||||
parts = self.relpath.parts
|
|
||||||
if len(parts) < 3 or parts[0] != "prompts":
|
|
||||||
raise ValueError("prompt relpath must be under prompts/<slug>/")
|
|
||||||
|
|
||||||
prompt_dir_name = parts[1]
|
|
||||||
if self.frontmatter.name != prompt_dir_name:
|
|
||||||
raise ValueError("frontmatter name must exactly match prompt directory name")
|
|
||||||
if self.frontmatter.x_personal_mcp.id != self.frontmatter.name:
|
|
||||||
raise ValueError("x-personal-mcp.id must exactly match name")
|
|
||||||
|
|
||||||
expected_capability = f"resource://prompts/{self.frontmatter.name}/document"
|
|
||||||
if expected_capability not in self.frontmatter.x_personal_mcp.capabilities:
|
|
||||||
raise ValueError(f"capabilities must include {expected_capability}")
|
|
||||||
|
|
||||||
if self.prompt_id != self.frontmatter.x_personal_mcp.id:
|
|
||||||
raise ValueError("prompt_id must exactly match x-personal-mcp.id")
|
|
||||||
return self
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_bundle(cls, bundle: "PromptFilesBundle") -> "StoredPrompt":
|
|
||||||
frontmatter = PromptFrontmatter.from_raw_yaml(bundle.prompt.frontmatter)
|
|
||||||
return cls.model_validate(
|
|
||||||
{
|
|
||||||
"prompt_id": frontmatter.x_personal_mcp.id,
|
|
||||||
"relpath": bundle.prompt.relpath,
|
|
||||||
"content": bundle.prompt.content,
|
|
||||||
"frontmatter": frontmatter,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
@@ -6,105 +6,21 @@ from pydantic import field_validator
|
|||||||
from .common import DocsPath
|
from .common import DocsPath
|
||||||
from .common import StrictFrozenModel
|
from .common import StrictFrozenModel
|
||||||
from .common import frozen_mapping
|
from .common import frozen_mapping
|
||||||
from .prompt import PromptArgumentEntry
|
|
||||||
|
|
||||||
|
|
||||||
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
def _empty_docs_mapping() -> Mapping[DocsPath, str]:
|
||||||
return frozen_mapping()
|
return frozen_mapping()
|
||||||
|
|
||||||
|
|
||||||
class PromptRecord(StrictFrozenModel):
|
|
||||||
"""Registry record containing a fully resolved prompt document."""
|
|
||||||
|
|
||||||
prompt_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
version: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
arguments: Mapping[str, PromptArgumentEntry] = Field(default_factory=frozen_mapping)
|
|
||||||
document_uri: str
|
|
||||||
document_relpath: DocsPath
|
|
||||||
document_content: str
|
|
||||||
|
|
||||||
@field_validator("arguments", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def freeze_arguments(cls, value: Mapping[str, PromptArgumentEntry] | None) -> Mapping[str, PromptArgumentEntry]:
|
|
||||||
return frozen_mapping(value)
|
|
||||||
|
|
||||||
|
|
||||||
class PromptSummaryRecord(StrictFrozenModel):
|
|
||||||
"""Compact prompt summary exposed by catalog listing APIs."""
|
|
||||||
|
|
||||||
prompt_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
document_uri: str
|
|
||||||
version: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_record(cls, record: PromptRecord) -> "PromptSummaryRecord":
|
|
||||||
return cls(
|
|
||||||
prompt_id=record.prompt_id,
|
|
||||||
name=record.name,
|
|
||||||
description=record.description,
|
|
||||||
tags=record.tags,
|
|
||||||
capabilities=record.capabilities,
|
|
||||||
document_uri=record.document_uri,
|
|
||||||
version=record.version,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PromptSummaryPayload(StrictFrozenModel):
|
|
||||||
"""Catalog payload model for prompt index summaries."""
|
|
||||||
|
|
||||||
id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
tags: list[str]
|
|
||||||
capabilities: list[str]
|
|
||||||
version: str
|
|
||||||
document_uri: str
|
|
||||||
detail_uri: str
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_record(cls, record: PromptRecord) -> "PromptSummaryPayload":
|
|
||||||
return cls(
|
|
||||||
id=record.prompt_id,
|
|
||||||
name=record.name,
|
|
||||||
description=record.description,
|
|
||||||
tags=list(record.tags),
|
|
||||||
capabilities=list(record.capabilities),
|
|
||||||
version=record.version,
|
|
||||||
document_uri=record.document_uri,
|
|
||||||
detail_uri=f"resource://catalog/prompts/{record.prompt_id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DocsRegistry(StrictFrozenModel):
|
class DocsRegistry(StrictFrozenModel):
|
||||||
"""In-memory index of loaded prompts and documentation content."""
|
"""In-memory index of documentation content."""
|
||||||
|
|
||||||
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
|
docs_markdown_by_path: Mapping[DocsPath, str] = Field(default_factory=_empty_docs_mapping)
|
||||||
"""Maps each documentation path to its loaded Markdown content."""
|
"""Maps each documentation path to its loaded Markdown content."""
|
||||||
docs_markdown_path_index: tuple[DocsPath, ...]
|
docs_markdown_path_index: tuple[DocsPath, ...]
|
||||||
"""Lists documentation paths in deterministic index order."""
|
"""Lists documentation paths in deterministic index order."""
|
||||||
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
|
|
||||||
"""Maps each prompt identifier to its fully resolved registry record."""
|
|
||||||
prompts_in_load_order: tuple[str, ...] = ()
|
|
||||||
"""Preserves prompt identifiers in deterministic source loading order."""
|
|
||||||
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
|
||||||
"""Stores compact prompt summaries in the same deterministic loading order."""
|
|
||||||
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
|
||||||
"""Indexes prompt identifiers by tag for catalog filtering and search."""
|
|
||||||
|
|
||||||
@field_validator(
|
@field_validator("docs_markdown_by_path", mode="before")
|
||||||
"docs_markdown_by_path",
|
|
||||||
"prompts_by_id",
|
|
||||||
"tag_to_prompt_ids",
|
|
||||||
mode="before",
|
|
||||||
)
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||||
return frozen_mapping(value)
|
return frozen_mapping(value)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ from .models.registry import DocsRegistry
|
|||||||
|
|
||||||
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
||||||
docs_path = parse_docs_path(path)
|
docs_path = parse_docs_path(path)
|
||||||
|
if docs_path.parts[0] == "skills":
|
||||||
|
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||||
if docs_path not in registry.docs_markdown_by_path:
|
if docs_path not in registry.docs_markdown_by_path:
|
||||||
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
raise KeyError(f"unknown docs path: {docs_path.as_posix()}")
|
||||||
return {
|
return {
|
||||||
@@ -12,16 +14,3 @@ def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]
|
|||||||
"source_path": f"docs/{docs_path.as_posix()}",
|
"source_path": f"docs/{docs_path.as_posix()}",
|
||||||
"content": registry.docs_markdown_by_path[docs_path],
|
"content": registry.docs_markdown_by_path[docs_path],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def read_prompt_document(registry: DocsRegistry, prompt_id: str) -> dict[str, str]:
|
|
||||||
if prompt_id not in registry.prompts_by_id:
|
|
||||||
raise KeyError(f"unknown prompt_id: {prompt_id}")
|
|
||||||
prompt = registry.prompts_by_id[prompt_id]
|
|
||||||
return {
|
|
||||||
"id": prompt.prompt_id,
|
|
||||||
"uri": prompt.document_uri,
|
|
||||||
"format": "markdown",
|
|
||||||
"source_path": f"docs/{prompt.document_relpath.as_posix()}",
|
|
||||||
"content": prompt.document_content,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
path=runtime_settings.mounts.mcp,
|
path=runtime_settings.mounts.mcp,
|
||||||
json_response=True,
|
json_response=True,
|
||||||
stateless_http=True,
|
stateless_http=True,
|
||||||
transport=runtime_settings.mcp_transport,
|
transport="http",
|
||||||
)
|
)
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
debug=runtime_settings.debug,
|
debug=runtime_settings.debug,
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from personal_mcp.prompts.content import render_prompt
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptContentRenderer:
|
||||||
|
def test_renders_arguments_without_frontmatter(self) -> None:
|
||||||
|
rendered = render_prompt(
|
||||||
|
"jsfiddle-page-layout",
|
||||||
|
{"domain": "public library", "layout_brief": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not rendered.startswith("---")
|
||||||
|
assert "`domain`: public library" in rendered
|
||||||
|
assert "`layout_brief`: Not provided" in rendered
|
||||||
|
|
||||||
|
def test_rejects_placeholder_drift(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="placeholders do not match arguments"):
|
||||||
|
render_prompt("jsfiddle-page-layout", {"domain": "public library"})
|
||||||
|
|
||||||
|
def test_rejects_invalid_prompt_id(self) -> None:
|
||||||
|
with pytest.raises(ValueError, match="lowercase kebab-case"):
|
||||||
|
render_prompt("../outside", {})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import pytest
|
||||||
|
from fastmcp import Client
|
||||||
|
from fastmcp import FastMCP
|
||||||
|
|
||||||
|
from personal_mcp.prompts import create_prompts_provider
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
EXPECTED_PROMPTS = {
|
||||||
|
"authoring",
|
||||||
|
"greenfield-architecture",
|
||||||
|
"jsfiddle-page-layout",
|
||||||
|
"mcp-consumer-repo-shim",
|
||||||
|
"nicegui-component-extraction",
|
||||||
|
"pytest-fill-scaffold",
|
||||||
|
"pytest-scaffold",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestPromptFileSystemProvider:
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_discovers_exact_authored_set(self) -> None:
|
||||||
|
mcp = FastMCP("prompts-test")
|
||||||
|
mcp.add_provider(create_prompts_provider())
|
||||||
|
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
prompts = await client.list_prompts()
|
||||||
|
|
||||||
|
assert {prompt.name for prompt in prompts} == EXPECTED_PROMPTS
|
||||||
|
assert all(prompt.description for prompt in prompts)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_exposes_typed_arguments_and_renders_markdown(self) -> None:
|
||||||
|
mcp = FastMCP("prompts-test")
|
||||||
|
mcp.add_provider(create_prompts_provider())
|
||||||
|
|
||||||
|
async with Client(mcp) as client:
|
||||||
|
prompts = await client.list_prompts()
|
||||||
|
authoring = next(prompt for prompt in prompts if prompt.name == "authoring")
|
||||||
|
result = await client.get_prompt(
|
||||||
|
"authoring",
|
||||||
|
{
|
||||||
|
"artifact_type": "skill",
|
||||||
|
"artifact_id": "demo-skill",
|
||||||
|
"goal": "Demonstrate typed prompts.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
required = {argument.name for argument in authoring.arguments or [] if argument.required}
|
||||||
|
assert required == {"artifact_type", "artifact_id", "goal"}
|
||||||
|
assert result.messages
|
||||||
|
assert "`artifact_id`: demo-skill" in result.messages[0].content.text
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
|
||||||
|
|
||||||
# Ingest-specific fixtures and factories belong in this subtree conftest.
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def make_doc() -> Callable[[str], MarkdownDocument]:
|
|
||||||
def _make_doc(relpath: str, content: str = "# body\n") -> MarkdownDocument:
|
|
||||||
return MarkdownDocument(relpath=PurePosixPath(relpath), content=content)
|
|
||||||
|
|
||||||
return _make_doc
|
|
||||||
@@ -1,32 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from personal_mcp.registry.load import get_docs_registry
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).parents[3]
|
|
||||||
DOCS_ROOT = REPO_ROOT / "docs"
|
|
||||||
|
|
||||||
|
|
||||||
class TestCurrentDocsIngestion:
|
class TestCurrentDocsIngestion:
|
||||||
"""Covers ingestion of the repository's current docs tree."""
|
"""Covers ingestion of the repository's current docs tree."""
|
||||||
|
|
||||||
def test_loads_markdown_documents(self) -> None:
|
def test_registry_includes_docs_and_excludes_skills(self) -> None:
|
||||||
"""Ensures all current markdown documents can be loaded."""
|
"""Ensures the docs registry cannot duplicate native skill resources."""
|
||||||
docs = MarkdownDocument.from_root(DOCS_ROOT)
|
registry = get_docs_registry()
|
||||||
|
|
||||||
assert docs
|
assert PurePosixPath("index.md") in registry.docs_markdown_by_path
|
||||||
|
assert any(path.parts[0] == "prompts" for path in registry.docs_markdown_by_path)
|
||||||
def test_bundles_current_prompts(self) -> None:
|
assert all(path.parts[0] != "skills" for path in registry.docs_markdown_by_path)
|
||||||
"""Ensures all current canonical prompt documents can be bundled."""
|
|
||||||
docs = MarkdownDocument.from_root(DOCS_ROOT)
|
|
||||||
expected_slugs = {path.parent.name for path in DOCS_ROOT.glob("prompts/*/PROMPT.md")}
|
|
||||||
|
|
||||||
bundles = PromptFilesBundle.from_docs(docs.values())
|
|
||||||
|
|
||||||
assert {bundle.slug for bundle in bundles} == expected_slugs
|
|
||||||
|
|||||||
@@ -1,176 +1,27 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||||
from personal_mcp.registry.ingest.document import get_frontmatter_delim_idx
|
|
||||||
from personal_mcp.registry.ingest.document import get_raw_frontmatter
|
|
||||||
from personal_mcp.registry.ingest.document import walk_resources
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
class TestMarkdownDocument:
|
class TestMarkdownDocument:
|
||||||
"""Covers MarkdownDocument construction and derived properties."""
|
"""Covers recursive Markdown discovery and loading."""
|
||||||
|
|
||||||
class TestFromRoot:
|
def test_loads_markdown_in_stable_order(self, tmp_path: Path) -> None:
|
||||||
"""Covers loading markdown documents from a resource root."""
|
nested = tmp_path / "guides"
|
||||||
|
nested.mkdir()
|
||||||
def test_keys_by_relpath(self, tmp_path: Path) -> None:
|
(nested / "b.md").write_text("caf\u00e9\n", encoding="utf-8")
|
||||||
"""Ensures from_root returns a mapping keyed by relative path."""
|
(nested / "a.md").write_text("alpha\n", encoding="utf-8")
|
||||||
skills_dir = tmp_path / "skills" / "demo"
|
(nested / "ignored.txt").write_text("ignored\n", encoding="utf-8")
|
||||||
skills_dir.mkdir(parents=True)
|
|
||||||
(skills_dir / "SKILL.md").write_text("# demo\n", encoding="utf-8")
|
|
||||||
|
|
||||||
docs = MarkdownDocument.from_root(tmp_path)
|
docs = MarkdownDocument.from_root(tmp_path)
|
||||||
|
|
||||||
assert set(docs) == {PurePosixPath("skills/demo/SKILL.md")}
|
assert list(docs) == [
|
||||||
|
PurePosixPath("guides/a.md"),
|
||||||
def test_loads_markdown_only(self, tmp_path: Path) -> None:
|
PurePosixPath("guides/b.md"),
|
||||||
"""Ensures from_root includes only markdown resources."""
|
|
||||||
(tmp_path / "a.md").write_text("a\n", encoding="utf-8")
|
|
||||||
(tmp_path / "b.MD").write_text("b\n", encoding="utf-8")
|
|
||||||
(tmp_path / "c.txt").write_text("c\n", encoding="utf-8")
|
|
||||||
|
|
||||||
docs = MarkdownDocument.from_root(tmp_path)
|
|
||||||
|
|
||||||
assert set(docs) == {PurePosixPath("a.md"), PurePosixPath("b.MD")}
|
|
||||||
|
|
||||||
def test_preserves_relpaths(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures from_root preserves PurePosixPath-style relative paths."""
|
|
||||||
nested = tmp_path / "skills" / "slug"
|
|
||||||
nested.mkdir(parents=True)
|
|
||||||
(nested / "SKILL.md").write_text("# slug\n", encoding="utf-8")
|
|
||||||
|
|
||||||
docs = MarkdownDocument.from_root(tmp_path)
|
|
||||||
[relpath] = docs.keys()
|
|
||||||
|
|
||||||
assert isinstance(relpath, PurePosixPath)
|
|
||||||
assert relpath == PurePosixPath("skills/slug/SKILL.md")
|
|
||||||
|
|
||||||
class TestFromResource:
|
|
||||||
"""Covers loading a single markdown document from a resource."""
|
|
||||||
|
|
||||||
def test_reads_utf8(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures from_resource reads text using UTF-8."""
|
|
||||||
resource = tmp_path / "index.md"
|
|
||||||
resource.write_text("caf\u00e9\n", encoding="utf-8")
|
|
||||||
|
|
||||||
doc = MarkdownDocument.from_resource(PurePosixPath("index.md"), resource)
|
|
||||||
|
|
||||||
assert doc.content == "caf\u00e9\n"
|
|
||||||
|
|
||||||
def test_sets_frontmatter(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures from_resource stores frontmatter when delimiters exist."""
|
|
||||||
resource = tmp_path / "index.md"
|
|
||||||
resource.write_text("---\nname: demo\n---\n# body\n", encoding="utf-8")
|
|
||||||
|
|
||||||
doc = MarkdownDocument.from_resource(PurePosixPath("index.md"), resource)
|
|
||||||
|
|
||||||
assert doc.frontmatter == "name: demo"
|
|
||||||
|
|
||||||
def test_none_frontmatter(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures from_resource sets frontmatter to None when absent."""
|
|
||||||
resource = tmp_path / "index.md"
|
|
||||||
resource.write_text("# no frontmatter\n", encoding="utf-8")
|
|
||||||
|
|
||||||
doc = MarkdownDocument.from_resource(PurePosixPath("index.md"), resource)
|
|
||||||
|
|
||||||
assert doc.frontmatter is None
|
|
||||||
|
|
||||||
class TestPromptSlugProperty:
|
|
||||||
"""Covers prompt_slug derivation from document relative paths."""
|
|
||||||
|
|
||||||
def test_returns_slug(self) -> None:
|
|
||||||
"""Ensures prompt_slug returns the slug for valid prompt paths."""
|
|
||||||
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo/PROMPT.md"), content="#")
|
|
||||||
|
|
||||||
assert doc.prompt_slug == "demo"
|
|
||||||
|
|
||||||
def test_none_for_non_prompt(self) -> None:
|
|
||||||
"""Ensures prompt_slug is None for non-prompt paths."""
|
|
||||||
doc = MarkdownDocument(relpath=PurePosixPath("docs/index.md"), content="#")
|
|
||||||
|
|
||||||
assert doc.prompt_slug is None
|
|
||||||
|
|
||||||
def test_none_for_incomplete_prompt(self) -> None:
|
|
||||||
"""Ensures prompt_slug is None for incomplete prompt paths."""
|
|
||||||
doc = MarkdownDocument(relpath=PurePosixPath("prompts/demo.md"), content="#")
|
|
||||||
|
|
||||||
assert doc.prompt_slug is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestWalkResources:
|
|
||||||
"""Covers recursive resource walking and markdown filtering behavior."""
|
|
||||||
|
|
||||||
def test_yields_markdown(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures walk_resources yields markdown files from nested directories."""
|
|
||||||
nested = tmp_path / "skills" / "alpha"
|
|
||||||
nested.mkdir(parents=True)
|
|
||||||
(nested / "SKILL.md").write_text("# alpha\n", encoding="utf-8")
|
|
||||||
(tmp_path / "index.md").write_text("# index\n", encoding="utf-8")
|
|
||||||
|
|
||||||
relpaths = [path for path, _ in walk_resources(tmp_path)]
|
|
||||||
|
|
||||||
assert relpaths == [
|
|
||||||
PurePosixPath("index.md"),
|
|
||||||
PurePosixPath("skills/alpha/SKILL.md"),
|
|
||||||
]
|
]
|
||||||
|
assert docs[PurePosixPath("guides/b.md")].content == "caf\u00e9\n"
|
||||||
def test_ignores_other_suffixes(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures walk_resources excludes files with non-matching suffixes."""
|
|
||||||
(tmp_path / "a.md").write_text("a\n", encoding="utf-8")
|
|
||||||
(tmp_path / "b.txt").write_text("b\n", encoding="utf-8")
|
|
||||||
(tmp_path / "c.json").write_text("c\n", encoding="utf-8")
|
|
||||||
|
|
||||||
relpaths = [path for path, _ in walk_resources(tmp_path)]
|
|
||||||
|
|
||||||
assert relpaths == [PurePosixPath("a.md")]
|
|
||||||
|
|
||||||
def test_sorted_output(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures walk_resources yields entries in sorted child-name order."""
|
|
||||||
(tmp_path / "b.md").write_text("b\n", encoding="utf-8")
|
|
||||||
(tmp_path / "a.md").write_text("a\n", encoding="utf-8")
|
|
||||||
(tmp_path / "skills").mkdir()
|
|
||||||
(tmp_path / "skills" / "z.md").write_text("z\n", encoding="utf-8")
|
|
||||||
|
|
||||||
relpaths = [path for path, _ in walk_resources(tmp_path)]
|
|
||||||
|
|
||||||
assert relpaths == [
|
|
||||||
PurePosixPath("a.md"),
|
|
||||||
PurePosixPath("b.md"),
|
|
||||||
PurePosixPath("skills/z.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
def test_applies_prefix(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures walk_resources prepends the provided prefix to relpaths."""
|
|
||||||
(tmp_path / "index.md").write_text("# index\n", encoding="utf-8")
|
|
||||||
|
|
||||||
relpaths = [path for path, _ in walk_resources(tmp_path, prefix=PurePosixPath("docs"))]
|
|
||||||
|
|
||||||
assert relpaths == [PurePosixPath("docs/index.md")]
|
|
||||||
|
|
||||||
|
|
||||||
class TestFrontmatterParsing:
|
|
||||||
"""Covers frontmatter delimiter discovery and raw block extraction."""
|
|
||||||
|
|
||||||
def test_extracts_between_delimiters(self) -> None:
|
|
||||||
"""Ensures get_raw_frontmatter returns lines between first delimiters."""
|
|
||||||
raw = "---\nname: demo\ntags:\n - test\n---\n# body\n"
|
|
||||||
|
|
||||||
assert get_raw_frontmatter(raw) == "name: demo\ntags:\n - test"
|
|
||||||
|
|
||||||
def test_none_without_two_delimiters(self) -> None:
|
|
||||||
"""Ensures get_raw_frontmatter returns None without two delimiters."""
|
|
||||||
raw = "---\nname: demo\n# body\n"
|
|
||||||
|
|
||||||
assert get_raw_frontmatter(raw) is None
|
|
||||||
|
|
||||||
def test_allows_leading_whitespace(self) -> None:
|
|
||||||
"""Ensures delimiter detection accepts lines with leading whitespace."""
|
|
||||||
raw = " ---\nname: demo\n ---\n# body\n"
|
|
||||||
|
|
||||||
assert list(get_frontmatter_delim_idx(raw)) == [0, 2]
|
|
||||||
|
|||||||
@@ -1,154 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
|
||||||
from personal_mcp.registry.ingest.prompt import group_prompt_paths
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
MakeDoc = Callable[[str], MarkdownDocument]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPromptFilesBundle:
|
|
||||||
"""Covers PromptFilesBundle construction and path-based categorization."""
|
|
||||||
|
|
||||||
class TestFromRoot:
|
|
||||||
"""Covers bundle creation from resource roots."""
|
|
||||||
|
|
||||||
def test_builds_bundles(self, tmp_path: Path) -> None:
|
|
||||||
"""Ensures from_root builds bundles from discovered markdown docs."""
|
|
||||||
alpha = tmp_path / "prompts" / "alpha"
|
|
||||||
beta = tmp_path / "prompts" / "beta"
|
|
||||||
alpha.mkdir(parents=True)
|
|
||||||
beta.mkdir(parents=True)
|
|
||||||
(alpha / "PROMPT.md").write_text("# alpha\n", encoding="utf-8")
|
|
||||||
(alpha / "notes.md").write_text("notes\n", encoding="utf-8")
|
|
||||||
(beta / "PROMPT.md").write_text("# beta\n", encoding="utf-8")
|
|
||||||
|
|
||||||
bundles = PromptFilesBundle.from_root(tmp_path)
|
|
||||||
|
|
||||||
assert {bundle.slug for bundle in bundles} == {"alpha", "beta"}
|
|
||||||
|
|
||||||
def test_delegates_to_from_docs(
|
|
||||||
self,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Ensures from_root delegates bundle assembly to from_docs."""
|
|
||||||
alpha = tmp_path / "prompts" / "alpha"
|
|
||||||
beta = tmp_path / "prompts" / "beta"
|
|
||||||
alpha.mkdir(parents=True)
|
|
||||||
beta.mkdir(parents=True)
|
|
||||||
(alpha / "PROMPT.md").write_text("# alpha\n", encoding="utf-8")
|
|
||||||
(alpha / "notes.md").write_text("notes\n", encoding="utf-8")
|
|
||||||
(beta / "PROMPT.md").write_text("# beta\n", encoding="utf-8")
|
|
||||||
|
|
||||||
from_root = PromptFilesBundle.from_root(tmp_path)
|
|
||||||
from_docs = PromptFilesBundle.from_docs(MarkdownDocument.from_root(tmp_path).values())
|
|
||||||
|
|
||||||
assert tuple(from_root) == from_docs
|
|
||||||
|
|
||||||
class TestFromDocs:
|
|
||||||
"""Covers bundle creation from preloaded markdown documents."""
|
|
||||||
|
|
||||||
def test_groups_by_slug(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures from_docs groups documents by prompt slug."""
|
|
||||||
docs = [
|
|
||||||
make_doc("prompts/alpha/PROMPT.md"),
|
|
||||||
make_doc("prompts/alpha/notes.md"),
|
|
||||||
make_doc("prompts/beta/PROMPT.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
bundles = PromptFilesBundle.from_docs(docs)
|
|
||||||
|
|
||||||
assert {bundle.slug for bundle in bundles} == {"alpha", "beta"}
|
|
||||||
|
|
||||||
def test_one_bundle_per_slug(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures from_docs produces one PromptFilesBundle per slug."""
|
|
||||||
docs = [
|
|
||||||
make_doc("prompts/alpha/PROMPT.md"),
|
|
||||||
make_doc("prompts/alpha/notes.md"),
|
|
||||||
make_doc("prompts/alpha/changelog.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
bundles = PromptFilesBundle.from_docs(docs)
|
|
||||||
|
|
||||||
assert len(bundles) == 1
|
|
||||||
assert bundles[0].slug == "alpha"
|
|
||||||
|
|
||||||
class TestFromPaths:
|
|
||||||
"""Covers classification of prompt and other documents."""
|
|
||||||
|
|
||||||
def test_selects_prompt_md(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures from_paths selects PROMPT.md as the primary document."""
|
|
||||||
docs = {
|
|
||||||
make_doc("prompts/alpha/PROMPT.md"),
|
|
||||||
make_doc("prompts/alpha/notes.md"),
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle = PromptFilesBundle.from_paths("alpha", docs)
|
|
||||||
|
|
||||||
assert bundle.prompt.relpath.name == "PROMPT.md"
|
|
||||||
|
|
||||||
def test_collects_other_docs(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures from_paths classifies non-prompt docs as other docs."""
|
|
||||||
docs = {
|
|
||||||
make_doc("prompts/alpha/PROMPT.md"),
|
|
||||||
make_doc("prompts/alpha/notes.md"),
|
|
||||||
make_doc("prompts/alpha/changelog.md"),
|
|
||||||
}
|
|
||||||
|
|
||||||
bundle = PromptFilesBundle.from_paths("alpha", docs)
|
|
||||||
|
|
||||||
assert {doc.relpath.as_posix() for doc in bundle.other} == {
|
|
||||||
"prompts/alpha/changelog.md",
|
|
||||||
"prompts/alpha/notes.md",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestGroupPromptPaths:
|
|
||||||
"""Covers grouping markdown documents by derived prompt slug."""
|
|
||||||
|
|
||||||
def test_groups_slugged_docs(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures group_prompt_paths groups only documents with a slug."""
|
|
||||||
docs = [
|
|
||||||
make_doc("prompts/alpha/PROMPT.md"),
|
|
||||||
make_doc("prompts/alpha/notes.md"),
|
|
||||||
make_doc("prompts/beta/PROMPT.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
grouped = group_prompt_paths(docs)
|
|
||||||
|
|
||||||
assert set(grouped) == {"alpha", "beta"}
|
|
||||||
|
|
||||||
def test_excludes_unslugged_docs(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures group_prompt_paths excludes documents without prompt slugs."""
|
|
||||||
docs = [
|
|
||||||
make_doc("docs/index.md"),
|
|
||||||
make_doc("prompts/legacy.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
assert group_prompt_paths(docs) == {}
|
|
||||||
|
|
||||||
def test_returns_sets(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures group_prompt_paths returns sets of docs per slug."""
|
|
||||||
grouped = group_prompt_paths([make_doc("prompts/alpha/PROMPT.md")])
|
|
||||||
|
|
||||||
assert isinstance(grouped["alpha"], set)
|
|
||||||
|
|
||||||
def test_stable_grouping(self, make_doc: MakeDoc) -> None:
|
|
||||||
"""Ensures group_prompt_paths behaves consistently after internal sorting."""
|
|
||||||
docs = [
|
|
||||||
make_doc("prompts/beta/PROMPT.md"),
|
|
||||||
make_doc("prompts/alpha/notes.md"),
|
|
||||||
make_doc("prompts/alpha/PROMPT.md"),
|
|
||||||
]
|
|
||||||
|
|
||||||
grouped_forward = group_prompt_paths(docs)
|
|
||||||
grouped_reverse = group_prompt_paths(list(reversed(docs)))
|
|
||||||
|
|
||||||
assert grouped_forward == grouped_reverse
|
|
||||||
@@ -1,78 +1,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
|
||||||
from personal_mcp.registry.models.common import parse_docs_path
|
from personal_mcp.registry.models.common import parse_docs_path
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
def make_markdown_document(
|
class TestDocsPathValidation:
|
||||||
relpath: str,
|
"""Covers canonical resource-path contracts."""
|
||||||
*,
|
|
||||||
content: str = "# body\n",
|
|
||||||
frontmatter: str | None = None,
|
|
||||||
) -> MarkdownDocument:
|
|
||||||
"""Builds a markdown ingest document with deterministic defaults."""
|
|
||||||
return MarkdownDocument(
|
|
||||||
relpath=PurePosixPath(relpath),
|
|
||||||
content=content,
|
|
||||||
frontmatter=frontmatter,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def make_prompt_frontmatter_payload(
|
|
||||||
*,
|
|
||||||
prompt_id: str,
|
|
||||||
name: str | None = None,
|
|
||||||
version: str = "1.0.0",
|
|
||||||
description: str = "demo prompt",
|
|
||||||
tags: tuple[str, ...] = ("testing",),
|
|
||||||
capabilities: tuple[str, ...] | None = None,
|
|
||||||
arguments: dict[str, dict[str, Any]] | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Builds frontmatter payload YAML for prompt conversion tests."""
|
|
||||||
canonical_name = name or prompt_id
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"name": canonical_name,
|
|
||||||
"description": description,
|
|
||||||
"x-personal-mcp": {
|
|
||||||
"id": prompt_id,
|
|
||||||
"version": version,
|
|
||||||
"tags": list(tags),
|
|
||||||
"capabilities": list(capabilities or (f"resource://prompts/{canonical_name}/document",)),
|
|
||||||
"arguments": arguments or {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return yaml.safe_dump(payload, sort_keys=False)
|
|
||||||
|
|
||||||
|
|
||||||
def as_markdown(frontmatter_yaml: str, *, body: str = "# body\n") -> str:
|
|
||||||
"""Wraps frontmatter YAML in markdown fence delimiters."""
|
|
||||||
return f"---\n{frontmatter_yaml.strip()}\n---\n{body}"
|
|
||||||
|
|
||||||
|
|
||||||
def assert_model_is_frozen(instance: Any, *, attr: str, value: Any) -> None:
|
|
||||||
"""Asserts pydantic frozen model semantics."""
|
|
||||||
with pytest.raises(ValidationError, match="Instance is frozen"):
|
|
||||||
setattr(instance, attr, value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGate5ContractValidation:
|
|
||||||
"""Gate 5: canonical resource-path contracts."""
|
|
||||||
|
|
||||||
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
||||||
"""Ensures boundary strings become path objects before publication."""
|
path = parse_docs_path("guides/demo.md")
|
||||||
path = parse_docs_path("skills/demo/SKILL.md")
|
|
||||||
|
|
||||||
assert path == PurePosixPath("skills/demo/SKILL.md")
|
assert path == PurePosixPath("guides/demo.md")
|
||||||
assert isinstance(path, PurePosixPath)
|
assert isinstance(path, PurePosixPath)
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -80,51 +23,11 @@ class TestGate5ContractValidation:
|
|||||||
(
|
(
|
||||||
"/absolute.md",
|
"/absolute.md",
|
||||||
"../outside.md",
|
"../outside.md",
|
||||||
"skills\\demo\\SKILL.md",
|
"guides\\demo.md",
|
||||||
"skills//demo/SKILL.md",
|
"guides//demo.md",
|
||||||
"skills/demo/README.txt",
|
"guides/demo.txt",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
||||||
"""Ensures non-canonical docs paths fail contract validation."""
|
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
parse_docs_path(value)
|
parse_docs_path(value)
|
||||||
|
|
||||||
|
|
||||||
class TestGate6FreezeValidation:
|
|
||||||
"""Gate 6: immutable in-memory registry snapshot semantics."""
|
|
||||||
|
|
||||||
def test_docs_registry_copies_mapping_inputs(self) -> None:
|
|
||||||
"""Ensures registry snapshots are isolated from caller-owned mapping mutations."""
|
|
||||||
index_path = PurePosixPath("index.md")
|
|
||||||
source_docs = {index_path: "# index\n"}
|
|
||||||
registry = DocsRegistry(
|
|
||||||
docs_markdown_by_path=source_docs,
|
|
||||||
docs_markdown_path_index=(index_path,),
|
|
||||||
prompts_by_id={},
|
|
||||||
prompts_in_load_order=(),
|
|
||||||
prompts_summary_in_load_order=(),
|
|
||||||
tag_to_prompt_ids={},
|
|
||||||
)
|
|
||||||
|
|
||||||
source_docs[PurePosixPath("other.md")] = "# other\n"
|
|
||||||
|
|
||||||
assert PurePosixPath("other.md") not in registry.docs_markdown_by_path
|
|
||||||
assert registry.docs_markdown_path_index == (index_path,)
|
|
||||||
|
|
||||||
def test_docs_registry_instance_is_frozen(self) -> None:
|
|
||||||
"""Ensures frozen model prevents attribute reassignment."""
|
|
||||||
registry = DocsRegistry(
|
|
||||||
docs_markdown_by_path={},
|
|
||||||
docs_markdown_path_index=(),
|
|
||||||
prompts_by_id={},
|
|
||||||
prompts_in_load_order=(),
|
|
||||||
prompts_summary_in_load_order=(),
|
|
||||||
tag_to_prompt_ids={},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert_model_is_frozen(
|
|
||||||
registry,
|
|
||||||
attr="docs_markdown_path_index",
|
|
||||||
value=(PurePosixPath("index.md"),),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,170 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from pydantic import ValidationError
|
|
||||||
|
|
||||||
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
|
||||||
from personal_mcp.registry.load import _build_prompt_record
|
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
|
||||||
from tests.registry.models.test_document_validation import assert_model_is_frozen
|
|
||||||
from tests.registry.models.test_document_validation import make_markdown_document
|
|
||||||
from tests.registry.models.test_document_validation import make_prompt_frontmatter_payload
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
def _make_prompt_bundle(
|
|
||||||
*,
|
|
||||||
slug: str,
|
|
||||||
frontmatter: str | None,
|
|
||||||
other_files: tuple[str, ...] = (),
|
|
||||||
) -> PromptFilesBundle:
|
|
||||||
prompt = make_markdown_document(
|
|
||||||
f"prompts/{slug}/PROMPT.md",
|
|
||||||
frontmatter=frontmatter,
|
|
||||||
)
|
|
||||||
other = tuple(make_markdown_document(f"prompts/{slug}/{filename}") for filename in other_files)
|
|
||||||
return PromptFilesBundle(slug=slug, prompt=prompt, other=other)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPromptValidationGates:
|
|
||||||
"""Gate-oriented validation coverage for prompt conversion."""
|
|
||||||
|
|
||||||
class TestGate1LayoutValidation:
|
|
||||||
"""Gate 1: enforce required source shape before metadata parsing."""
|
|
||||||
|
|
||||||
def test_missing_frontmatter_fails_fast(self) -> None:
|
|
||||||
"""Ensures conversion rejects missing prompt frontmatter at the layout gate."""
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=None)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="missing YAML frontmatter"):
|
|
||||||
_build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
class TestGate2MetadataValidation:
|
|
||||||
"""Gate 2: validate prompt metadata via pydantic models."""
|
|
||||||
|
|
||||||
def test_rejects_non_semver_version(self) -> None:
|
|
||||||
"""Ensures semver violations fail during prompt model validation."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial", version="not-semver")
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="version must be semver"):
|
|
||||||
_build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
def test_rejects_invalid_argument_names(self) -> None:
|
|
||||||
"""Ensures prompt argument keys use Python-identifier naming rules."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(
|
|
||||||
prompt_id="initial",
|
|
||||||
arguments={
|
|
||||||
"invalid-name": {
|
|
||||||
"required": True,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
|
|
||||||
with pytest.raises(ValidationError, match="invalid prompt argument name"):
|
|
||||||
_build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
def test_rejects_missing_primary_capability(self) -> None:
|
|
||||||
"""Ensures canonical prompt document capability is required."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(
|
|
||||||
prompt_id="initial",
|
|
||||||
capabilities=("resource://catalog/prompts_index",),
|
|
||||||
)
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match="capabilities must include"):
|
|
||||||
_build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
class TestGate3ResourceValidation:
|
|
||||||
"""Gate 3: resolve prompt document resources and argument schema."""
|
|
||||||
|
|
||||||
def test_assigns_canonical_document_uri(self) -> None:
|
|
||||||
"""Ensures prompt records emit canonical document URIs."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
assert record.document_uri == "resource://prompts/initial/document"
|
|
||||||
assert record.document_relpath == PurePosixPath("prompts/initial/PROMPT.md")
|
|
||||||
|
|
||||||
def test_preserves_argument_schema(self) -> None:
|
|
||||||
"""Ensures argument metadata survives conversion unchanged."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(
|
|
||||||
prompt_id="initial",
|
|
||||||
arguments={
|
|
||||||
"topic": {
|
|
||||||
"required": True,
|
|
||||||
"description": "topic to discuss",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
assert record.arguments["topic"].required is True
|
|
||||||
assert record.arguments["topic"].description == "topic to discuss"
|
|
||||||
|
|
||||||
class TestGate5ContractValidation:
|
|
||||||
"""Gate 5: validate model_dump contract shape for API surfaces."""
|
|
||||||
|
|
||||||
def test_prompt_record_model_dump_contains_contract_fields(self) -> None:
|
|
||||||
"""Ensures prompt record serialization includes canonical fields."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
dumped = record.model_dump()
|
|
||||||
|
|
||||||
assert dumped["prompt_id"] == "initial"
|
|
||||||
assert dumped["document_uri"] == "resource://prompts/initial/document"
|
|
||||||
assert "arguments" in dumped
|
|
||||||
assert "document_content" in dumped
|
|
||||||
|
|
||||||
def test_prompt_summary_projection_stays_stable(self) -> None:
|
|
||||||
"""Ensures prompt summary shape remains deterministic for index responses."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
summary = PromptSummaryRecord.from_record(record)
|
|
||||||
|
|
||||||
assert summary.model_dump() == {
|
|
||||||
"prompt_id": "initial",
|
|
||||||
"name": "initial",
|
|
||||||
"description": "demo prompt",
|
|
||||||
"tags": ("testing",),
|
|
||||||
"capabilities": ("resource://prompts/initial/document",),
|
|
||||||
"document_uri": "resource://prompts/initial/document",
|
|
||||||
"version": "1.0.0",
|
|
||||||
}
|
|
||||||
|
|
||||||
class TestGate6FreezeValidation:
|
|
||||||
"""Gate 6: ensure immutable runtime records."""
|
|
||||||
|
|
||||||
def test_prompt_record_instance_is_frozen(self) -> None:
|
|
||||||
"""Ensures validated prompt records cannot be mutated after creation."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(prompt_id="initial")
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
assert_model_is_frozen(record, attr="name", value="mutated")
|
|
||||||
|
|
||||||
def test_argument_entry_values_are_frozen(self) -> None:
|
|
||||||
"""Ensures nested prompt argument entries are immutable after publication."""
|
|
||||||
frontmatter = make_prompt_frontmatter_payload(
|
|
||||||
prompt_id="initial",
|
|
||||||
arguments={
|
|
||||||
"topic": {
|
|
||||||
"required": True,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter)
|
|
||||||
record = _build_prompt_record(bundle=bundle)
|
|
||||||
|
|
||||||
assert_model_is_frozen(record.arguments["topic"], attr="required", value=False)
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
from pathlib import PurePosixPath
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from personal_mcp.registry.models.prompt import PromptArgumentEntry
|
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
|
||||||
|
|
||||||
|
|
||||||
def _make_prompt_record() -> PromptRecord:
|
|
||||||
return PromptRecord(
|
|
||||||
prompt_id="demo-prompt",
|
|
||||||
name="demo-prompt",
|
|
||||||
description="demo prompt",
|
|
||||||
version="0.9.0",
|
|
||||||
tags=("testing",),
|
|
||||||
capabilities=("resource://prompts/demo-prompt/document",),
|
|
||||||
arguments={
|
|
||||||
"topic": PromptArgumentEntry(
|
|
||||||
title="Topic",
|
|
||||||
required=True,
|
|
||||||
description="topic to discuss",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
document_uri="resource://prompts/demo-prompt/document",
|
|
||||||
document_relpath=PurePosixPath("prompts/demo-prompt/PROMPT.md"),
|
|
||||||
document_content="# demo",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_prompt_summary_payload_from_record_shape() -> None:
|
|
||||||
record = _make_prompt_record()
|
|
||||||
|
|
||||||
payload = PromptSummaryPayload.from_record(record).model_dump()
|
|
||||||
|
|
||||||
assert payload == {
|
|
||||||
"id": "demo-prompt",
|
|
||||||
"name": "demo-prompt",
|
|
||||||
"description": "demo prompt",
|
|
||||||
"tags": ["testing"],
|
|
||||||
"capabilities": ["resource://prompts/demo-prompt/document"],
|
|
||||||
"version": "0.9.0",
|
|
||||||
"document_uri": "resource://prompts/demo-prompt/document",
|
|
||||||
"detail_uri": "resource://catalog/prompts/demo-prompt",
|
|
||||||
}
|
|
||||||
@@ -3,36 +3,16 @@ from pathlib import PurePosixPath
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from personal_mcp.registry.models.registry import DocsRegistry
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
from personal_mcp.registry.models.registry import PromptRecord
|
|
||||||
from personal_mcp.registry.models.registry import PromptSummaryRecord
|
|
||||||
from personal_mcp.registry.read import read_docs_markdown_path
|
from personal_mcp.registry.read import read_docs_markdown_path
|
||||||
from personal_mcp.registry.read import read_prompt_document
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.unit
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
|
||||||
def _make_registry() -> DocsRegistry:
|
def _make_registry() -> DocsRegistry:
|
||||||
prompt_path = PurePosixPath("prompts/demo-prompt/PROMPT.md")
|
|
||||||
index_path = PurePosixPath("index.md")
|
index_path = PurePosixPath("index.md")
|
||||||
prompt = PromptRecord(
|
|
||||||
prompt_id="demo-prompt",
|
|
||||||
name="demo-prompt",
|
|
||||||
description="demo prompt",
|
|
||||||
version="1.0.0",
|
|
||||||
tags=("testing",),
|
|
||||||
capabilities=("resource://prompts/demo-prompt/document",),
|
|
||||||
arguments={},
|
|
||||||
document_uri="resource://prompts/demo-prompt/document",
|
|
||||||
document_relpath=prompt_path,
|
|
||||||
document_content="# prompt",
|
|
||||||
)
|
|
||||||
return DocsRegistry(
|
return DocsRegistry(
|
||||||
docs_markdown_by_path={index_path: "# index"},
|
docs_markdown_by_path={index_path: "# index"},
|
||||||
docs_markdown_path_index=(index_path,),
|
docs_markdown_path_index=(index_path,),
|
||||||
prompts_by_id={prompt.prompt_id: prompt},
|
|
||||||
prompts_in_load_order=(prompt.prompt_id,),
|
|
||||||
prompts_summary_in_load_order=(PromptSummaryRecord.from_record(prompt),),
|
|
||||||
tag_to_prompt_ids={"testing": (prompt.prompt_id,)},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,12 +27,11 @@ def test_reads_docs_path_from_string_boundary() -> None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_skill_docs_path() -> None:
|
||||||
|
with pytest.raises(KeyError, match="unknown docs path"):
|
||||||
|
read_docs_markdown_path(_make_registry(), "skills/demo/SKILL.md")
|
||||||
|
|
||||||
|
|
||||||
def test_rejects_non_posix_docs_path() -> None:
|
def test_rejects_non_posix_docs_path() -> None:
|
||||||
with pytest.raises(ValueError, match="POSIX separators"):
|
with pytest.raises(ValueError, match="POSIX separators"):
|
||||||
read_docs_markdown_path(_make_registry(), "skills\\demo\\SKILL.md")
|
read_docs_markdown_path(_make_registry(), "guides\\demo.md")
|
||||||
|
|
||||||
|
|
||||||
def test_serializes_record_paths_in_document_payloads() -> None:
|
|
||||||
registry = _make_registry()
|
|
||||||
|
|
||||||
assert read_prompt_document(registry, "demo-prompt")["source_path"] == ("docs/prompts/demo-prompt/PROMPT.md")
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import pytest
|
|||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
from httpx import ASGITransport
|
from httpx import ASGITransport
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from httpx2 import ASGITransport as McpASGITransport
|
||||||
|
from httpx2 import AsyncClient as McpAsyncClient
|
||||||
from mcp import ClientSession
|
from mcp import ClientSession
|
||||||
from mcp.client.streamable_http import streamable_http_client
|
from mcp.client.streamable_http import streamable_http_client
|
||||||
|
|
||||||
@@ -35,15 +37,15 @@ def mcp_session_factory():
|
|||||||
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
|
mcp_url = f"http://testserver{app.state.settings.mounts.mcp}"
|
||||||
async with (
|
async with (
|
||||||
app.router.lifespan_context(app),
|
app.router.lifespan_context(app),
|
||||||
AsyncClient(
|
McpAsyncClient(
|
||||||
transport=ASGITransport(app=app),
|
transport=McpASGITransport(app=app),
|
||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
timeout=10.0,
|
timeout=10.0,
|
||||||
) as http_client,
|
) as http_client,
|
||||||
streamable_http_client(
|
streamable_http_client(
|
||||||
mcp_url,
|
mcp_url,
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
) as (read_stream, write_stream, _),
|
) as (read_stream, write_stream),
|
||||||
ClientSession(read_stream, write_stream) as session,
|
ClientSession(read_stream, write_stream) as session,
|
||||||
):
|
):
|
||||||
if initialize:
|
if initialize:
|
||||||
|
|||||||
@@ -35,22 +35,12 @@ class TestMcpHttpEndpoints:
|
|||||||
"""Covers MCP transport endpoint smoke behavior."""
|
"""Covers MCP transport endpoint smoke behavior."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rejects_get_stream_without_support(
|
async def test_exposes_no_tools(self, mcp_session_factory) -> None:
|
||||||
self,
|
"""Ensures the server publishes only native resource and prompt surfaces."""
|
||||||
client: AsyncClient,
|
|
||||||
mcp_session_factory,
|
|
||||||
) -> None:
|
|
||||||
"""Ensures GET /mcp returns method not allowed for current transport mode."""
|
|
||||||
response = await client.get(
|
|
||||||
"/mcp",
|
|
||||||
headers={"Accept": "text/event-stream"},
|
|
||||||
)
|
|
||||||
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
async with mcp_session_factory() as mcp_session:
|
||||||
# Keep the SDK-backed session in use for this route smoke lane.
|
result = await mcp_session.list_tools()
|
||||||
await mcp_session.list_tools()
|
|
||||||
|
|
||||||
assert response.status_code == 405
|
assert result.tools == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_accepts_initialize_jsonrpc_request(
|
async def test_accepts_initialize_jsonrpc_request(
|
||||||
@@ -61,5 +51,5 @@ class TestMcpHttpEndpoints:
|
|||||||
async with mcp_session_factory(initialize=False) as mcp_session_uninitialized:
|
async with mcp_session_factory(initialize=False) as mcp_session_uninitialized:
|
||||||
initialize_result = await mcp_session_uninitialized.initialize()
|
initialize_result = await mcp_session_uninitialized.initialize()
|
||||||
|
|
||||||
assert initialize_result.protocolVersion
|
assert initialize_result.protocol_version
|
||||||
assert initialize_result.serverInfo.name
|
assert initialize_result.server_info.name
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ import pytest
|
|||||||
|
|
||||||
pytestmark = pytest.mark.smoke
|
pytestmark = pytest.mark.smoke
|
||||||
|
|
||||||
|
EXPECTED_PROMPTS = {
|
||||||
|
"authoring",
|
||||||
|
"greenfield-architecture",
|
||||||
|
"jsfiddle-page-layout",
|
||||||
|
"mcp-consumer-repo-shim",
|
||||||
|
"nicegui-component-extraction",
|
||||||
|
"pytest-fill-scaffold",
|
||||||
|
"pytest-scaffold",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestMcpPromptSurface:
|
class TestMcpPromptSurface:
|
||||||
"""Covers smoke-level MCP prompt discovery and retrieval paths."""
|
"""Covers smoke-level MCP prompt discovery and retrieval paths."""
|
||||||
@@ -17,8 +27,8 @@ class TestMcpPromptSurface:
|
|||||||
async with mcp_session_factory() as mcp_session:
|
async with mcp_session_factory() as mcp_session:
|
||||||
result = await mcp_session.list_prompts()
|
result = await mcp_session.list_prompts()
|
||||||
|
|
||||||
assert result.prompts
|
assert {prompt.name for prompt in result.prompts} == EXPECTED_PROMPTS
|
||||||
assert all(prompt.name for prompt in result.prompts)
|
assert all(prompt.description for prompt in result.prompts)
|
||||||
|
|
||||||
class TestPromptResolution:
|
class TestPromptResolution:
|
||||||
"""Covers MCP prompts/get behavior using native request and response objects."""
|
"""Covers MCP prompts/get behavior using native request and response objects."""
|
||||||
@@ -27,14 +37,15 @@ class TestMcpPromptSurface:
|
|||||||
async def test_gets_prompt_as_native_object(self, mcp_session_factory) -> None:
|
async def test_gets_prompt_as_native_object(self, mcp_session_factory) -> None:
|
||||||
"""Ensures prompts/get resolves a listed prompt into structured message objects."""
|
"""Ensures prompts/get resolves a listed prompt into structured message objects."""
|
||||||
async with mcp_session_factory() as mcp_session:
|
async with mcp_session_factory() as mcp_session:
|
||||||
listed_prompts = await mcp_session.list_prompts()
|
|
||||||
prompt = listed_prompts.prompts[0]
|
|
||||||
arguments = {arg.name: "test" for arg in (prompt.arguments or []) if arg.required}
|
|
||||||
|
|
||||||
resolved_prompt = await mcp_session.get_prompt(
|
resolved_prompt = await mcp_session.get_prompt(
|
||||||
name=prompt.name,
|
name="authoring",
|
||||||
arguments=arguments or None,
|
arguments={
|
||||||
|
"artifact_type": "skill",
|
||||||
|
"artifact_id": "demo-skill",
|
||||||
|
"goal": "Demonstrate native prompt rendering.",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resolved_prompt.messages
|
assert resolved_prompt.messages
|
||||||
assert all(message.content for message in resolved_prompt.messages)
|
assert all(message.content for message in resolved_prompt.messages)
|
||||||
|
assert "`artifact_id`: demo-skill" in resolved_prompt.messages[0].content.text
|
||||||
|
|||||||
@@ -6,41 +6,10 @@ import pytest
|
|||||||
|
|
||||||
pytestmark = pytest.mark.smoke
|
pytestmark = pytest.mark.smoke
|
||||||
|
|
||||||
RETIRED_TOOL_NAMES = {
|
|
||||||
"search_patterns",
|
|
||||||
"get_pattern_by_id",
|
|
||||||
"get_skill_document_by_id",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestMcpSkillsSurface:
|
class TestMcpSkillsSurface:
|
||||||
"""Covers native skill resources over the HTTP MCP surface."""
|
"""Covers native skill resources over the HTTP MCP surface."""
|
||||||
|
|
||||||
class TestTools:
|
|
||||||
"""Covers generic resource fallback tools for native skills."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_lists_resource_fallback_tools(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures generic resource tools replace skill-specific catalog tools."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.list_tools()
|
|
||||||
tool_names = {tool.name for tool in result.tools}
|
|
||||||
|
|
||||||
assert {"list_resources", "read_resource"}.issubset(tool_names)
|
|
||||||
assert RETIRED_TOOL_NAMES.isdisjoint(tool_names)
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_reads_skill_through_fallback_tool(self, mcp_session_factory) -> None:
|
|
||||||
"""Ensures tool-only clients can read a native skill resource."""
|
|
||||||
async with mcp_session_factory() as mcp_session:
|
|
||||||
result = await mcp_session.call_tool(
|
|
||||||
"read_resource",
|
|
||||||
{"uri": "skill://mcp-details/SKILL.md"},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result.isError is False
|
|
||||||
assert result.content
|
|
||||||
|
|
||||||
class TestResources:
|
class TestResources:
|
||||||
"""Covers native skill resources, manifests, and file templates."""
|
"""Covers native skill resources, manifests, and file templates."""
|
||||||
|
|
||||||
@@ -53,17 +22,15 @@ class TestMcpSkillsSurface:
|
|||||||
|
|
||||||
assert "skill://mcp-details/SKILL.md" in resource_uris
|
assert "skill://mcp-details/SKILL.md" in resource_uris
|
||||||
assert "skill://mcp-details/_manifest" in resource_uris
|
assert "skill://mcp-details/_manifest" in resource_uris
|
||||||
assert "resource://catalog/skills_index" not in resource_uris
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_lists_resource_templates(self, mcp_session_factory) -> None:
|
async def test_lists_resource_templates(self, mcp_session_factory) -> None:
|
||||||
"""Ensures supporting files use per-skill wildcard templates."""
|
"""Ensures supporting files use per-skill wildcard templates."""
|
||||||
async with mcp_session_factory() as mcp_session:
|
async with mcp_session_factory() as mcp_session:
|
||||||
result = await mcp_session.list_resource_templates()
|
result = await mcp_session.list_resource_templates()
|
||||||
template_uris = {template.uriTemplate for template in result.resourceTemplates}
|
template_uris = {template.uri_template for template in result.resource_templates}
|
||||||
|
|
||||||
assert "skill://mcp-details/{path*}" in template_uris
|
assert "skill://mcp-details/{path*}" in template_uris
|
||||||
assert "resource://skills/{skill_id}/document" not in template_uris
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_reads_manifest_and_supporting_file(self, mcp_session_factory) -> None:
|
async def test_reads_manifest_and_supporting_file(self, mcp_session_factory) -> None:
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
"python_full_version >= '3.14'",
|
||||||
"python_full_version >= '3.14' and sys_platform != 'win32'",
|
"python_full_version < '3.14'",
|
||||||
"python_full_version < '3.14' and sys_platform == 'win32'",
|
|
||||||
"python_full_version < '3.14' and sys_platform != 'win32'",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[manifest]
|
||||||
|
constraints = [{ name = "fastmcp-slim", specifier = "==4.0.0b1" }]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiofile"
|
name = "aiofile"
|
||||||
version = "3.12.3"
|
version = "3.12.3"
|
||||||
@@ -130,15 +131,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2026.7.22"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cffi"
|
name = "cffi"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@@ -515,21 +507,22 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp"
|
name = "fastmcp"
|
||||||
version = "3.4.6"
|
version = "4.0.0b1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
{ name = "fastmcp-slim", extra = ["client", "server"] },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/5a/e2c78e26233cd8a416b21513e1925435d54c008a0ec467dbdaa80369daf7/fastmcp-3.4.6.tar.gz", hash = "sha256:2287938da8364ad7071bec2d2393af6ae10fd4e836f06f506569f1456cc87eb4", size = 28808130, upload-time = "2026-08-05T14:54:42.177Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/eb/fd/e513c524bb3e296203f65bd8e9e726e9236bd3b59315e7cbdeb095662c01/fastmcp-4.0.0b1.tar.gz", hash = "sha256:f98d69588a73e1672840558641d5d0f111e207baffe001f3465713a53ebb6b4c", size = 42065171, upload-time = "2026-07-28T21:18:15.312Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/a5/c02275db111892388972edbb05fbcbfdf1e83cbd1fd03356b3a49b93f839/fastmcp-3.4.6-py3-none-any.whl", hash = "sha256:2a29967be9f68cdd1b4cefb413ede74f83adefd923919a37aaac3611eccdd749", size = 8017, upload-time = "2026-08-05T14:54:38.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/66/41b503ef852eff83f3f0c04f46cd1fc738c5374fd509384fc6b96e45918f/fastmcp-4.0.0b1-py3-none-any.whl", hash = "sha256:d66eb7b0763ffff2ae0fc573778ea25604dcb7e59769e5afaf9851a806eb1129", size = 8064, upload-time = "2026-07-28T21:18:12.688Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastmcp-slim"
|
name = "fastmcp-slim"
|
||||||
version = "3.4.6"
|
version = "4.0.0b1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "mcp-types" },
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "pydantic", extra = ["email"] },
|
{ name = "pydantic", extra = ["email"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -537,16 +530,16 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/b6/b5b9e81e67a3f39534881d2af6aa3fbd2dc367eaa070c3c932770a0c062f/fastmcp_slim-3.4.6.tar.gz", hash = "sha256:6a1e6e42c697ba90abcb1be617a26947d07316f13c6fa138ae7b0de24558e32c", size = 594167, upload-time = "2026-08-05T14:54:15.924Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/bc/33/f207166aac88c6d8be1b2a82c54fecd1e04b075ef12d027aa17b3134fc0f/fastmcp_slim-4.0.0b1.tar.gz", hash = "sha256:158efb25720e0cb301711146b2d05d181de95cd91fe70e87c251ad14b123d665", size = 660081, upload-time = "2026-07-28T21:17:50.171Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/0b/02c254c46b4323ae5262b76a29af73b50a863efc5739a3454d144d3605ee/fastmcp_slim-3.4.6-py3-none-any.whl", hash = "sha256:3e08e6acb03523a47aa17f4d2ab9943e648a41e20b24084da491a732c46dffdc", size = 769174, upload-time = "2026-08-05T14:54:14.663Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/5d/fbe192d2ab50bb31b284fd0eb6c72d4347df7a57d836bee4f1973ffd1d7d/fastmcp_slim-4.0.0b1-py3-none-any.whl", hash = "sha256:dd907a3db5a2f479ca958c30157e227b4f7b340a56d333eb91de95719254597a", size = 827975, upload-time = "2026-07-28T21:17:48.747Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
client = [
|
client = [
|
||||||
{ name = "authlib" },
|
{ name = "authlib" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
{ name = "mcp" },
|
{ name = "mcp" },
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
|
||||||
@@ -557,7 +550,7 @@ server = [
|
|||||||
{ name = "cyclopts" },
|
{ name = "cyclopts" },
|
||||||
{ name = "exceptiongroup" },
|
{ name = "exceptiongroup" },
|
||||||
{ name = "griffelib" },
|
{ name = "griffelib" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
{ name = "joserfc" },
|
{ name = "joserfc" },
|
||||||
{ name = "jsonref" },
|
{ name = "jsonref" },
|
||||||
{ name = "jsonschema-path" },
|
{ name = "jsonschema-path" },
|
||||||
@@ -604,16 +597,16 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore"
|
name = "httpcore2"
|
||||||
version = "1.0.9"
|
version = "2.9.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
|
{ name = "truststore" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -653,27 +646,19 @@ wheels = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx"
|
name = "httpx2"
|
||||||
version = "0.28.1"
|
version = "2.9.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "certifi" },
|
{ name = "httpcore2" },
|
||||||
{ name = "httpcore" },
|
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
|
{ name = "truststore" },
|
||||||
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" },
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx-sse"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1005,15 +990,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mcp"
|
name = "mcp"
|
||||||
version = "1.29.0"
|
version = "2.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "httpx" },
|
{ name = "httpx2" },
|
||||||
{ name = "httpx-sse" },
|
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
|
{ name = "mcp-types" },
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
|
||||||
{ name = "pyjwt", extra = ["crypto"] },
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||||
@@ -1023,9 +1008,22 @@ dependencies = [
|
|||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/74/33/32d4dff2c95bb5d897c3ef4c83649a08996b17b58f0a326d2495d4c81179/mcp-2.0.0.tar.gz", hash = "sha256:0f440e735c13ece8bb19bc62cf0b86f4313448432fbb77d35e14034f4e050728", size = 1662284, upload-time = "2026-07-28T13:45:32.346Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/72/7d7897418912c1d12e87556630dfb7bf0eac71160e9bef8b447960804ee3/mcp-2.0.0-py3-none-any.whl", hash = "sha256:1cb4c75d2d2c7b8c1d756355e5d82a39f2822cc7f13e22a2051d7ca3592349d6", size = 349980, upload-time = "2026-07-28T13:45:28.853Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mcp-types"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/bb/56/9b8e1c152f61f6c6b07c4b5896c88c7d0ae90bac6ee6306f852fcc5c1eb0/mcp_types-2.0.0.tar.gz", hash = "sha256:d7d939b9285c9961ae8866ba75ef85da34d12bafe276efbf4eb6a131786d8379", size = 66632, upload-time = "2026-07-28T13:45:33.804Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/4c/c78d78c3d52b0ac594ad7cc8ef5972adfe070e3597a8a4c6ce0cd39196ea/mcp_types-2.0.0-py3-none-any.whl", hash = "sha256:6b2de797ca2797f568b79529e1b25948e34de511bcc0bd82fef1039a6d1b8eb0", size = 69649, upload-time = "2026-07-28T13:45:30.713Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1166,14 +1164,13 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prompts"
|
name = "prompts"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "fastmcp" },
|
{ name = "fastmcp" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-json-logger" },
|
{ name = "python-json-logger" },
|
||||||
{ name = "pyyaml" },
|
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
{ name = "zensical" },
|
{ name = "zensical" },
|
||||||
]
|
]
|
||||||
@@ -1186,18 +1183,19 @@ dev = [
|
|||||||
{ name = "ty" },
|
{ name = "ty" },
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
|
{ name = "httpx2" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
{ name = "pytest-cov" },
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.115.0" },
|
{ name = "fastapi", specifier = ">=0.133.0" },
|
||||||
{ name = "fastmcp", specifier = ">=3.4.4" },
|
{ name = "fastmcp", specifier = "==4.0.0b1" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2" },
|
{ name = "pydantic-settings", specifier = ">=2" },
|
||||||
{ name = "python-json-logger", specifier = ">=4" },
|
{ name = "python-json-logger", specifier = ">=4" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.2" },
|
|
||||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
|
||||||
{ name = "zensical", specifier = ">=0.0.45" },
|
{ name = "zensical", specifier = ">=0.0.45" },
|
||||||
]
|
]
|
||||||
@@ -1210,9 +1208,11 @@ dev = [
|
|||||||
{ name = "ty", specifier = ">=0.0.51" },
|
{ name = "ty", specifier = ">=0.0.51" },
|
||||||
]
|
]
|
||||||
test = [
|
test = [
|
||||||
|
{ name = "httpx2", specifier = ">=2.9.1" },
|
||||||
{ name = "pytest", specifier = ">=9.1.1" },
|
{ name = "pytest", specifier = ">=9.1.1" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.4.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.2" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1873,6 +1873,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/66/0d785f0bc5e4315a96c989bb476d0fc07ea4f85132550c7b156ca2035d52/traitlets-5.16.1-py3-none-any.whl", hash = "sha256:f775618166caa0396c8e337099240f2bd3e5e917d203b2e6fbe21a58d3cb1f6b", size = 86211, upload-time = "2026-08-03T08:32:34.48Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "truststore"
|
||||||
|
version = "0.10.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ty"
|
name = "ty"
|
||||||
version = "0.0.69"
|
version = "0.0.69"
|
||||||
|
|||||||
Reference in New Issue
Block a user