Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ca1a65c2 | ||
|
|
94dd47cc19 | ||
|
|
b3d4e55a15 | ||
|
|
7b2b80ecf2 | ||
|
|
d4ca78dbfb | ||
|
|
eeeb6ecdbe | ||
|
|
0177496fab | ||
|
|
00498a2fed | ||
|
|
913ba66d8b | ||
|
|
e2c199c1b7 | ||
|
|
45a1e56d1c | ||
|
|
a6ccc14917 | ||
|
|
a0ae38d0cc | ||
|
|
34d3808bbb | ||
|
|
1cfe9c8e40 | ||
|
|
313c4ecb1e | ||
|
|
ea5450f6cb | ||
|
|
ab53c239bf | ||
|
|
35d7fa1718 | ||
|
|
e93462ec3b | ||
|
|
58a94ad9b6 | ||
|
|
c893173fcc | ||
|
|
123c491413 | ||
|
|
3c7f7e61b7 | ||
|
|
76ea9ebbda | ||
|
|
0aa7ace272 | ||
|
|
4da2b0ac83 | ||
|
|
806bb15bcc | ||
|
|
ff7cd4a07f | ||
|
|
3603471699 | ||
|
|
dcbf570a13 | ||
|
|
d3b336f1e3 | ||
|
|
1f7e63267a | ||
|
|
69cd9037a3 | ||
|
|
b9bb11ac02 | ||
|
|
4958eeb3ef | ||
|
|
5a31ba6390 | ||
|
|
36347ff4a5 | ||
|
|
c189677717 | ||
|
|
37fa9b6c6f | ||
|
|
34923b51d7 | ||
|
|
2d65d83162 | ||
|
|
3a6e2665dd | ||
|
|
b98d8b782a | ||
|
|
36032040ae | ||
|
|
4f05f13e45 | ||
|
|
57347077a9 | ||
|
|
7fec3a4337 | ||
|
|
c5b7733528 | ||
|
|
3c5db37223 | ||
|
|
29130c3a0c | ||
|
|
aec3500370 | ||
|
|
9c8ab70c06 | ||
|
|
9a9432cc55 | ||
|
|
4320a251f5 | ||
|
|
caa4a5079a | ||
|
|
993dc6a879 | ||
|
|
dab539489a | ||
|
|
197fa32f2c | ||
|
|
c653c7024b | ||
|
|
82b50fb63b | ||
|
|
f8e0c14d46 | ||
|
|
098a2418ee | ||
|
|
7f672b9c8f | ||
|
|
3c5efc6018 | ||
|
|
0b2d45d419 | ||
|
|
406fd63a07 | ||
|
|
323f02102d | ||
|
|
906bba427b | ||
|
|
06d5fc18f2 | ||
|
|
38edc4ac36 | ||
|
|
c73771c2f4 | ||
|
|
33144da02f | ||
|
|
0a9dadd5a8 |
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
name: Pytest Scaffolding Guidance
|
||||||
|
description: Route tests edits to the Personal MCP pytesting resource.
|
||||||
|
applyTo: 'tests/**'
|
||||||
|
---
|
||||||
|
|
||||||
|
When editing files under `tests/`, use `resource://skills/pytesting/document` as the primary guidance source for test scaffolding and pytest authoring decisions.
|
||||||
|
|
||||||
|
Execution pattern:
|
||||||
|
|
||||||
|
1. Load `resource://skills/pytesting/document` first.
|
||||||
|
2. Apply only the portions relevant to the file being edited.
|
||||||
|
3. Keep tests focused, deterministic, and aligned with repository conventions.
|
||||||
|
4. Include source-document links for any feature-level recommendation.
|
||||||
|
|
||||||
|
If task intent is ambiguous, ask one clarifying question before editing.
|
||||||
|
|
||||||
|
Be sure to also refer to the [testing page](../../docs/testing.md) page for design detail
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: VS Code Configuration
|
||||||
|
description: Route .vscode edits to the Personal MCP VS Code configuration skill resource.
|
||||||
|
applyTo: '.vscode/**'
|
||||||
|
---
|
||||||
|
|
||||||
|
When editing files under `.vscode/`, use `resource://skills/vscode-configuration/document` as the primary guidance source.
|
||||||
|
|
||||||
|
Execution pattern:
|
||||||
|
|
||||||
|
1. Load `resource://skills/vscode-configuration/document` first.
|
||||||
|
2. Select only the matching reference page for the current file type:
|
||||||
|
- `launch.json` -> debug launch configurations.
|
||||||
|
- `tasks.json` -> tasks.json project tasks.
|
||||||
|
- `mcp.json` -> mcp.json MCP server configuration.
|
||||||
|
3. Prefer the smallest safe config change and keep settings explicit.
|
||||||
|
4. Include source-document links for any feature-level recommendation.
|
||||||
|
|
||||||
|
If task intent is ambiguous, ask one clarifying question before editing.
|
||||||
@@ -7,3 +7,7 @@ applyTo: '**/*.md'
|
|||||||
When editing Markdown files in this repository, use the Zensical docs resource `resource://skills/zensical-docs/document` for relevant documentation authoring guidance.
|
When editing Markdown files in this repository, use the Zensical docs resource `resource://skills/zensical-docs/document` for relevant documentation authoring guidance.
|
||||||
|
|
||||||
Prefer Zensical-native documentation conventions when they cover the need cleanly, while preserving expected MkDocs compatibility unless the Zensical guidance intentionally diverges.
|
Prefer Zensical-native documentation conventions when they cover the need cleanly, while preserving expected MkDocs compatibility unless the Zensical guidance intentionally diverges.
|
||||||
|
|
||||||
|
Always check to make sure the entries in `/home/john/Documents/prompts/zensical.toml` are up to date with any changes.
|
||||||
|
|
||||||
|
Also ensure that top-level pages specify icons in their front matter
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ Create a docs-first FastMCP architecture where all Markdown remains in docs/ as
|
|||||||
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.
|
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.
|
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.
|
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. Depends on step 5.
|
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.
|
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.
|
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.
|
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.
|
||||||
@@ -31,7 +31,7 @@ Create a docs-first FastMCP architecture where all Markdown remains in docs/ as
|
|||||||
2. Run uv run pytest -q with tests that validate frontmatter parsing, URI generation, reference mapping, and catalog responses.
|
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.
|
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.
|
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.
|
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**
|
**Decisions**
|
||||||
- Anthropic compatibility: strict skill directory pattern with SKILL.md and references subtree.
|
- Anthropic compatibility: strict skill directory pattern with SKILL.md and references subtree.
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ Example valid ids:
|
|||||||
|
|
||||||
- `fastapi-uv-docker`
|
- `fastapi-uv-docker`
|
||||||
- `zensical-docs`
|
- `zensical-docs`
|
||||||
- `pytest-scaffolding`
|
- `pytesting`
|
||||||
|
|
||||||
Example invalid ids:
|
Example invalid ids:
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,41 @@ Normative conclusions from those sources:
|
|||||||
3. Resources and tools must resolve to the same canonical authored markdown.
|
3. Resources and tools must resolve to the same canonical authored markdown.
|
||||||
4. Fallback behavior should keep context bounded and deterministic.
|
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)
|
### Discovery Priority Contract (Normative)
|
||||||
|
|
||||||
Preferred sequence for skill discovery and loading:
|
Preferred sequence for skill discovery and loading:
|
||||||
@@ -58,6 +93,19 @@ The fallback tool surface includes:
|
|||||||
4. `get_pattern_by_id`
|
4. `get_pattern_by_id`
|
||||||
5. `get_skill_document_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:
|
Fallback order:
|
||||||
|
|
||||||
1. call `list_resources` to inspect canonical static/template resource surfaces
|
1. call `list_resources` to inspect canonical static/template resource surfaces
|
||||||
@@ -70,6 +118,40 @@ Tool behavior requirements:
|
|||||||
2. deterministic ordering and bounded pagination
|
2. deterministic ordering and bounded pagination
|
||||||
3. explicit not-found responses (`found: false` style) where applicable
|
3. explicit not-found responses (`found: false` style) where applicable
|
||||||
4. payloads remain schema-aligned with catalog resources
|
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
|
### Resources-As-Tools Compatibility Layer
|
||||||
|
|
||||||
|
|||||||
Vendored
+85
@@ -0,0 +1,85 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Ruff: Check",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"ruff",
|
||||||
|
"check",
|
||||||
|
"."
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Ty: Check",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"ty",
|
||||||
|
"check"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Docs: Build",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"zensical",
|
||||||
|
"build"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"group": "build",
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Server: Run (uvicorn)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "uv",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"uvicorn",
|
||||||
|
"personal_mcp.main:app",
|
||||||
|
"--host",
|
||||||
|
"127.0.0.1",
|
||||||
|
"--port",
|
||||||
|
"8000",
|
||||||
|
"--reload"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Docker: Compose Up (Build)",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"compose",
|
||||||
|
"up",
|
||||||
|
"--build",
|
||||||
|
"-d"
|
||||||
|
],
|
||||||
|
"options": {
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
},
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+38
-15
@@ -16,6 +16,8 @@ The system is complete in three layers:
|
|||||||
2. Catalog resources provide normalized discovery.
|
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.
|
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.
|
||||||
|
|
||||||
This architecture is anchored by three contracts:
|
This architecture is anchored by three contracts:
|
||||||
|
|
||||||
1. Docs-first authored content contract under `docs/` with strict per-skill ownership.
|
1. Docs-first authored content contract under `docs/` with strict per-skill ownership.
|
||||||
@@ -24,9 +26,9 @@ This architecture is anchored by three contracts:
|
|||||||
|
|
||||||
Detailed contract pages:
|
Detailed contract pages:
|
||||||
|
|
||||||
1. [Content Contract](./content.md)
|
1. [Content Contract](./contracts/index.md#content-contract)
|
||||||
2. [Frontmatter Contract](./frontmatter.md)
|
2. [Frontmatter Contract](./contracts/frontmatter.md)
|
||||||
3. [URI Contract](./uris.md)
|
3. [URI Contract](./contracts/uris.md)
|
||||||
|
|
||||||
This architecture keeps authored content human-friendly while preserving machine-stable contracts.
|
This architecture keeps authored content human-friendly while preserving machine-stable contracts.
|
||||||
|
|
||||||
@@ -55,6 +57,17 @@ Each skill publishes resource families:
|
|||||||
|
|
||||||
The document resource returns canonical Markdown, while clients can perform any downstream section extraction they need.
|
The document resource returns canonical Markdown, while clients can perform any downstream section extraction they need.
|
||||||
|
|
||||||
|
### Prompt Modules
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Prompt modules publish two additive surfaces:
|
||||||
|
|
||||||
|
1. prompt resources for catalog and document retrieval
|
||||||
|
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.
|
||||||
|
|
||||||
### Catalog Module
|
### Catalog Module
|
||||||
|
|
||||||
The catalog is the canonical discovery layer and publishes normalized records for all modules. It may also expose a minimal set of read-only discovery tools that resolve back to the same canonical markdown content when a client chat surface does not expose MCP resource attachment.
|
The catalog is the canonical discovery layer and publishes normalized records for all modules. It may also expose a minimal set of read-only discovery tools that resolve back to the same canonical markdown content when a client chat surface does not expose MCP resource attachment.
|
||||||
@@ -62,7 +75,11 @@ The catalog is the canonical discovery layer and publishes normalized records fo
|
|||||||
Typical catalog resources:
|
Typical catalog resources:
|
||||||
|
|
||||||
1. resource://catalog/skills_index
|
1. resource://catalog/skills_index
|
||||||
2. resource://catalog/skills/{skill_id}
|
2. resource://catalog/skills_index{?q,tag,capability,cursor,limit}
|
||||||
|
3. resource://catalog/skills/{skill_id}
|
||||||
|
4. resource://catalog/prompts_index
|
||||||
|
5. resource://catalog/prompts_index{?q,tag,cursor,limit}
|
||||||
|
6. resource://catalog/prompts/{prompt_id}
|
||||||
|
|
||||||
Only canonical catalog resources are part of the runtime contract in this phase.
|
Only canonical catalog resources are part of the runtime contract in this phase.
|
||||||
|
|
||||||
@@ -75,7 +92,7 @@ Loader responsibilities:
|
|||||||
1. Parse SKILL.md frontmatter for each skill.
|
1. Parse SKILL.md frontmatter for each skill.
|
||||||
2. Validate schema and cross-field constraints before any resource registration.
|
2. Validate schema and cross-field constraints before any resource registration.
|
||||||
3. Build an in-memory registry keyed by `skill_id`.
|
3. Build an in-memory registry keyed by `skill_id`.
|
||||||
4. Fail fast for duplicate ids, missing markdown files, broken reference mappings, and invalid `depends_on` values.
|
4. Fail fast for duplicate ids, missing markdown files, and broken reference mappings.
|
||||||
|
|
||||||
Registry load failure is a startup error, not a partial runtime warning.
|
Registry load failure is a startup error, not a partial runtime warning.
|
||||||
|
|
||||||
@@ -110,7 +127,7 @@ flowchart TD
|
|||||||
|
|
||||||
Each skill declares frontmatter in `docs/skills/<skill-id>/SKILL.md`.
|
Each skill declares frontmatter in `docs/skills/<skill-id>/SKILL.md`.
|
||||||
|
|
||||||
For the full field-level contract, validation model, and FastMCP metadata mapping, see [Frontmatter Contract](./frontmatter.md).
|
For the full field-level contract, validation model, and FastMCP metadata mapping, see [Frontmatter Contract](./contracts/frontmatter.md).
|
||||||
|
|
||||||
Anthropic-facing required fields:
|
Anthropic-facing required fields:
|
||||||
|
|
||||||
@@ -123,8 +140,7 @@ Repository indexing metadata is declared in `x-personal-mcp`:
|
|||||||
2. version
|
2. version
|
||||||
3. tags
|
3. tags
|
||||||
4. capabilities
|
4. capabilities
|
||||||
5. depends_on
|
5. optional references map (for nested entries, overrides, and aliases)
|
||||||
6. references map (ref id to relative path and optional metadata)
|
|
||||||
|
|
||||||
No `metadata.yaml` sidecar is part of the end-state contract.
|
No `metadata.yaml` sidecar is part of the end-state contract.
|
||||||
|
|
||||||
@@ -132,18 +148,25 @@ No `metadata.yaml` sidecar is part of the end-state contract.
|
|||||||
|
|
||||||
Canonical resource URIs are:
|
Canonical resource URIs are:
|
||||||
|
|
||||||
For the full URI semantics, parameter validation rules, and compatibility policy, see [URI Contract](./uris.md).
|
For the full URI semantics, parameter validation rules, and compatibility policy, see [URI Contract](./contracts/uris.md).
|
||||||
|
|
||||||
1. resource://skills/<skill_id>/document
|
1. resource://skills/<skill_id>/document
|
||||||
2. resource://skills/<skill_id>/references/<ref_id>
|
2. resource://skills/<skill_id>/references/<ref_id>
|
||||||
3. resource://catalog/skills_index
|
3. resource://catalog/skills_index
|
||||||
4. resource://catalog/skills/{skill_id}
|
4. resource://catalog/skills_index{?q,tag,capability,cursor,limit}
|
||||||
5. resource://docs/{path*}
|
5. resource://catalog/skills/{skill_id}
|
||||||
|
6. resource://docs/{path*}
|
||||||
|
7. resource://catalog/prompts_index
|
||||||
|
8. resource://catalog/prompts_index{?q,tag,cursor,limit}
|
||||||
|
9. resource://catalog/prompts/{prompt_id}
|
||||||
|
10. resource://prompts/{prompt_id}/document
|
||||||
|
|
||||||
Validation rules:
|
Validation rules:
|
||||||
|
|
||||||
1. `skill_id` is lowercase kebab-case and must satisfy the stable skill id contract.
|
1. `skill_id` is lowercase kebab-case and must satisfy the stable skill id contract.
|
||||||
2. `ref_id` is lowercase kebab-case and must be declared in the skill references manifest.
|
2. `ref_id` is lowercase kebab-case and must resolve from either:
|
||||||
|
- top-level auto-discovery of `references/*.md` filename stems, or
|
||||||
|
- an explicit `x-personal-mcp.references` entry.
|
||||||
3. `path*` resolves only to normalized markdown paths under `docs/`.
|
3. `path*` resolves only to normalized markdown paths under `docs/`.
|
||||||
|
|
||||||
### Resource Registration Contract
|
### Resource Registration Contract
|
||||||
@@ -198,7 +221,7 @@ Markdown remains easy to review, while contracts remain stable for clients.
|
|||||||
|
|
||||||
### Client Independence
|
### Client Independence
|
||||||
|
|
||||||
Clients can use Ask, Edit, or Agent modes without requiring server-owned prompt orchestration. However, MCP affordances are still chat-surface-dependent: some clients or sessions expose resource attachment directly, while others make tool invocation the more reliable retrieval path.
|
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
|
## Authoring and Publishing Lifecycle
|
||||||
|
|
||||||
@@ -229,8 +252,8 @@ Allowed exception:
|
|||||||
|
|
||||||
Existing markdown reference sets are valid examples of authored source material for this architecture:
|
Existing markdown reference sets are valid examples of authored source material for this architecture:
|
||||||
|
|
||||||
1. docs/skills/pytest-scaffolding/references/pytest-docs.md
|
1. docs/skills/pytesting/references/pytest-docs.md
|
||||||
2. docs/skills/python-logging-dictconfig/references/python-logging-docs.md
|
2. docs/skills/python-logging/references/python-logging-docs.md
|
||||||
3. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
3. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
||||||
|
|
||||||
These inputs are treated as content sources, while resource URIs and catalog payloads remain the machine-facing contracts.
|
These inputs are treated as content sources, while resource URIs and catalog payloads remain the machine-facing contracts.
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/pencil
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authoring Guide
|
||||||
|
|
||||||
|
This page defines the practical authoring workflow for this repository so Markdown remains the single source of truth for both published docs and MCP resources.
|
||||||
|
|
||||||
|
Primary references:
|
||||||
|
- [Skill contract](./contracts/skill_contract.md)
|
||||||
|
- [Prompt contract](./contracts/prompt.md)
|
||||||
|
- [Frontmatter contract](./contracts/frontmatter.md)
|
||||||
|
- [URI contract](./contracts/uris.md)
|
||||||
|
- [Zensical documentation authoring skill](./skills/zensical-docs/SKILL.md)
|
||||||
|
|
||||||
|
## What You Author
|
||||||
|
|
||||||
|
This repository has two primary authored content types:
|
||||||
|
|
||||||
|
1. Skills under `docs/skills/<skill-id>/`.
|
||||||
|
2. Prompts under `docs/prompts/<prompt-id>/`.
|
||||||
|
|
||||||
|
Each module keeps one canonical document plus optional references:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/
|
||||||
|
skills/<skill-id>/
|
||||||
|
SKILL.md
|
||||||
|
references/
|
||||||
|
*.md
|
||||||
|
|
||||||
|
prompts/<prompt-id>/
|
||||||
|
PROMPT.md
|
||||||
|
references/
|
||||||
|
*.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## Authoring Principles
|
||||||
|
|
||||||
|
1. Keep Markdown as the canonical source and avoid duplicating content into alternate metadata files.
|
||||||
|
2. Prefer resource-first discovery paths (`resource://catalog/...` then `resource://skills/...` or `resource://prompts/...`).
|
||||||
|
3. Keep pages focused and composable: overview in the primary doc, details in `references/`.
|
||||||
|
4. Use descriptive inline links for external sources instead of bare URLs.
|
||||||
|
5. Use stable ids and slugs; renames are breaking changes and should be intentional.
|
||||||
|
|
||||||
|
## Skill Authoring Workflow
|
||||||
|
|
||||||
|
When creating or updating a skill:
|
||||||
|
|
||||||
|
1. Confirm slug format is lowercase kebab-case.
|
||||||
|
2. Keep directory name, `name`, and `x-personal-mcp.id` aligned.
|
||||||
|
3. Ensure capabilities include `resource://skills/<skill-id>/document`.
|
||||||
|
4. Place supporting material under `references/`.
|
||||||
|
5. Use explicit frontmatter reference entries only when you need overrides or nested mappings.
|
||||||
|
|
||||||
|
Recommended sequence:
|
||||||
|
|
||||||
|
1. Draft `SKILL.md` intent and routing sections.
|
||||||
|
2. Add or refine `references/*.md`.
|
||||||
|
3. Verify links and example commands.
|
||||||
|
4. Run docs build and tests.
|
||||||
|
|
||||||
|
For exact metadata rules, see [Frontmatter contract](./contracts/frontmatter.md) and [Skill contract](./contracts/skill_contract.md).
|
||||||
|
|
||||||
|
## Prompt Authoring Workflow
|
||||||
|
|
||||||
|
When creating or updating a prompt module:
|
||||||
|
|
||||||
|
1. Keep one canonical `PROMPT.md`.
|
||||||
|
2. Keep `name`, `x-personal-mcp.id`, and directory slug aligned.
|
||||||
|
3. Include `resource://prompts/<prompt-id>/document` in capabilities.
|
||||||
|
4. Define prompt arguments in `x-personal-mcp.arguments` when inputs are required.
|
||||||
|
5. Keep long rationale and source notes in `references/` to preserve prompt clarity.
|
||||||
|
|
||||||
|
For exact structure, see [Prompt contract](./contracts/prompt.md).
|
||||||
|
|
||||||
|
## Prompt Argument Mechanics
|
||||||
|
|
||||||
|
When defining prompt inputs, keep argument metadata aligned with the prompt contract and runtime behavior.
|
||||||
|
|
||||||
|
1. Define arguments under `x-personal-mcp.arguments` as a map keyed by argument name.
|
||||||
|
2. Argument names must match Python identifier format: `^[A-Za-z_][A-Za-z0-9_]*$`.
|
||||||
|
3. Each argument entry supports only:
|
||||||
|
- `title` (optional)
|
||||||
|
- `description` (optional)
|
||||||
|
- `required` (optional, defaults to `false`)
|
||||||
|
4. Unknown argument fields are rejected by strict frontmatter validation.
|
||||||
|
5. Prompt argument metadata appears in `resource://catalog/prompts/{prompt_id}`, and MCP prompt objects expose the same arguments for prompt-list/get-prompt workflows.
|
||||||
|
6. Enum-like constraints are not a native argument field; encode allowed values in `description`.
|
||||||
|
|
||||||
|
### Frontmatter Safety Rules
|
||||||
|
|
||||||
|
Use these rules to avoid YAML parse failures in prompt and skill frontmatter:
|
||||||
|
|
||||||
|
1. Quote any scalar value that contains `:` (for example, `description: "Enum: skill | prompt | shim"`).
|
||||||
|
2. Prefer quoted scalars for values with reserved YAML characters such as `#`, `{}`, `[]`, or leading `*`.
|
||||||
|
3. If a description needs multiple lines, use a block scalar (`|`) instead of packing punctuation-heavy text into one line.
|
||||||
|
4. Keep frontmatter keys simple and contract-bound; do not add undeclared argument fields.
|
||||||
|
|
||||||
|
### Validation Timing
|
||||||
|
|
||||||
|
Run validation immediately after frontmatter edits, not only at the end of a task:
|
||||||
|
|
||||||
|
1. First pass after metadata changes: `uv run zensical build`
|
||||||
|
2. Prompt/skill load verification: `uv run pytest -q`
|
||||||
|
3. Final full pass before completion: run the full checklist in [Validation Checklist](#validation-checklist)
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
x-personal-mcp:
|
||||||
|
arguments:
|
||||||
|
artifact_type:
|
||||||
|
title: Artifact type
|
||||||
|
description: Allowed values are skill, prompt, or shim.
|
||||||
|
required: true
|
||||||
|
scope_glob:
|
||||||
|
title: Scope glob
|
||||||
|
description: Optional applyTo glob for shim outputs.
|
||||||
|
required: false
|
||||||
|
```
|
||||||
|
|
||||||
|
References:
|
||||||
|
|
||||||
|
1. [Frontmatter contract](./contracts/frontmatter.md)
|
||||||
|
2. [URI contract](./contracts/uris.md)
|
||||||
|
3. [Resource-First Pattern Module Architecture](./architecture.md)
|
||||||
|
4. [Prompt objects concept docs](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts)
|
||||||
|
|
||||||
|
## Writing Quality Rules
|
||||||
|
|
||||||
|
Apply these defaults to all docs pages:
|
||||||
|
|
||||||
|
1. Prefer short sections with strong headings over long unbroken prose.
|
||||||
|
2. Keep claims source-linked, especially for MCP, FastMCP, pytest, FastAPI, SQLAlchemy, and Zensical behavior.
|
||||||
|
3. Prefer relative links for internal docs paths.
|
||||||
|
4. Use code blocks for commands and configuration snippets.
|
||||||
|
5. Keep examples minimal and actionable.
|
||||||
|
|
||||||
|
Source examples:
|
||||||
|
- [Model Context Protocol docs](https://modelcontextprotocol.io/docs/getting-started/intro)
|
||||||
|
- [FastMCP docs](https://gofastmcp.com/getting-started/welcome)
|
||||||
|
- [Zensical docs](https://zensical.org/docs/)
|
||||||
|
|
||||||
|
## Authoring for GitHub Copilot
|
||||||
|
|
||||||
|
For resource selection or tool-based matching to work well, each skill should have:
|
||||||
|
|
||||||
|
1. precise `description`
|
||||||
|
2. focused `tags`
|
||||||
|
3. explicit `capabilities`
|
||||||
|
4. stable `id` and slug naming
|
||||||
|
|
||||||
|
Weak metadata reduces Copilot match quality and increases wrong context injection.
|
||||||
|
|
||||||
|
### Copilot Instruction Authoring Pattern
|
||||||
|
|
||||||
|
If you want Copilot to use `personal-mcp` skill content more reliably, instruction files should describe three things clearly:
|
||||||
|
|
||||||
|
1. when MCP-backed skill guidance is relevant
|
||||||
|
2. which retrieval path Copilot should prefer first
|
||||||
|
3. how much skill context it should load before answering
|
||||||
|
|
||||||
|
Instructions strongly steer discovery behavior, but they do not force VS Code to auto-attach MCP resources. Keep wording explicit about preferred path and fallback path.
|
||||||
|
|
||||||
|
Repository policy:
|
||||||
|
|
||||||
|
1. start from catalog discovery
|
||||||
|
2. prefer MCP resources when the current chat surface exposes resource attachment
|
||||||
|
3. fall back to catalog tools when resource attachment is unavailable
|
||||||
|
4. keep loaded skill context bounded
|
||||||
|
|
||||||
|
Suggested instruction text:
|
||||||
|
|
||||||
|
```md
|
||||||
|
When a task may match a documented implementation pattern from `personal-mcp`:
|
||||||
|
|
||||||
|
1. Start with catalog-first discovery.
|
||||||
|
2. Prefer MCP resources when the chat surface exposes resource attachment.
|
||||||
|
3. If MCP resource attachment is unavailable, use `list_resources`/`read_resource` first, then thin catalog tools if needed.
|
||||||
|
4. Load only the most relevant skill document, or at most 2 skill documents.
|
||||||
|
5. Reconcile loaded skill guidance with the actual repository code before making changes.
|
||||||
|
|
||||||
|
Preferred resource order:
|
||||||
|
|
||||||
|
1. `resource://catalog/skills_index`
|
||||||
|
2. `resource://catalog/skills/{skill_id}`
|
||||||
|
3. `resource://skills/<skill-id>/document`
|
||||||
|
4. `resource://skills/<skill-id>/references/<ref-id>` when needed
|
||||||
|
|
||||||
|
Preferred tool fallback order:
|
||||||
|
|
||||||
|
1. `list_resources`
|
||||||
|
2. `read_resource`
|
||||||
|
3. `search_patterns`
|
||||||
|
4. `get_pattern_by_id`
|
||||||
|
5. `get_skill_document_by_id`
|
||||||
|
|
||||||
|
Compatibility aliases for clients that use `catalog_*` naming are also available:
|
||||||
|
|
||||||
|
1. `catalog_search_patterns`
|
||||||
|
2. `catalog_get_pattern_by_id`
|
||||||
|
3. `catalog_get_skill_document_by_id`
|
||||||
|
4. `catalog_search_prompts`
|
||||||
|
5. `catalog_get_prompt_by_id`
|
||||||
|
|
||||||
|
Use canonical names first; aliases exist only to preserve interoperability when a client emits non-canonical names.
|
||||||
|
|
||||||
|
If confidence is low after discovery, ask one clarifying question before loading more context.
|
||||||
|
```
|
||||||
|
|
||||||
|
This is guidance, not a guarantee. It defines a reliable policy while preserving the resource-first architecture.
|
||||||
|
|
||||||
|
Thin shim path binding guidance for MCP consumers is covered in [Skill Usage Mechanics](./usage.md).
|
||||||
|
|
||||||
|
## Zensical Details
|
||||||
|
|
||||||
|
When adding or restructuring pages:
|
||||||
|
|
||||||
|
1. Update navigation in `zensical.toml`.
|
||||||
|
2. Ensure top-level pages include frontmatter with an icon.
|
||||||
|
3. Keep naming and labels concise so navigation remains scannable.
|
||||||
|
|
||||||
|
Top-level page pattern:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
icon: lucide/pencil
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Validation Checklist
|
||||||
|
|
||||||
|
Run these checks before considering authoring changes complete:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run zensical build
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ty check
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Address any errors or warnings that result.
|
||||||
|
|
||||||
|
If a change only affects docs content, `uv run zensical build` is still required.
|
||||||
|
|
||||||
|
## Quick Authoring Checklist
|
||||||
|
|
||||||
|
1. Correct location (`skills/` or `prompts/`).
|
||||||
|
2. Frontmatter id and slug alignment.
|
||||||
|
3. Capability URI present.
|
||||||
|
4. Links valid and descriptive.
|
||||||
|
5. Navigation updated when needed.
|
||||||
|
6. Validation commands passed.
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
---
|
|
||||||
icon: lucide/file-text
|
|
||||||
---
|
|
||||||
|
|
||||||
# Content Contract
|
|
||||||
|
|
||||||
This page defines the authored content contract for the docs-first MCP architecture.
|
|
||||||
|
|
||||||
## Canonical Source Of Truth
|
|
||||||
|
|
||||||
1. All authored Markdown lives under `docs/`.
|
|
||||||
2. MCP resources and static docs are two distribution surfaces of the same authored files.
|
|
||||||
3. No parallel authored markdown is allowed in `src/` or other package-only paths.
|
|
||||||
|
|
||||||
## Canonical Skill Shape
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. `SKILL.md` is required for every skill.
|
|
||||||
2. `references/` is the only place for skill-specific supporting docs.
|
|
||||||
3. Nested folders inside `references/` are allowed so a skill can reorganize internals without changing global architecture.
|
|
||||||
4. Skill directories are independent ownership boundaries; no cross-skill file writes.
|
|
||||||
|
|
||||||
## File Placement And Ownership Boundaries
|
|
||||||
|
|
||||||
1. Top-level project docs stay in `docs/*.md`.
|
|
||||||
2. Skill docs stay in `docs/skills/<skill-id>/...`.
|
|
||||||
3. A skill may link to other skills, but must not store content inside another skill's directory.
|
|
||||||
4. Server and runtime code may index and serve docs, but must not be the source of authored markdown.
|
|
||||||
|
|
||||||
## Metadata Location Constraint
|
|
||||||
|
|
||||||
1. Skill metadata is embedded in YAML frontmatter in `SKILL.md`.
|
|
||||||
2. No `metadata.yaml` sidecar exists in the end state.
|
|
||||||
3. Reference lookup metadata, including reference id to relative path mappings, is declared from `SKILL.md` frontmatter rather than inferred as a hidden global convention.
|
|
||||||
|
|
||||||
## Skill Id Contract
|
|
||||||
|
|
||||||
`skill-id` is the public identifier and should satisfy all rules below:
|
|
||||||
|
|
||||||
1. Format: lowercase kebab-case only.
|
|
||||||
2. Character set: `a-z`, `0-9`, and `-`.
|
|
||||||
3. Must start with a letter.
|
|
||||||
4. No underscores, spaces, dots, or uppercase characters.
|
|
||||||
5. Directory name should equal `skill-id` in each committed revision.
|
|
||||||
6. Frontmatter `id` should equal directory name in each committed revision.
|
|
||||||
7. Treat `skill-id` as immutable after release; any rename is a breaking replacement and clients must move to the new id.
|
|
||||||
|
|
||||||
Valid examples:
|
|
||||||
|
|
||||||
1. `fastapi-uv-docker`
|
|
||||||
2. `zensical-docs`
|
|
||||||
3. `pytest-scaffolding`
|
|
||||||
|
|
||||||
Invalid examples:
|
|
||||||
|
|
||||||
1. `fastapi_uv_docker`
|
|
||||||
2. `Zensical-Docs`
|
|
||||||
3. `docs.zensical`
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
This contract guarantees:
|
|
||||||
|
|
||||||
1. One authored source tree in `docs/` for both website and MCP.
|
|
||||||
2. One skill directory maps to one skill identity per revision.
|
|
||||||
3. Namespace and slug drift is minimized by keeping directory and frontmatter ids aligned per revision.
|
|
||||||
4. Per-skill reference structure can evolve without changing cross-skill architecture.
|
|
||||||
5. Packaging for stdio is deterministic because authored content is path-stable.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This contract does not define:
|
|
||||||
|
|
||||||
1. URI versioning policy details.
|
|
||||||
2. The full frontmatter schema.
|
|
||||||
3. Migration instructions from the current architecture.
|
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/braces
|
||||||
|
---
|
||||||
|
|
||||||
|
# Frontmatter Contract
|
||||||
|
|
||||||
|
This page defines the `SKILL.md` frontmatter and FastMCP metadata contract.
|
||||||
|
|
||||||
|
Prompt modules use the same contract style in `docs/prompts/<prompt-id>/PROMPT.md` with prompt-specific capability and MCP-aligned prompt argument metadata.
|
||||||
|
|
||||||
|
## Validated Frontmatter Surface
|
||||||
|
|
||||||
|
The registry runtime validates a strict, standard-only frontmatter surface:
|
||||||
|
|
||||||
|
1. Top-level fields accepted for skills: `name`, `description`, `x-personal-mcp`.
|
||||||
|
2. Top-level fields accepted for prompts: `name`, `description`, `x-personal-mcp`.
|
||||||
|
3. Unknown top-level fields are rejected during registry load.
|
||||||
|
|
||||||
|
Skill and prompt identifier rules:
|
||||||
|
|
||||||
|
1. `name` is required, 1-64 chars, lowercase kebab-case, and must not contain `anthropic` or `claude`.
|
||||||
|
2. `description` is required, 1-1024 chars.
|
||||||
|
3. `x-personal-mcp.id` must exactly match `name`.
|
||||||
|
4. Directory slug must exactly match `name`.
|
||||||
|
|
||||||
|
Capability invariants:
|
||||||
|
|
||||||
|
1. Skill capabilities must include `resource://skills/<skill-id>/document`.
|
||||||
|
2. Prompt capabilities must include `resource://prompts/<prompt-id>/document`.
|
||||||
|
|
||||||
|
Repository contract decisions:
|
||||||
|
|
||||||
|
1. Treat `name` and `description` as required in all `SKILL.md` files.
|
||||||
|
2. Keep only validated standard fields at top level.
|
||||||
|
3. Keep MCP indexing metadata in a namespaced extension block.
|
||||||
|
4. Reject unsupported optional top-level fields until explicit model support is added.
|
||||||
|
|
||||||
|
Reference specs:
|
||||||
|
|
||||||
|
1. MCP prompts data types: [Prompts](https://modelcontextprotocol.io/specification/latest/server/prompts)
|
||||||
|
2. MCP schema reference for `Prompt` and `PromptArgument`: [Schema](https://modelcontextprotocol.io/specification/latest/schema)
|
||||||
|
|
||||||
|
## Canonical Frontmatter Schema
|
||||||
|
|
||||||
|
Use this two-layer pattern:
|
||||||
|
|
||||||
|
1. Anthropic layer: top-level fields intended for Anthropic and Agent Skills behavior.
|
||||||
|
2. Repository layer: 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>
|
||||||
|
|
||||||
|
# Repository-specific metadata
|
||||||
|
x-personal-mcp:
|
||||||
|
id: <skill-id>
|
||||||
|
version: <semver>
|
||||||
|
tags:
|
||||||
|
- <tag>
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/<skill-id>/document
|
||||||
|
# Optional: overrides and nested references only.
|
||||||
|
# Top-level references/*.md are auto-discovered.
|
||||||
|
references:
|
||||||
|
<ref-id>:
|
||||||
|
path: references/<file>.md
|
||||||
|
mime_type: text/markdown
|
||||||
|
title: <short title>
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## Repository Metadata Field Rules
|
||||||
|
|
||||||
|
Rules for `x-personal-mcp`:
|
||||||
|
|
||||||
|
1. `id` is required, must follow the skill id rules from the content contract, and must equal the directory name.
|
||||||
|
2. `version` is required and must be a semantic version string.
|
||||||
|
3. `tags` is optional and should be a list of kebab-case discovery labels.
|
||||||
|
4. `capabilities` is required and lists the MCP URIs the skill publishes.
|
||||||
|
5. `references` is an optional map keyed by `ref-id` for overrides and nested entries.
|
||||||
|
|
||||||
|
Prompt-specific additions:
|
||||||
|
|
||||||
|
1. `arguments` is an optional map keyed by argument name.
|
||||||
|
2. Each argument supports optional `title`, optional `description`, and optional `required`.
|
||||||
|
3. This aligns with MCP `PromptArgument` shape (`name`, optional `title`, optional `description`, optional `required`) where `name` is represented by the map key.
|
||||||
|
4. Prompt `capabilities` must include `resource://prompts/<prompt-id>/document`.
|
||||||
|
|
||||||
|
Example prompt frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
---
|
||||||
|
name: initial-test-structure
|
||||||
|
description: Generate a baseline pytest test layout for a target scope.
|
||||||
|
x-personal-mcp:
|
||||||
|
id: initial-test-structure
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- pytest
|
||||||
|
- testing
|
||||||
|
capabilities:
|
||||||
|
- resource://prompts/initial-test-structure/document
|
||||||
|
arguments:
|
||||||
|
target_scope:
|
||||||
|
title: Target scope
|
||||||
|
description: Target package or module under test.
|
||||||
|
required: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference entry rules:
|
||||||
|
|
||||||
|
1. `ref-id` is lowercase kebab-case.
|
||||||
|
2. `path` is a skill-relative markdown path and must stay inside the same skill directory.
|
||||||
|
3. Top-level files under `references/*.md` are auto-discovered with `ref-id` derived from a normalized filename stem (lowercase kebab-case).
|
||||||
|
4. Nested folders under `references/` are not auto-discovered and must be declared explicitly.
|
||||||
|
5. `mime_type` defaults to `text/markdown` when omitted.
|
||||||
|
6. `title` is an optional display label.
|
||||||
|
7. Renaming `ref-id` values is allowed when needed; optional aliases may be used during transitions.
|
||||||
|
|
||||||
|
## Auto-Generated Reference IDs
|
||||||
|
|
||||||
|
Top-level markdown files directly under `references/` are auto-registered as MCP references even when `x-personal-mcp.references` is empty.
|
||||||
|
|
||||||
|
How `ref-id` is derived:
|
||||||
|
|
||||||
|
1. Start from the filename stem (without `.md`).
|
||||||
|
2. Normalize to lowercase kebab-case.
|
||||||
|
3. Publish at `resource://skills/<skill-id>/references/<ref-id>`.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
1. `references/ruff-docs.md` -> `ref-id: ruff-docs`
|
||||||
|
2. `references/Ruff Integrations.md` -> `ref-id: ruff-integrations`
|
||||||
|
3. `references/python_logging_docs.md` -> `ref-id: python-logging-docs`
|
||||||
|
|
||||||
|
When to use explicit `x-personal-mcp.references` entries:
|
||||||
|
|
||||||
|
1. The file is nested, for example `references/guides/ci.md`.
|
||||||
|
2. You need to override defaults (`title`, `mime_type`, or custom `ref-id`).
|
||||||
|
3. You need compatibility aliases during a rename.
|
||||||
|
|
||||||
|
## Validation Models
|
||||||
|
|
||||||
|
The normative runtime model uses strict Pydantic v2 validation:
|
||||||
|
|
||||||
|
1. Models are immutable (`frozen=True`) and reject unknown fields (`extra="forbid"`).
|
||||||
|
2. `SkillFrontmatter` accepts only `name`, `description`, and `x-personal-mcp`.
|
||||||
|
3. `PromptFrontmatter` accepts only `name`, `description`, and `x-personal-mcp`.
|
||||||
|
4. `PromptArgumentEntry` accepts only optional `title`, optional `description`, and optional `required`.
|
||||||
|
5. Skill and prompt metadata enforce semver, kebab-case ids, capability requirements, and id/name/directory consistency.
|
||||||
|
6. Reference paths are validated as markdown files under `references/`.
|
||||||
|
|
||||||
|
Validation behavior contract:
|
||||||
|
|
||||||
|
1. Validate required core fields and relationships during registry load before FastMCP resource or tool registration.
|
||||||
|
2. Reject unknown or unsupported fields at parse and model-validation time.
|
||||||
|
3. Treat hard contract violations, including missing required fields, invalid ids, and broken required mappings, as startup errors.
|
||||||
|
4. Keep failure messages path-aware and field-specific for CI readability.
|
||||||
|
|
||||||
|
Projection mode contract for Anthropic API upload pipelines:
|
||||||
|
|
||||||
|
1. Parse with `SkillFrontmatter` first.
|
||||||
|
2. Emit Anthropic-safe frontmatter with standard fields only.
|
||||||
|
3. Preserve `x-personal-mcp` in source-of-truth documents; projection output is a build artifact.
|
||||||
|
|
||||||
|
## Anthropic Upload Compatibility Rule
|
||||||
|
|
||||||
|
1. Anthropic documentation guarantees behavior for standard frontmatter fields but does not explicitly guarantee handling of arbitrary unknown top-level keys.
|
||||||
|
2. Publishing pipelines that target strict API compatibility should support a projection mode that emits only standard frontmatter fields for upload.
|
||||||
|
3. Source-of-truth authoring remains in `x-personal-mcp`; upload payload shape is an explicit build concern.
|
||||||
|
|
||||||
|
## FastMCP Native Metadata Surfaces
|
||||||
|
|
||||||
|
Resources support native definition metadata:
|
||||||
|
|
||||||
|
1. `name`
|
||||||
|
2. `description`
|
||||||
|
3. `mime_type`
|
||||||
|
4. `tags`
|
||||||
|
5. `annotations`, including `readOnlyHint` and `idempotentHint`
|
||||||
|
6. `icons`
|
||||||
|
7. `meta`
|
||||||
|
8. `version`
|
||||||
|
9. `enabled`, which is deprecated in FastMCP v3 in favor of server-level enable and disable controls
|
||||||
|
|
||||||
|
Resources also support runtime metadata through `ResourceContent.meta` and `ResourceResult.meta`.
|
||||||
|
|
||||||
|
Tools support native definition metadata:
|
||||||
|
|
||||||
|
1. `name`
|
||||||
|
2. `description`
|
||||||
|
3. `tags`
|
||||||
|
4. `annotations`, including `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`
|
||||||
|
5. `icons`
|
||||||
|
6. `meta`
|
||||||
|
7. `version`
|
||||||
|
8. `timeout`
|
||||||
|
9. `output_schema`
|
||||||
|
10. `run_in_thread`
|
||||||
|
11. `enabled`, which is deprecated in FastMCP v3 in favor of server-level enable and disable controls
|
||||||
|
|
||||||
|
Tools also support runtime metadata through `ToolResult.meta`.
|
||||||
|
|
||||||
|
## Frontmatter To FastMCP Mapping Contract
|
||||||
|
|
||||||
|
At server startup, map `x-personal-mcp` into FastMCP registration as follows:
|
||||||
|
|
||||||
|
1. `x-personal-mcp.id` defines the canonical URI namespace and identity checks.
|
||||||
|
2. `description` becomes the default description for the primary skill document resource.
|
||||||
|
3. `x-personal-mcp.tags` maps to resource and tool tags.
|
||||||
|
4. `x-personal-mcp.version` maps to resource and tool version metadata.
|
||||||
|
5. `x-personal-mcp.capabilities` becomes the registered URI list and catalog exposure.
|
||||||
|
6. `x-personal-mcp.references[*]` becomes resource templates or concrete resources with `mime_type`, read-only annotations, and `meta` that includes `skill_id`, `ref_id`, and source `path`.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
This contract guarantees:
|
||||||
|
|
||||||
|
1. Anthropic-required frontmatter stays valid for custom skill upload and Claude Code loading.
|
||||||
|
2. MCP-specific metadata remains embedded in `SKILL.md` frontmatter, with no `metadata.yaml` sidecar.
|
||||||
|
3. FastMCP registration uses native metadata fields for resources and tools.
|
||||||
|
4. Reference ids and metadata can evolve with low-friction updates while internal file layout under `references/` stays refactor-friendly.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This contract does not define:
|
||||||
|
|
||||||
|
1. URI versioning and deprecation rollout policy details.
|
||||||
|
2. Migration script design from existing `metadata.yaml` files.
|
||||||
|
3. Runtime caching and indexing performance tuning.
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/file-check-2
|
||||||
|
---
|
||||||
|
|
||||||
|
# Contracts
|
||||||
|
|
||||||
|
This section groups the core data and contract documents for the repository.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
1. [Prompt Contract](./prompt.md)
|
||||||
|
2. [Skill Contract](./skill_contract.md)
|
||||||
|
3. [Frontmatter Contract](./frontmatter.md)
|
||||||
|
4. [URI Contract](./uris.md)
|
||||||
|
|
||||||
|
Use these pages as the normative source for authored content layout, frontmatter schema, and canonical MCP URI semantics.
|
||||||
|
|
||||||
|
## Content Contract
|
||||||
|
|
||||||
|
This page defines the authored content contract for the docs-first MCP architecture.
|
||||||
|
|
||||||
|
## Canonical Source Of Truth
|
||||||
|
|
||||||
|
1. All authored Markdown lives under `docs/`.
|
||||||
|
2. MCP resources and static docs are two distribution surfaces of the same authored files.
|
||||||
|
3. No parallel authored markdown is allowed in `src/` or other package-only paths.
|
||||||
|
|
||||||
|
## Canonical Content Shape
|
||||||
|
|
||||||
|
Authored content is organized under `docs/`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
---
|
||||||
|
config:
|
||||||
|
treeView:
|
||||||
|
rowIndent: 20
|
||||||
|
lineThickness: 2
|
||||||
|
themeVariables:
|
||||||
|
treeView:
|
||||||
|
labelColor: '#FFFFFF'
|
||||||
|
lineColor: '#FFFFFF'
|
||||||
|
---
|
||||||
|
treeView-beta
|
||||||
|
"docs/"
|
||||||
|
"*.md (top-level docs pages)"
|
||||||
|
"contracts/"
|
||||||
|
"prompt.md"
|
||||||
|
"skill_contract.md"
|
||||||
|
"frontmatter.md"
|
||||||
|
"uris.md"
|
||||||
|
"prompts/"
|
||||||
|
"<prompt-id>/"
|
||||||
|
"PROMPT.md"
|
||||||
|
"skills/"
|
||||||
|
"<skill-id>/"
|
||||||
|
"SKILL.md"
|
||||||
|
"references/..."
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Placement And Ownership Boundaries
|
||||||
|
|
||||||
|
1. Top-level project docs stay in `docs/*.md`.
|
||||||
|
2. Skill docs stay in `docs/skills/<skill-id>/...`.
|
||||||
|
3. Prompt docs stay in `docs/prompts/<prompt-id>/...`.
|
||||||
|
4. A skill or prompt may link across sections, but must not store content in another artifact's directory.
|
||||||
|
5. Server and runtime code may index and serve docs, but must not be the source of authored markdown.
|
||||||
|
|
||||||
|
## Delegated Contracts
|
||||||
|
|
||||||
|
1. Skill-specific directory, metadata, and id rules are defined in [Skill Contract](./skill_contract.md).
|
||||||
|
2. Prompt-specific directory, metadata, and id rules are defined in [Prompt Contract](./prompt.md).
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
This contract guarantees:
|
||||||
|
|
||||||
|
1. One authored source tree in `docs/` for both website and MCP.
|
||||||
|
2. Skill and prompt artifacts remain path-stable within their own sections.
|
||||||
|
3. Cross-surface publishing remains deterministic because authored content paths are canonical.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This contract does not define:
|
||||||
|
|
||||||
|
1. URI versioning policy details.
|
||||||
|
2. The full frontmatter schema.
|
||||||
|
3. Detailed skill rules (see [Skill Contract](./skill_contract.md)).
|
||||||
|
4. Detailed prompt rules (see [Prompt Contract](./prompt.md)).
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/messages-square
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prompt Contract
|
||||||
|
|
||||||
|
This page defines the canonical contract for prompts in the docs-first MCP architecture.
|
||||||
|
|
||||||
|
## Canonical Prompt Shape
|
||||||
|
|
||||||
|
Each prompt is one directory under `docs/prompts/`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
---
|
||||||
|
config:
|
||||||
|
treeView:
|
||||||
|
rowIndent: 20
|
||||||
|
lineThickness: 2
|
||||||
|
themeVariables:
|
||||||
|
treeView:
|
||||||
|
labelColor: '#FFFFFF'
|
||||||
|
lineColor: '#FFFFFF'
|
||||||
|
---
|
||||||
|
treeView-beta
|
||||||
|
"docs/"
|
||||||
|
"... (other docs)"
|
||||||
|
"prompts/"
|
||||||
|
"<prompt-id>/"
|
||||||
|
"PROMPT.md"
|
||||||
|
"references/"
|
||||||
|
"... (one or more markdown files, optional nested folders)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. `PROMPT.md` is required for every prompt.
|
||||||
|
2. `references/` is the only place for prompt-specific supporting docs.
|
||||||
|
3. Nested folders inside `references/` are allowed so a prompt can reorganize internals without changing global architecture.
|
||||||
|
4. Prompt directories are independent ownership boundaries; no cross-prompt file writes.
|
||||||
|
|
||||||
|
## Metadata Location Constraint
|
||||||
|
|
||||||
|
1. Prompt metadata is embedded in YAML frontmatter in `PROMPT.md`.
|
||||||
|
2. No `metadata.yaml` sidecar exists in the end state.
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Prompt Id Contract
|
||||||
|
|
||||||
|
`prompt-id` is the public identifier and should satisfy all rules below:
|
||||||
|
|
||||||
|
1. Format: lowercase kebab-case only.
|
||||||
|
2. Character set: `a-z`, `0-9`, and `-`.
|
||||||
|
3. Must start with a letter.
|
||||||
|
4. No underscores, spaces, dots, or uppercase characters.
|
||||||
|
5. Directory name should equal `prompt-id` in each committed revision.
|
||||||
|
6. Frontmatter `id` should equal directory name in each committed revision.
|
||||||
|
7. Treat `prompt-id` as immutable after release; any rename is a breaking replacement and clients must move to the new id.
|
||||||
|
|
||||||
|
Valid examples:
|
||||||
|
|
||||||
|
1. `pytest-fill-scaffold`
|
||||||
|
2. `review-pr-comments`
|
||||||
|
3. `scaffold-fastapi-service`
|
||||||
|
|
||||||
|
Invalid examples:
|
||||||
|
|
||||||
|
1. `fill_pytest_scaffold`
|
||||||
|
2. `Prompt-Template`
|
||||||
|
3. `docs.prompt`
|
||||||
|
|
||||||
|
## Direct Documentation Inclusion
|
||||||
|
|
||||||
|
1. For direct API documentation, use mkdocstrings directives rather than pasting large code blocks.
|
||||||
|
2. Keep manually-authored code examples short and task-focused; large implementation excerpts are out of scope for this contract.
|
||||||
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/brain-circuit
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill Contract
|
||||||
|
|
||||||
|
This page defines the canonical contract for skills in the docs-first MCP architecture.
|
||||||
|
|
||||||
|
## Canonical Skill Shape
|
||||||
|
|
||||||
|
Each skill is one directory under `docs/skills/`:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
---
|
||||||
|
config:
|
||||||
|
treeView:
|
||||||
|
rowIndent: 20
|
||||||
|
lineThickness: 2
|
||||||
|
themeVariables:
|
||||||
|
treeView:
|
||||||
|
labelColor: '#FFFFFF'
|
||||||
|
lineColor: '#FFFFFF'
|
||||||
|
---
|
||||||
|
treeView-beta
|
||||||
|
"docs/"
|
||||||
|
"... (other docs)"
|
||||||
|
"skills/"
|
||||||
|
"<skill-id>/"
|
||||||
|
"SKILL.md"
|
||||||
|
"references/"
|
||||||
|
"... (one or more markdown files, optional nested folders)"
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
1. `SKILL.md` is required for every skill.
|
||||||
|
2. `references/` is the only place for skill-specific supporting docs.
|
||||||
|
3. Nested folders inside `references/` are allowed so a skill can reorganize internals without changing global architecture.
|
||||||
|
4. Skill directories are independent ownership boundaries; no cross-skill file writes.
|
||||||
|
|
||||||
|
## Metadata Location Constraint
|
||||||
|
|
||||||
|
1. Skill metadata is embedded in YAML frontmatter in `SKILL.md`.
|
||||||
|
2. No `metadata.yaml` sidecar exists in the end state.
|
||||||
|
3. Reference lookup metadata is documented and explicit: top-level `references/*.md` are auto-discovered from filenames, while `SKILL.md` frontmatter declares overrides and nested mappings when needed.
|
||||||
|
|
||||||
|
## Skill Id Contract
|
||||||
|
|
||||||
|
`skill-id` is the public identifier and should satisfy all rules below:
|
||||||
|
|
||||||
|
1. Format: lowercase kebab-case only.
|
||||||
|
2. Character set: `a-z`, `0-9`, and `-`.
|
||||||
|
3. Must start with a letter.
|
||||||
|
4. No underscores, spaces, dots, or uppercase characters.
|
||||||
|
5. Directory name should equal `skill-id` in each committed revision.
|
||||||
|
6. Frontmatter `id` should equal directory name in each committed revision.
|
||||||
|
7. Treat `skill-id` as immutable after release; any rename is a breaking replacement and clients must move to the new id.
|
||||||
|
|
||||||
|
Valid examples:
|
||||||
|
|
||||||
|
1. `fastapi-uv-docker`
|
||||||
|
2. `zensical-docs`
|
||||||
|
3. `pytesting`
|
||||||
|
|
||||||
|
Invalid examples:
|
||||||
|
|
||||||
|
1. `fastapi_uv_docker`
|
||||||
|
2. `Zensical-Docs`
|
||||||
|
3. `docs.zensical`
|
||||||
|
|
||||||
|
## Direct Documentation Inclusion
|
||||||
|
|
||||||
|
1. For direct API documentation, use mkdocstrings directives rather than pasting large code blocks.
|
||||||
|
2. Keep manually-authored code examples short and task-focused; large implementation excerpts are out of scope for this contract.
|
||||||
|
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/link
|
||||||
|
---
|
||||||
|
|
||||||
|
# URI Contract
|
||||||
|
|
||||||
|
This page defines the canonical resource URI contract, template parameter rules, and compatibility policy.
|
||||||
|
|
||||||
|
Conventions in this document follow [MCP resource semantics](https://modelcontextprotocol.io/docs/learn/server-concepts#resources), [URI generic syntax (RFC3986)](https://www.rfc-editor.org/rfc/rfc3986), and [URI templates (RFC6570)](https://www.rfc-editor.org/rfc/rfc6570).
|
||||||
|
|
||||||
|
## Canonical URI Surface
|
||||||
|
|
||||||
|
The public, preferred direct resource 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*}`
|
||||||
|
6. `resource://catalog/prompts_index`
|
||||||
|
7. `resource://catalog/prompts/{prompt_id}`
|
||||||
|
8. `resource://prompts/{prompt_id}/document`
|
||||||
|
|
||||||
|
The public, preferred resource template URIs are:
|
||||||
|
|
||||||
|
1. `resource://catalog/skills_index{?q,tag,capability,cursor,limit}`
|
||||||
|
2. `resource://catalog/prompts_index{?q,tag,cursor,limit}`
|
||||||
|
|
||||||
|
Contract intent:
|
||||||
|
|
||||||
|
1. Catalog URIs are discovery surfaces.
|
||||||
|
2. Skill URIs are the primary per-skill guidance surfaces.
|
||||||
|
3. Catalog query templates are additive discovery helpers for filtering and pagination.
|
||||||
|
4. The docs wildcard URI is a direct authored-markdown access surface under `docs/`.
|
||||||
|
|
||||||
|
Best-practice alignment:
|
||||||
|
|
||||||
|
1. Resource identifiers are stable and noun-oriented.
|
||||||
|
2. Dynamic lookup variants are represented as RFC6570 templates.
|
||||||
|
3. Resources remain read-oriented and are described with explicit MIME types.
|
||||||
|
|
||||||
|
## URI Semantics
|
||||||
|
|
||||||
|
### `resource://catalog/skills_index`
|
||||||
|
|
||||||
|
1. Returns a compact list of skill records for discovery.
|
||||||
|
2. Contains one entry per `skill_id`.
|
||||||
|
3. Includes enough metadata for client-side selection, at minimum `id`, `name`, `description`, `tags`, and `capabilities`.
|
||||||
|
|
||||||
|
### `resource://catalog/skills/{skill_id}`
|
||||||
|
|
||||||
|
1. Returns one normalized record for `skill_id`.
|
||||||
|
2. Includes the canonical document URI and declared reference ids.
|
||||||
|
3. Returns not found when `skill_id` does not exist.
|
||||||
|
|
||||||
|
### `resource://skills/{skill_id}/document`
|
||||||
|
|
||||||
|
1. Returns the canonical `SKILL.md` authored content for that skill.
|
||||||
|
2. `skill_id` must satisfy the stable skill id rules from the content contract.
|
||||||
|
|
||||||
|
### `resource://skills/{skill_id}/references/{ref_id}`
|
||||||
|
|
||||||
|
1. Returns one reference document declared in the skill frontmatter references manifest.
|
||||||
|
2. `ref_id` is the stable public handle for that reference document.
|
||||||
|
|
||||||
|
### `resource://docs/{path*}`
|
||||||
|
|
||||||
|
1. Returns authored markdown at a normalized relative path under `docs/`.
|
||||||
|
2. Supports nested paths via [RFC6570 wildcard expansion](https://www.rfc-editor.org/rfc/rfc6570).
|
||||||
|
3. Typical examples include `index.md`, `usage.md`, `skills/<skill-id>/SKILL.md`, and `skills/<skill-id>/references/<file>.md`.
|
||||||
|
|
||||||
|
### `resource://catalog/prompts_index`
|
||||||
|
|
||||||
|
1. Returns a compact list of prompt records for discovery.
|
||||||
|
2. Contains one entry per `prompt_id`.
|
||||||
|
3. Includes `id`, `name`, `description`, `tags`, `version`, and canonical document URI.
|
||||||
|
|
||||||
|
### `resource://catalog/skills_index{?q,tag,capability,cursor,limit}`
|
||||||
|
|
||||||
|
1. Returns the same record family as `resource://catalog/skills_index` with optional filtering and pagination.
|
||||||
|
2. Query parameters are optional and composable.
|
||||||
|
3. Unknown query keys are ignored or rejected deterministically by server policy.
|
||||||
|
|
||||||
|
### `resource://catalog/prompts_index{?q,tag,cursor,limit}`
|
||||||
|
|
||||||
|
1. Returns the same record family as `resource://catalog/prompts_index` with optional filtering and pagination.
|
||||||
|
2. Query parameters are optional and composable.
|
||||||
|
3. Unknown query keys are ignored or rejected deterministically by server policy.
|
||||||
|
|
||||||
|
### `resource://catalog/prompts/{prompt_id}`
|
||||||
|
|
||||||
|
1. Returns one normalized record for `prompt_id`.
|
||||||
|
2. Includes prompt argument metadata when declared in frontmatter.
|
||||||
|
3. Returns not found when `prompt_id` does not exist.
|
||||||
|
|
||||||
|
### `resource://prompts/{prompt_id}/document`
|
||||||
|
|
||||||
|
1. Returns the canonical prompt markdown document.
|
||||||
|
2. `prompt_id` must satisfy lowercase kebab-case rules.
|
||||||
|
|
||||||
|
## Template Parameter And Validation Rules
|
||||||
|
|
||||||
|
### `skill_id`
|
||||||
|
|
||||||
|
1. Lowercase kebab-case.
|
||||||
|
2. Must satisfy the stable skill id rules from the content contract.
|
||||||
|
|
||||||
|
### `ref_id`
|
||||||
|
|
||||||
|
1. Lowercase kebab-case.
|
||||||
|
2. Must be declared in the skill's references manifest.
|
||||||
|
|
||||||
|
### `path*`
|
||||||
|
|
||||||
|
1. Relative POSIX path only, expressed as URI path segments under [RFC3986 path syntax](https://www.rfc-editor.org/rfc/rfc3986#section-3.3).
|
||||||
|
2. No leading slash.
|
||||||
|
3. No `..` traversal segments.
|
||||||
|
4. Resolves only inside `docs/`.
|
||||||
|
5. Markdown-only in the end state, meaning `.md` files.
|
||||||
|
6. Any reserved URI characters in path segments must be [percent-encoded](https://www.rfc-editor.org/rfc/rfc3986#section-2.1).
|
||||||
|
|
||||||
|
### `prompt_id`
|
||||||
|
|
||||||
|
1. Lowercase kebab-case.
|
||||||
|
2. Must be unique across prompt ids and must not collide with skill ids.
|
||||||
|
|
||||||
|
## URI Hygiene Rules
|
||||||
|
|
||||||
|
1. Use lowercase, human-readable path segments for stable discoverability.
|
||||||
|
2. Keep identifiers immutable once public whenever practical.
|
||||||
|
3. Keep template variables semantic (`skill_id`, `prompt_id`, `ref_id`, `path*`) and avoid overloading one variable for unrelated meanings.
|
||||||
|
4. Do not include secrets, tokens, or user-identifying data in URI paths or query strings.
|
||||||
|
5. Prefer additive query parameters for discovery over introducing parallel URI families, matching [MCP resource-template discovery patterns](https://modelcontextprotocol.io/docs/learn/server-concepts#resources).
|
||||||
|
6. Return clear not-found semantics for unknown ids and invalid template resolution.
|
||||||
|
|
||||||
|
## URI Versioning Policy
|
||||||
|
|
||||||
|
Default rule:
|
||||||
|
|
||||||
|
1. Keep URIs unversioned by default.
|
||||||
|
2. Allow URI and payload updates when they improve clarity or implementation simplicity.
|
||||||
|
|
||||||
|
Breaking-change rule:
|
||||||
|
|
||||||
|
1. Breaking changes use direct replacement of the canonical URI family.
|
||||||
|
2. No compatibility aliases or dual URI families are maintained.
|
||||||
|
|
||||||
|
FastMCP version metadata usage:
|
||||||
|
|
||||||
|
1. Resource `version` metadata may be used for implementation and version discovery.
|
||||||
|
2. 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:
|
||||||
|
|
||||||
|
1. Prefer keeping `ref_id` stable when practical.
|
||||||
|
2. File paths may change without URI churn as long as the mapped `ref_id` still resolves.
|
||||||
|
3. If a reference is renamed, introduce a new `ref_id` and treat the old one as retired.
|
||||||
|
4. Avoid reusing retired `ref_id` values for unrelated content.
|
||||||
|
|
||||||
|
## Invariants
|
||||||
|
|
||||||
|
This contract guarantees:
|
||||||
|
|
||||||
|
1. One canonical URI pattern per core capability surface.
|
||||||
|
2. Fast, low-friction URI evolution through direct replacement of canonical URIs.
|
||||||
|
3. A single canonical catalog URI family with no alias maintenance overhead.
|
||||||
|
4. Reference mappings can evolve with minimal churn.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This contract does not define:
|
||||||
|
|
||||||
|
1. Implementation-specific transform wiring details, such as `VersionFilter`, mounts, or provider composition.
|
||||||
|
2. Migration script mechanics for auto-generating aliases.
|
||||||
|
3. Authorization policy design for URI-level access control.
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
1. [MCP Server Concepts: Resources](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)
|
||||||
|
2. [MCP Architecture Overview](https://modelcontextprotocol.io/docs/learn/architecture)
|
||||||
|
3. [MCP Specification Repository](https://github.com/modelcontextprotocol/spec)
|
||||||
|
4. [RFC6570 URI Template](https://www.rfc-editor.org/rfc/rfc6570)
|
||||||
@@ -69,6 +69,15 @@ When resource attachment is unavailable in the active session, use ResourcesAsTo
|
|||||||
4. `get_pattern_by_id`
|
4. `get_pattern_by_id`
|
||||||
5. `get_skill_document_by_id`
|
5. `get_skill_document_by_id`
|
||||||
|
|
||||||
|
Canonical naming policy:
|
||||||
|
|
||||||
|
1. Prefer the five canonical tool names above in prompts and instructions.
|
||||||
|
2. For compatibility with clients that emit `catalog_*` naming, the server also exposes:
|
||||||
|
- `catalog_search_patterns`
|
||||||
|
- `catalog_get_pattern_by_id`
|
||||||
|
- `catalog_get_skill_document_by_id`
|
||||||
|
3. Canonical and compatibility alias tools return equivalent payloads for the same input.
|
||||||
|
|
||||||
The first two are generated from the canonical resource surface and should be preferred in tool-only clients.
|
The first two are generated from the canonical resource surface and should be preferred in tool-only clients.
|
||||||
|
|
||||||
These should stay read-only, minimal, and schema-aligned with catalog resources.
|
These should stay read-only, minimal, and schema-aligned with catalog resources.
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
---
|
|
||||||
icon: lucide/braces
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frontmatter Contract
|
|
||||||
|
|
||||||
This page defines the `SKILL.md` frontmatter and FastMCP metadata contract.
|
|
||||||
|
|
||||||
## Anthropic Frontmatter Support
|
|
||||||
|
|
||||||
Across Anthropic API and Agent Skills surfaces:
|
|
||||||
|
|
||||||
1. Required fields for custom skill bundles are `name` and `description`.
|
|
||||||
2. `name` must be 1-64 characters, lowercase letters, numbers, and hyphens only, with no XML tags, and must not use the reserved words `anthropic` or `claude`.
|
|
||||||
3. `description` must be 1-1024 characters, non-empty, and contain no XML tags.
|
|
||||||
|
|
||||||
Portable optional fields from the Agent Skills specification:
|
|
||||||
|
|
||||||
1. `license`
|
|
||||||
2. `compatibility`
|
|
||||||
3. `metadata`
|
|
||||||
4. `allowed-tools`
|
|
||||||
|
|
||||||
Claude Code-specific optional fields:
|
|
||||||
|
|
||||||
1. `when_to_use`
|
|
||||||
2. `argument-hint`
|
|
||||||
3. `arguments`
|
|
||||||
4. `disable-model-invocation`
|
|
||||||
5. `user-invocable`
|
|
||||||
6. `allowed-tools`
|
|
||||||
7. `disallowed-tools`
|
|
||||||
8. `model`
|
|
||||||
9. `effort`
|
|
||||||
10. `context`
|
|
||||||
11. `agent`
|
|
||||||
12. `hooks`
|
|
||||||
13. `paths`
|
|
||||||
14. `shell`
|
|
||||||
|
|
||||||
Repository contract decisions:
|
|
||||||
|
|
||||||
1. Treat `name` and `description` as required in all `SKILL.md` files.
|
|
||||||
2. Keep Anthropic-facing semantics in standard fields.
|
|
||||||
3. Keep MCP indexing metadata in a namespaced extension block.
|
|
||||||
4. Preserve forward compatibility by allowing additive optional metadata fields over time.
|
|
||||||
|
|
||||||
## Canonical Frontmatter Schema
|
|
||||||
|
|
||||||
Use this two-layer pattern:
|
|
||||||
|
|
||||||
1. Anthropic layer: top-level fields intended for Anthropic and Agent Skills behavior.
|
|
||||||
2. Repository layer: 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 and Agent Skills fields
|
|
||||||
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
|
|
||||||
x-personal-mcp:
|
|
||||||
id: <skill-id>
|
|
||||||
version: <semver>
|
|
||||||
tags:
|
|
||||||
- <tag>
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/<skill-id>/document
|
|
||||||
depends_on: []
|
|
||||||
# Optional: overrides and nested references only.
|
|
||||||
# Top-level references/*.md are auto-discovered.
|
|
||||||
references:
|
|
||||||
<ref-id>:
|
|
||||||
path: references/<file>.md
|
|
||||||
mime_type: text/markdown
|
|
||||||
title: <short title>
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
## Repository Metadata Field Rules
|
|
||||||
|
|
||||||
Rules for `x-personal-mcp`:
|
|
||||||
|
|
||||||
1. `id` is required, must follow the skill id rules from the content contract, and must equal the directory name.
|
|
||||||
2. `version` is required and must be a semantic version string.
|
|
||||||
3. `tags` is optional and should be a list of kebab-case discovery labels.
|
|
||||||
4. `capabilities` is required and lists the MCP URIs the skill publishes.
|
|
||||||
5. `depends_on` is optional and lists other skill ids.
|
|
||||||
6. `references` is an optional map keyed by `ref-id` for overrides and nested entries.
|
|
||||||
|
|
||||||
Reference entry rules:
|
|
||||||
|
|
||||||
1. `ref-id` is lowercase kebab-case.
|
|
||||||
2. `path` is a skill-relative markdown path and must stay inside the same skill directory.
|
|
||||||
3. Top-level files under `references/*.md` are auto-discovered with `ref-id` derived from a normalized filename stem (lowercase kebab-case).
|
|
||||||
4. Nested folders under `references/` are not auto-discovered and must be declared explicitly.
|
|
||||||
5. `mime_type` defaults to `text/markdown` when omitted.
|
|
||||||
6. `title` is an optional display label.
|
|
||||||
7. Renaming `ref-id` values is allowed when needed; optional aliases may be used during transitions.
|
|
||||||
|
|
||||||
## Validation Models
|
|
||||||
|
|
||||||
The normative model uses Pydantic v2 with change-friendly validation:
|
|
||||||
|
|
||||||
```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)
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. Validate required core fields and relationships during registry load before FastMCP resource or tool registration.
|
|
||||||
2. Allow unknown additive fields so frontmatter can evolve without blocking startup.
|
|
||||||
3. Treat hard contract violations, including missing required fields, invalid ids, and broken required mappings, as startup errors.
|
|
||||||
4. Treat non-critical compatibility issues as warnings when possible.
|
|
||||||
5. Error messages should include the skill path and failing field for CI readability.
|
|
||||||
|
|
||||||
Projection mode contract for Anthropic API upload pipelines:
|
|
||||||
|
|
||||||
1. Parse with `SkillFrontmatter` first.
|
|
||||||
2. Emit Anthropic-safe frontmatter with standard fields only.
|
|
||||||
3. Serialize repository metadata into standard `metadata` as namespaced keys.
|
|
||||||
4. Preserve the canonical authored source in `x-personal-mcp`; projection output is a build artifact.
|
|
||||||
|
|
||||||
## Anthropic Upload Compatibility Rule
|
|
||||||
|
|
||||||
1. Anthropic documentation guarantees behavior for standard frontmatter fields but does not explicitly guarantee handling of arbitrary unknown top-level keys.
|
|
||||||
2. Publishing pipelines that target strict API compatibility should support a projection mode that emits only standard frontmatter fields for upload.
|
|
||||||
3. In projection mode, repository extension metadata is serialized into the standard `metadata` field as namespaced keys or JSON-encoded values, while source-of-truth authoring remains in `x-personal-mcp`.
|
|
||||||
|
|
||||||
## FastMCP Native Metadata Surfaces
|
|
||||||
|
|
||||||
Resources support native definition metadata:
|
|
||||||
|
|
||||||
1. `name`
|
|
||||||
2. `description`
|
|
||||||
3. `mime_type`
|
|
||||||
4. `tags`
|
|
||||||
5. `annotations`, including `readOnlyHint` and `idempotentHint`
|
|
||||||
6. `icons`
|
|
||||||
7. `meta`
|
|
||||||
8. `version`
|
|
||||||
9. `enabled`, which is deprecated in FastMCP v3 in favor of server-level enable and disable controls
|
|
||||||
|
|
||||||
Resources also support runtime metadata through `ResourceContent.meta` and `ResourceResult.meta`.
|
|
||||||
|
|
||||||
Tools support native definition metadata:
|
|
||||||
|
|
||||||
1. `name`
|
|
||||||
2. `description`
|
|
||||||
3. `tags`
|
|
||||||
4. `annotations`, including `title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, and `openWorldHint`
|
|
||||||
5. `icons`
|
|
||||||
6. `meta`
|
|
||||||
7. `version`
|
|
||||||
8. `timeout`
|
|
||||||
9. `output_schema`
|
|
||||||
10. `run_in_thread`
|
|
||||||
11. `enabled`, which is deprecated in FastMCP v3 in favor of server-level enable and disable controls
|
|
||||||
|
|
||||||
Tools also support runtime metadata through `ToolResult.meta`.
|
|
||||||
|
|
||||||
## Frontmatter To FastMCP Mapping Contract
|
|
||||||
|
|
||||||
At server startup, map `x-personal-mcp` into FastMCP registration as follows:
|
|
||||||
|
|
||||||
1. `x-personal-mcp.id` defines the canonical URI namespace and identity checks.
|
|
||||||
2. `description` becomes the default description for the primary skill document resource.
|
|
||||||
3. `x-personal-mcp.tags` maps to resource and tool tags.
|
|
||||||
4. `x-personal-mcp.version` maps to resource and tool version metadata.
|
|
||||||
5. `x-personal-mcp.capabilities` becomes the registered URI list and catalog exposure.
|
|
||||||
6. `x-personal-mcp.references[*]` becomes resource templates or concrete resources with `mime_type`, read-only annotations, and `meta` that includes `skill_id`, `ref_id`, and source `path`.
|
|
||||||
7. `x-personal-mcp.depends_on` becomes catalog dependency graph metadata and validation inputs.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
This contract guarantees:
|
|
||||||
|
|
||||||
1. Anthropic-required frontmatter stays valid for custom skill upload and Claude Code loading.
|
|
||||||
2. MCP-specific metadata remains embedded in `SKILL.md` frontmatter, with no `metadata.yaml` sidecar.
|
|
||||||
3. FastMCP registration uses native metadata fields for resources and tools.
|
|
||||||
4. Reference ids and metadata can evolve with low-friction updates while internal file layout under `references/` stays refactor-friendly.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This contract does not define:
|
|
||||||
|
|
||||||
1. URI versioning and deprecation rollout policy details.
|
|
||||||
2. Migration script design from existing `metadata.yaml` files.
|
|
||||||
3. Runtime caching and indexing performance tuning.
|
|
||||||
+4
-3
@@ -39,9 +39,10 @@ When the server is running, the health check is available at `/healthz` and the
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- [Resource-First Pattern Module Architecture](./architecture.md)
|
- [Resource-First Pattern Module Architecture](./architecture.md)
|
||||||
- [Content Contract](./content.md)
|
- [Contracts](./contracts/index.md)
|
||||||
- [Frontmatter Contract](./frontmatter.md)
|
- [Content Contract](./contracts/index.md#content-contract)
|
||||||
- [URI Contract](./uris.md)
|
- [Frontmatter Contract](./contracts/frontmatter.md)
|
||||||
|
- [URI Contract](./contracts/uris.md)
|
||||||
- [Static Docs Hosting Pattern](./mcp_layout.md)
|
- [Static Docs Hosting Pattern](./mcp_layout.md)
|
||||||
- [Skill Usage Mechanics](./usage.md)
|
- [Skill Usage Mechanics](./usage.md)
|
||||||
- [Copilot MCP Mechanics](./copilot.md)
|
- [Copilot MCP Mechanics](./copilot.md)
|
||||||
|
|||||||
+26
-41
@@ -32,55 +32,35 @@ treeView-beta
|
|||||||
"zensical.toml"
|
"zensical.toml"
|
||||||
"docs"
|
"docs"
|
||||||
"index.md"
|
"index.md"
|
||||||
"architecture.md"
|
"<project-docs>.md"
|
||||||
"content.md"
|
"contracts"
|
||||||
"frontmatter.md"
|
"index.md"
|
||||||
|
"<contract-pages>.md"
|
||||||
"mcp_layout.md"
|
"mcp_layout.md"
|
||||||
"uris.md"
|
"prompts"
|
||||||
|
"<prompt-id>"
|
||||||
|
"PROMPT.md"
|
||||||
|
"references"
|
||||||
"skills"
|
"skills"
|
||||||
"new-skill"
|
"<skill-id>"
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"copilot-customization"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"fastapi-async-sqlalchemy-modernization"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"fastapi-uv-docker"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"nicegui"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"nicegui-ui-customization"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"pytest-scaffolding"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"python-logging-dictconfig"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"vscode-configuration"
|
|
||||||
"SKILL.md"
|
|
||||||
"references"
|
|
||||||
"zensical-docs"
|
|
||||||
"SKILL.md"
|
"SKILL.md"
|
||||||
"references"
|
"references"
|
||||||
|
"<reference>.md"
|
||||||
"site"
|
"site"
|
||||||
"static build output"
|
"static build output"
|
||||||
"src"
|
"src"
|
||||||
"personal_mcp"
|
"personal_mcp"
|
||||||
|
"__init__.py"
|
||||||
"main.py"
|
"main.py"
|
||||||
"mcp.py"
|
"mcp.py"
|
||||||
"web"
|
|
||||||
"app.py"
|
|
||||||
"docs_mount.py"
|
|
||||||
"catalog"
|
"catalog"
|
||||||
"server.py"
|
"<catalog-modules>.py"
|
||||||
|
"registry"
|
||||||
|
"<registry-modules>.py"
|
||||||
|
"web"
|
||||||
|
"<web-modules>.py"
|
||||||
"skills"
|
"skills"
|
||||||
"document_loader.py"
|
"<skills-modules>.py"
|
||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
@@ -88,7 +68,8 @@ Notes:
|
|||||||
1. docs contains both project-authored pages and the canonical skill Markdown tree.
|
1. docs contains both project-authored pages and the canonical skill Markdown tree.
|
||||||
2. site contains static build output only.
|
2. site contains static build output only.
|
||||||
3. docs/skills contains canonical skill Markdown and reference Markdown.
|
3. docs/skills contains canonical skill Markdown and reference Markdown.
|
||||||
4. MCP resources and docs site read from the same Markdown sources.
|
4. docs/prompts contains canonical prompt Markdown used for prompt catalog and document surfaces.
|
||||||
|
5. MCP resources and docs site read from the same Markdown sources.
|
||||||
|
|
||||||
## Runtime Composition
|
## Runtime Composition
|
||||||
|
|
||||||
@@ -146,7 +127,11 @@ Example mapping model:
|
|||||||
Catalog discovery resources are:
|
Catalog discovery resources are:
|
||||||
|
|
||||||
1. resource://catalog/skills_index
|
1. resource://catalog/skills_index
|
||||||
2. resource://catalog/skills/{skill_id}
|
2. resource://catalog/skills_index{?q,tag,capability,cursor,limit}
|
||||||
|
3. resource://catalog/skills/{skill_id}
|
||||||
|
4. resource://catalog/prompts_index
|
||||||
|
5. resource://catalog/prompts_index{?q,tag,cursor,limit}
|
||||||
|
6. resource://catalog/prompts/{prompt_id}
|
||||||
|
|
||||||
Registry-backed registration details:
|
Registry-backed registration details:
|
||||||
|
|
||||||
@@ -208,8 +193,8 @@ This keeps docs publication explicit and predictable.
|
|||||||
|
|
||||||
Existing reference docs remain valid content inputs in this pattern:
|
Existing reference docs remain valid content inputs in this pattern:
|
||||||
|
|
||||||
1. docs/skills/pytest-scaffolding/references/pytest-docs.md
|
1. docs/skills/pytesting/references/pytest-docs.md
|
||||||
2. docs/skills/python-logging-dictconfig/references/python-logging-docs.md
|
2. docs/skills/python-logging/references/python-logging-docs.md
|
||||||
3. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
3. docs/skills/fastapi-uv-docker/references/fastapi-best-practices.md
|
||||||
|
|
||||||
These are source documents, not deployment artifacts.
|
These are source documents, not deployment artifacts.
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
---
|
|
||||||
icon: lucide/file-plus
|
|
||||||
---
|
|
||||||
|
|
||||||
# Hooking Up a New Skill
|
|
||||||
|
|
||||||
Use this checklist to add a new skill in the docs-first model.
|
|
||||||
|
|
||||||
For the full contract details, see [Content Contract](./content.md), [Frontmatter Contract](./frontmatter.md), and [URI Contract](./uris.md).
|
|
||||||
|
|
||||||
## Canonical Skill Shape
|
|
||||||
|
|
||||||
Create one skill directory under `docs/skills/`:
|
|
||||||
|
|
||||||
```text
|
|
||||||
docs/
|
|
||||||
skills/
|
|
||||||
<skill-id>/
|
|
||||||
SKILL.md
|
|
||||||
references/
|
|
||||||
... (optional markdown files, nested folders allowed)
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
1. `SKILL.md` is required.
|
|
||||||
2. All skill-specific supporting docs live under `references/`.
|
|
||||||
3. Skill directories are ownership boundaries; no cross-skill writes.
|
|
||||||
4. `skill-id` is lowercase kebab-case and should remain stable.
|
|
||||||
|
|
||||||
## SKILL.md Frontmatter
|
|
||||||
|
|
||||||
`SKILL.md` frontmatter is authoritative for metadata.
|
|
||||||
|
|
||||||
Required top-level fields:
|
|
||||||
|
|
||||||
1. `name`
|
|
||||||
2. `description`
|
|
||||||
3. `x-personal-mcp`
|
|
||||||
|
|
||||||
Required `x-personal-mcp` fields:
|
|
||||||
|
|
||||||
1. `id`
|
|
||||||
2. `version`
|
|
||||||
3. `capabilities`
|
|
||||||
|
|
||||||
Optional `x-personal-mcp` fields:
|
|
||||||
|
|
||||||
1. `tags`
|
|
||||||
2. `depends_on`
|
|
||||||
3. `references`
|
|
||||||
|
|
||||||
Canonical frontmatter template:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
---
|
|
||||||
name: <skill-id>
|
|
||||||
description: <what this skill does and when to use it>
|
|
||||||
|
|
||||||
x-personal-mcp:
|
|
||||||
id: <skill-id>
|
|
||||||
version: 1.0.0
|
|
||||||
tags: []
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/<skill-id>/document
|
|
||||||
depends_on: []
|
|
||||||
# Optional: only for nested references or metadata overrides.
|
|
||||||
references:
|
|
||||||
<ref-id>:
|
|
||||||
path: references/<file>.md
|
|
||||||
mime_type: text/markdown
|
|
||||||
title: <optional short title>
|
|
||||||
---
|
|
||||||
```
|
|
||||||
|
|
||||||
Reference manifest rules:
|
|
||||||
|
|
||||||
1. `ref-id` is lowercase kebab-case.
|
|
||||||
2. `path` is skill-relative and must stay under `references/`.
|
|
||||||
3. Top-level `references/*.md` files are auto-discovered, and `ref-id` is derived from a normalized filename stem.
|
|
||||||
4. Nested `references/**` markdown files must be declared explicitly.
|
|
||||||
5. Reference paths are markdown files.
|
|
||||||
|
|
||||||
No `metadata.yaml` sidecar is part of this model.
|
|
||||||
|
|
||||||
## URI Surface
|
|
||||||
|
|
||||||
Canonical resource URIs for a skill:
|
|
||||||
|
|
||||||
1. `resource://skills/<skill_id>/document`
|
|
||||||
2. `resource://skills/<skill_id>/references/<ref_id>`
|
|
||||||
|
|
||||||
Canonical discovery URIs:
|
|
||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
|
||||||
2. `resource://catalog/skills/{skill_id}`
|
|
||||||
|
|
||||||
Docs passthrough URI:
|
|
||||||
|
|
||||||
1. `resource://docs/{path*}`
|
|
||||||
|
|
||||||
Compatibility rule:
|
|
||||||
|
|
||||||
1. Keep URI families unversioned by default.
|
|
||||||
2. For breaking changes, update clients to the canonical replacement URIs directly.
|
|
||||||
|
|
||||||
## Checklist
|
|
||||||
|
|
||||||
1. Create `docs/skills/<skill-id>/SKILL.md`.
|
|
||||||
2. Add optional references under `docs/skills/<skill-id>/references/`.
|
|
||||||
3. Populate frontmatter with `name`, `description`, and `x-personal-mcp` metadata.
|
|
||||||
4. Ensure `x-personal-mcp.id` equals `name` and directory `<skill-id>`.
|
|
||||||
5. Ensure `capabilities` includes `resource://skills/<skill-id>/document`.
|
|
||||||
6. Add supporting docs under `references/`; top-level markdown files are exposed automatically.
|
|
||||||
7. Declare `x-personal-mcp.references` only for nested paths or to override defaults.
|
|
||||||
|
|
||||||
## Quick Validation
|
|
||||||
|
|
||||||
1. Confirm docs build succeeds:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run zensical build
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Confirm tests succeed:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest -q
|
|
||||||
```
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
name: authoring
|
||||||
|
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
|
||||||
|
|
||||||
|
Use this prompt to author or update docs-first MCP modules in this repository, including repository-specific Copilot thin shims.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
1. artifact_type: one of skill, prompt, shim
|
||||||
|
2. artifact_id: lowercase kebab-case id
|
||||||
|
3. goal: one-sentence capability statement
|
||||||
|
4. optional scope_glob for shim outputs
|
||||||
|
|
||||||
|
## Required References
|
||||||
|
|
||||||
|
Load only what matches the requested artifact:
|
||||||
|
|
||||||
|
1. Authoring workflow and validation policy: [Authoring Guide](../../authoring.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)
|
||||||
|
4. Thin shim mechanics and path binding: [Skill Usage Mechanics](../../usage.md)
|
||||||
|
5. Copilot resource attachment and fallback behavior: [Copilot MCP Mechanics](../../copilot.md)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Validate required inputs and ask one clarifying question if any required input is missing.
|
||||||
|
2. Keep ids and slugs aligned with folder names and frontmatter ids.
|
||||||
|
3. Enforce artifact_type enum values exactly: skill, prompt, shim.
|
||||||
|
4. If artifact_type is outside the enum, ask one correction question and stop before generating output.
|
||||||
|
5. Apply YAML safety rules for frontmatter values:
|
||||||
|
- quote values containing `:`
|
||||||
|
- prefer quotes for punctuation-heavy scalars
|
||||||
|
- use block scalars for multiline descriptions
|
||||||
|
6. Run immediate validation after frontmatter edits:
|
||||||
|
- `uv run zensical build`
|
||||||
|
- `uv run pytest -q`
|
||||||
|
7. Produce only the requested artifact type.
|
||||||
|
8. Keep guidance deterministic and minimal, with explicit references to source docs.
|
||||||
|
9. If artifact_type is shim:
|
||||||
|
- bind one applyTo scope to one primary skill resource URI
|
||||||
|
- prefer MCP resource attachment first
|
||||||
|
- if resource attachment is unavailable, use fallback tool order:
|
||||||
|
1. list_resources
|
||||||
|
2. read_resource
|
||||||
|
3. search_patterns
|
||||||
|
4. get_pattern_by_id
|
||||||
|
5. get_skill_document_by_id
|
||||||
|
10. Return created or updated file paths and any validation commands that should be run.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
1. Files created or updated.
|
||||||
|
2. Which references were used.
|
||||||
|
3. Validation commands and outcomes (or commands to run if execution is not requested).
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
name: greenfield-architecture
|
||||||
|
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
|
||||||
|
|
||||||
|
Use this prompt to design a new software app or library architecture in generic terms.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
1. intent_document: optional full document that explains goals, context, constraints, and desired outcomes
|
||||||
|
2. problem_domain: concise domain and one-sentence business goal when no full intent document is provided
|
||||||
|
3. scope_type: app or library
|
||||||
|
4. optional constraints: runtime, deployment, scale, non-functional priorities
|
||||||
|
|
||||||
|
If both intent_document and problem_domain are provided, treat intent_document as the primary source and use problem_domain as a summary cross-check.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Validate required inputs.
|
||||||
|
- scope_type is required
|
||||||
|
- at least one of intent_document or problem_domain must be provided
|
||||||
|
- ask one concise clarification question if inputs are incomplete or contradictory
|
||||||
|
2. Start with research before proposing architecture:
|
||||||
|
- identify at least three established patterns or methodologies used for similar systems
|
||||||
|
- summarize what each pattern optimizes for
|
||||||
|
- compare strengths, risks, and implementation complexity
|
||||||
|
3. Ask which aspects of those patterns matter most for the user context.
|
||||||
|
4. Identify major libraries or frameworks commonly used for this problem space and explain tradeoffs for each:
|
||||||
|
- strengths and weaknesses
|
||||||
|
- ecosystem maturity
|
||||||
|
- performance profile
|
||||||
|
- operational complexity
|
||||||
|
- learning curve
|
||||||
|
5. Recommend one primary stack and one fallback stack, with rationale tied to stated priorities.
|
||||||
|
6. Produce the architecture deliverables:
|
||||||
|
- high-level concepts, features, and requirements
|
||||||
|
- intended use cases and key workflows
|
||||||
|
- high-level package/module structure
|
||||||
|
- conceptual boundaries for each module (what belongs there and what does not)
|
||||||
|
- dependency and data-flow direction between modules
|
||||||
|
7. Plan incremental delivery with explicit growth paths:
|
||||||
|
- define the initial prototype slice with the smallest valuable feature set
|
||||||
|
- identify which features are intentionally deferred from the prototype
|
||||||
|
- describe extension paths that add complexity in controlled stages
|
||||||
|
- ensure each stage preserves clean module boundaries and low migration risk
|
||||||
|
8. Design a test strategy aligned to the proposed structure and staged delivery plan:
|
||||||
|
- unit, integration, contract, and end-to-end layers
|
||||||
|
- what each layer should cover in prototype stage vs extension stages
|
||||||
|
- fixture and environment setup for fast, deterministic tests
|
||||||
|
- boundary seams for mocks/fakes and minimization of nondeterministic external I/O
|
||||||
|
- CI execution approach for fast feedback and confidence
|
||||||
|
9. Call out key risks, assumptions, and open questions.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Return these sections in order:
|
||||||
|
|
||||||
|
1. Research Summary
|
||||||
|
2. Pattern Comparison
|
||||||
|
3. Library and Framework Tradeoffs
|
||||||
|
4. Recommended Stack
|
||||||
|
5. Architecture Overview
|
||||||
|
6. Concepts, Features, and Requirements
|
||||||
|
7. Intended Use Cases
|
||||||
|
8. Package and Module Layout
|
||||||
|
9. Conceptual Boundary Map
|
||||||
|
10. Initial Prototype Scope
|
||||||
|
11. Extension Roadmap
|
||||||
|
12. Test Strategy
|
||||||
|
13. Risks and Open Questions
|
||||||
|
14. Next Implementation Steps
|
||||||
|
|
||||||
|
## Quality Rules
|
||||||
|
|
||||||
|
1. Keep language generic and project-agnostic.
|
||||||
|
2. Prefer established patterns over novelty unless there is a strong reason to diverge.
|
||||||
|
3. Tie each recommendation to an explicit requirement or tradeoff.
|
||||||
|
4. Make assumptions explicit and concise.
|
||||||
|
5. Ask one focused clarifying question when confidence is low instead of over-speculating.
|
||||||
|
6. Prefer architecture decisions that support starting simple and growing complexity without major rewrites.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
---
|
||||||
|
name: mcp-consumer-repo-shim
|
||||||
|
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 skill resource URI, usually resource://skills/<skill-id>/document.
|
||||||
|
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
|
||||||
|
|
||||||
|
Use this prompt to generate exactly one repository-scoped Copilot instruction shim for an MCP consumer repository.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Required:
|
||||||
|
- apply_to_glob
|
||||||
|
- primary_skill_resource
|
||||||
|
- Optional:
|
||||||
|
- shim_title
|
||||||
|
- companion_docs_page
|
||||||
|
|
||||||
|
## Required References
|
||||||
|
|
||||||
|
Load only sections relevant to the requested shim:
|
||||||
|
|
||||||
|
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)
|
||||||
|
3. Authoring workflow and validation checklist: [Authoring Guide](../../authoring.md)
|
||||||
|
4. Instruction metadata expectations and examples: [Copilot customization skill](../../skills/copilot-customization/SKILL.md)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Validate that apply_to_glob and primary_skill_resource are present.
|
||||||
|
2. If either value is missing or ambiguous, ask exactly one clarifying question before generating output.
|
||||||
|
3. Generate one .instructions.md file content block only.
|
||||||
|
4. Keep the shim concise and deterministic:
|
||||||
|
- include YAML frontmatter with name, description, and applyTo
|
||||||
|
- 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)
|
||||||
|
5. Include VS Code/Copilot integration mechanics in the shim body:
|
||||||
|
- prefer MCP resource attachment when available
|
||||||
|
- if attachment is unavailable, use tool fallback order:
|
||||||
|
1. list_resources
|
||||||
|
2. read_resource
|
||||||
|
3. search_patterns
|
||||||
|
4. get_pattern_by_id
|
||||||
|
5. get_skill_document_by_id
|
||||||
|
- ask one clarifying question when confidence is low
|
||||||
|
6. If companion_docs_page is provided, include it as a companion docs link line.
|
||||||
|
7. Do not generate additional files, code changes, or batch shim packs.
|
||||||
|
|
||||||
|
## Output Format
|
||||||
|
|
||||||
|
Return exactly:
|
||||||
|
|
||||||
|
1. Suggested file path line under .github/instructions/.
|
||||||
|
2. One fenced markdown block containing the full .instructions.md content.
|
||||||
|
3. A brief note (max 3 lines) describing what the shim routes and why.
|
||||||
|
|
||||||
|
## Output Template
|
||||||
|
|
||||||
|
````md
|
||||||
|
Path: .github/instructions/<slug>.instructions.md
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
name: <shim-title>
|
||||||
|
description: Route <scope> edits to the Personal MCP <skill-id> resource.
|
||||||
|
applyTo: '<apply_to_glob>'
|
||||||
|
---
|
||||||
|
|
||||||
|
When editing files matching <apply_to_glob>, use <primary_skill_resource> as the primary guidance source.
|
||||||
|
|
||||||
|
Execution pattern:
|
||||||
|
|
||||||
|
1. Load the primary skill document first.
|
||||||
|
2. Apply only sections relevant to the file being edited.
|
||||||
|
3. Keep edits minimal and aligned with repository conventions.
|
||||||
|
4. Prefer MCP resource attachment when available in the current chat surface.
|
||||||
|
5. If MCP resource attachment is unavailable, use tool fallback in this order:
|
||||||
|
1. list_resources
|
||||||
|
2. read_resource
|
||||||
|
3. search_patterns
|
||||||
|
4. get_pattern_by_id
|
||||||
|
5. get_skill_document_by_id
|
||||||
|
6. If confidence is low, ask one clarifying question before editing.
|
||||||
|
|
||||||
|
Companion docs page: <optional-relative-doc-link>
|
||||||
|
```
|
||||||
|
````
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
|
||||||
|
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](../../skills/pytesting/SKILL.md)
|
||||||
|
2. Naming/hierarchy preservation: [naming and organization](../../skills/pytesting/references/naming-and-organization.md)
|
||||||
|
3. Baseline pytest fixtures/markers: [pytest docs notes](../../skills/pytesting/references/pytest-docs.md)
|
||||||
|
4. FastAPI-specific behavior (only when needed): [fastapi testing](../../skills/pytesting/references/fastapi-testing.md)
|
||||||
|
5. SQLAlchemy-specific behavior (only when needed): [sqlalchemy testing](../../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,97 @@
|
|||||||
|
---
|
||||||
|
name: pytest-scaffold
|
||||||
|
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
|
||||||
|
|
||||||
|
Use this prompt to consistently plan and scaffold pytest test modules for selected Python source modules.
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- Required:
|
||||||
|
- target_modules: one or more module paths under src/
|
||||||
|
- mode: one of plan-only or scaffold
|
||||||
|
- Optional:
|
||||||
|
- path_strategy: preference for how source paths map into tests/
|
||||||
|
- naming_style: preference for concise method naming style
|
||||||
|
|
||||||
|
## Required References
|
||||||
|
|
||||||
|
Load these in order and apply only the relevant sections:
|
||||||
|
|
||||||
|
1. Primary conventions: [Pytesting Skill](../../skills/pytesting/SKILL.md)
|
||||||
|
2. Hierarchy and naming: [Naming and Organization](../../skills/pytesting/references/naming-and-organization.md)
|
||||||
|
3. Marker and fixture defaults: [Pytest Docs Notes](../../skills/pytesting/references/pytest-docs.md)
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Inspect the current tests/ layout and infer existing naming and grouping conventions.
|
||||||
|
2. Propose a concise hierarchy plan first:
|
||||||
|
- test file paths
|
||||||
|
- class hierarchy
|
||||||
|
- method naming pattern
|
||||||
|
- fixture placement choices (tests/conftest.py or subtree conftest.py)
|
||||||
|
3. If mode is scaffold, implement only the scaffold structure:
|
||||||
|
- create missing test modules
|
||||||
|
- create class hierarchy
|
||||||
|
- add one-line docstrings to each class and test method
|
||||||
|
- keep test method names short and behavior-focused
|
||||||
|
4. Treat docstring-only scaffolds as an intentionally stable baseline for later fill-in work.
|
||||||
|
5. Validate collection with:
|
||||||
|
- uv run pytest --collect-only -q
|
||||||
|
6. Report outcomes:
|
||||||
|
- files created or updated
|
||||||
|
- collection result
|
||||||
|
- ambiguities and follow-up choices
|
||||||
|
|
||||||
|
## Naming Defaults
|
||||||
|
|
||||||
|
- Class naming:
|
||||||
|
- Test<PrimarySubject> as a top-level subject class
|
||||||
|
- nested Test<MethodOrArea> classes where extra context improves readability
|
||||||
|
- Test<FunctionName> top-level classes for standalone module functions
|
||||||
|
- Method naming:
|
||||||
|
- test_<short_outcome>
|
||||||
|
- one behavior target per method
|
||||||
|
- one-line docstring for full intent
|
||||||
|
|
||||||
|
## Authoring Rules
|
||||||
|
|
||||||
|
1. Keep scope focused on structure and naming in this prompt.
|
||||||
|
2. Do not fill test implementation details unless explicitly requested.
|
||||||
|
3. Preserve established repository conventions when they are already present.
|
||||||
|
4. If input constraints conflict, ask one concise clarifying question before editing.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
1. Discovery summary and references used.
|
||||||
|
2. Proposed or applied test tree.
|
||||||
|
3. Class and method naming map.
|
||||||
|
4. Validation command result.
|
||||||
|
5. Open questions only when they block completion.
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: copilot-customization
|
name: copilot-customization
|
||||||
description: 'Plan, create, review, and debug GitHub Copilot and VS Code agent customizations, including instructions, prompt files, skills, custom agents, hooks, MCP servers, and repo-specific personal-mcp skill integration.'
|
description: 'Plan, create, review, and debug GitHub Copilot and VS Code agent customizations, including instructions, prompt files, skills, custom agents, hooks, MCP servers, and repo-specific personal-mcp skill integration.'
|
||||||
argument-hint: 'What Copilot behavior are you customizing, and should it be workspace-scoped, personal, or exposed as an MCP skill resource?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: copilot-customization
|
id: copilot-customization
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -19,9 +18,6 @@ x-personal-mcp:
|
|||||||
- skills
|
- skills
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/copilot-customization/document
|
- resource://skills/copilot-customization/document
|
||||||
depends_on:
|
|
||||||
- new-skill
|
|
||||||
- zensical-docs
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Copilot Customization
|
# Copilot Customization
|
||||||
@@ -30,7 +26,12 @@ Use this skill when a task is about changing how GitHub Copilot or VS Code agent
|
|||||||
|
|
||||||
## When to Use
|
## When to Use
|
||||||
|
|
||||||
- Creating or updating `.github/copilot-instructions.md`, `AGENTS.md`, `CLAUDE.md`, or `*.instructions.md` files.
|
- Creating or updating:
|
||||||
|
- `.github/copilot-instructions.md`
|
||||||
|
- `AGENTS.md`
|
||||||
|
- `CLAUDE.md`
|
||||||
|
- `*.instructions.md` files
|
||||||
|
- `*.prompt.md` files
|
||||||
- Creating prompt files, custom agents, hooks, or Agent Skills.
|
- Creating prompt files, custom agents, hooks, or Agent Skills.
|
||||||
- Deciding whether behavior belongs in instructions, prompts, skills, agents, hooks, MCP servers, or agent plugins.
|
- Deciding whether behavior belongs in instructions, prompts, skills, agents, hooks, MCP servers, or agent plugins.
|
||||||
- Debugging why a customization is not discovered, loaded, or invoked.
|
- Debugging why a customization is not discovered, loaded, or invoked.
|
||||||
@@ -55,6 +56,60 @@ If the request is ambiguous, ask only for the missing axis that changes the file
|
|||||||
|
|
||||||
Use [VS Code customization references](./references/vscode-customization.md) for official-source details about locations, frontmatter, discovery behavior, priority, and troubleshooting.
|
Use [VS Code customization references](./references/vscode-customization.md) for official-source details about locations, frontmatter, discovery behavior, priority, and troubleshooting.
|
||||||
|
|
||||||
|
## Repo Shim Pattern For Personal MCP
|
||||||
|
|
||||||
|
Use a shim when you want another repository to consume this server as a preference and documentation source without duplicating methodology content.
|
||||||
|
|
||||||
|
### What the shim does
|
||||||
|
|
||||||
|
1. Tells the agent when to consult this MCP server.
|
||||||
|
2. Tells the agent how to retrieve relevant guidance.
|
||||||
|
3. Keeps repo-local behavior thin while canonical guidance stays in Personal MCP resources.
|
||||||
|
|
||||||
|
### Shim formats
|
||||||
|
|
||||||
|
Use either:
|
||||||
|
|
||||||
|
1. A repo instruction file (`*.instructions.md`) for always-on or file-scoped behavior.
|
||||||
|
2. A prompt file (`*.prompt.md`) for explicit, on-demand guidance retrieval.
|
||||||
|
|
||||||
|
### Retrieval strategies
|
||||||
|
|
||||||
|
Choose one of these patterns:
|
||||||
|
|
||||||
|
1. Direct URI strategy:
|
||||||
|
- Reference known resources directly, such as:
|
||||||
|
- `resource://catalog/skills_index`
|
||||||
|
- `resource://catalog/skills/{skill_id}`
|
||||||
|
- `resource://skills/<skill-id>/document`
|
||||||
|
- `resource://skills/<skill-id>/references/<ref-id>`
|
||||||
|
2. Discovery-first strategy:
|
||||||
|
- Start at catalog discovery (`resource://catalog/skills_index`), select the best skill match, then load the skill document and only the minimal references needed.
|
||||||
|
|
||||||
|
### Authoring guidance for shims
|
||||||
|
|
||||||
|
1. Keep shim content short and procedural; avoid copying large guidance blocks from Personal MCP.
|
||||||
|
2. State trigger conditions clearly (for example: "when creating a new skill" or "when editing docs contracts").
|
||||||
|
3. Specify whether to use direct URIs or discovery for that repo's common workflows.
|
||||||
|
4. Prefer loading only the most relevant skill document first; expand to references only when needed.
|
||||||
|
5. For stable repeated workflows, use explicit URIs. For broader or ambiguous requests, use discovery-first.
|
||||||
|
|
||||||
|
### Minimal shim examples
|
||||||
|
|
||||||
|
Instruction-style shim intent:
|
||||||
|
|
||||||
|
1. "For markdown edits (`applyTo: '**/*.md'`), load `resource://skills/zensical-docs/document` and apply Zensical-native documentation conventions unless they conflict with expected MkDocs compatibility."
|
||||||
|
|
||||||
|
Prompt-style shim intent:
|
||||||
|
|
||||||
|
1. "For docs authoring tasks, consult `resource://skills/zensical-docs/document`, summarize the relevant authoring constraints, then propose the smallest markdown change for this repository."
|
||||||
|
|
||||||
|
### Validation for shim implementation
|
||||||
|
|
||||||
|
1. Confirm the shim triggers in expected contexts.
|
||||||
|
2. Confirm resource loading path is unambiguous (direct URI or discovery).
|
||||||
|
3. Confirm repo-local customization remains thin and references Personal MCP as source of truth.
|
||||||
|
|
||||||
## Workspace Customization Workflow
|
## Workspace Customization Workflow
|
||||||
|
|
||||||
1. Identify the customization primitive and scope.
|
1. Identify the customization primitive and scope.
|
||||||
@@ -66,20 +121,6 @@ Use [VS Code customization references](./references/vscode-customization.md) for
|
|||||||
7. For skills, make the folder name match the `name` field exactly and reference any extra files from `SKILL.md` with relative links.
|
7. For skills, make the folder name match the `name` field exactly and reference any extra files from `SKILL.md` with relative links.
|
||||||
8. Validate placement, YAML frontmatter, discovery settings, and whether the customization should be workspace or user scoped.
|
8. Validate placement, YAML frontmatter, discovery settings, and whether the customization should be workspace or user scoped.
|
||||||
|
|
||||||
## Repo Integration Workflow
|
|
||||||
|
|
||||||
When adding a new skill to this `personal-mcp` repo, follow the resource-first pattern:
|
|
||||||
|
|
||||||
1. Search the catalog for `new skill` and load `resource://skills/new-skill/document`.
|
|
||||||
2. Create authored docs under `docs/skills/<skill-id>/SKILL.md`, with optional nested `references/` markdown files.
|
|
||||||
3. Keep `skill-id` stable and consistent across directory name, `name`, and `x-personal-mcp.id`.
|
|
||||||
4. Put discovery metadata in `SKILL.md` frontmatter under `x-personal-mcp`.
|
|
||||||
5. Declare `resource://skills/<skill-id>/document` in `x-personal-mcp.capabilities`.
|
|
||||||
6. Declare references in `x-personal-mcp.references` as `ref-id -> references/<file>.md` mappings.
|
|
||||||
7. Validate with the registry loader and `uv run zensical build`.
|
|
||||||
|
|
||||||
Keep runtime implementation registry-driven in `src/personal_mcp/mcp.py`; do not add per-skill Python server modules.
|
|
||||||
|
|
||||||
## Quality Checks
|
## Quality Checks
|
||||||
|
|
||||||
Before finishing:
|
Before finishing:
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: fastapi-async-sqlalchemy-modernization
|
name: fastapi-async-sqlalchemy-modernization
|
||||||
description: 'Create a step-by-step modernization plan for an existing FastAPI app using SQLAlchemy async patterns, context managers, and AsyncExitStack. Use when: planning migration from legacy DB setup, standardizing async engine/session lifecycles, defining transaction boundaries, and aligning with SQLAlchemy 2.x best practices.'
|
description: 'Create a step-by-step modernization plan for an existing FastAPI app using SQLAlchemy async patterns, context managers, and AsyncExitStack. Use when: planning migration from legacy DB setup, standardizing async engine/session lifecycles, defining transaction boundaries, and aligning with SQLAlchemy 2.x best practices.'
|
||||||
argument-hint: 'What is your current FastAPI + SQLAlchemy setup (sync/async driver, session pattern, lifespan usage, and deployment model)?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: fastapi-async-sqlalchemy-modernization
|
id: fastapi-async-sqlalchemy-modernization
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -9,10 +8,10 @@ x-personal-mcp:
|
|||||||
- fastapi
|
- fastapi
|
||||||
- sqlalchemy
|
- sqlalchemy
|
||||||
- async
|
- async
|
||||||
|
- asyncio
|
||||||
- modernization
|
- modernization
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/fastapi-async-sqlalchemy-modernization/document
|
- resource://skills/fastapi-async-sqlalchemy-modernization/document
|
||||||
depends_on: []
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# FastAPI Async SQLAlchemy Modernization Plan
|
# FastAPI Async SQLAlchemy Modernization Plan
|
||||||
@@ -63,6 +62,9 @@ Use these concepts as the planning backbone:
|
|||||||
7. Observability and resilience:
|
7. Observability and resilience:
|
||||||
Add pool/connection settings, logging, timeout, and health checks as first-class plan items.
|
Add pool/connection settings, logging, timeout, and health checks as first-class plan items.
|
||||||
See the [observability reference](references/observability.md).
|
See the [observability reference](references/observability.md).
|
||||||
|
8. SQLModel adoption where appropriate:
|
||||||
|
Prefer SQLModel for typed ORM models and API-facing data models when it reduces duplication, while preserving SQLAlchemy async lifecycle patterns.
|
||||||
|
See the [SQLModel integration reference](references/sqlmodel.md).
|
||||||
|
|
||||||
### Concept Reference Map
|
### Concept Reference Map
|
||||||
|
|
||||||
@@ -75,6 +77,7 @@ Use these concepts as the planning backbone:
|
|||||||
| Dependency injection | [Session management reference](references/session.md) |
|
| Dependency injection | [Session management reference](references/session.md) |
|
||||||
| Implicit I/O control in ORM | [Implicit I/O reference](references/implicit_io.md) |
|
| Implicit I/O control in ORM | [Implicit I/O reference](references/implicit_io.md) |
|
||||||
| Observability and resilience | [Observability reference](references/observability.md) |
|
| Observability and resilience | [Observability reference](references/observability.md) |
|
||||||
|
| SQLModel adoption where appropriate | [SQLModel integration reference](references/sqlmodel.md) |
|
||||||
|
|
||||||
## Decision Points
|
## Decision Points
|
||||||
|
|
||||||
@@ -86,6 +89,7 @@ Use these branching decisions before proposing migration steps.
|
|||||||
| ORM usage | Already ORM 2.x style (`select`, `session.execute`) | Legacy Query API: add compatibility stage and refactor incrementally |
|
| ORM usage | Already ORM 2.x style (`select`, `session.execute`) | Legacy Query API: add compatibility stage and refactor incrementally |
|
||||||
| Session scope | Request-scoped already | Global/shared sessions found: prioritize session-scope fix first |
|
| Session scope | Request-scoped already | Global/shared sessions found: prioritize session-scope fix first |
|
||||||
| Lifespan | Existing FastAPI lifespan hook | No lifespan hook: introduce lifespan before broader DB changes |
|
| Lifespan | Existing FastAPI lifespan hook | No lifespan hook: introduce lifespan before broader DB changes |
|
||||||
|
| Model layer | Existing SQLModel models fit roadmap | SQLAlchemy-only models: evaluate SQLModel adoption by bounded module |
|
||||||
| Concurrency | Background jobs/tasks use DB | No background DB use |
|
| Concurrency | Background jobs/tasks use DB | No background DB use |
|
||||||
| Transaction style | Explicit context-managed transactions | Implicit/autobegin side effects |
|
| Transaction style | Explicit context-managed transactions | Implicit/autobegin side effects |
|
||||||
|
|
||||||
@@ -116,6 +120,17 @@ Define one canonical model to migrate toward.
|
|||||||
|
|
||||||
Completion check: architecture diagram can explain where engine/session are created, used, and closed.
|
Completion check: architecture diagram can explain where engine/session are created, used, and closed.
|
||||||
|
|
||||||
|
### Step 1.5: Decide SQLModel Adoption Scope
|
||||||
|
|
||||||
|
Decide where SQLModel should be introduced during modernization.
|
||||||
|
|
||||||
|
- Prefer SQLModel when it reduces duplicated schema definitions between ORM entities and API data models.
|
||||||
|
- Keep SQLAlchemy async engine/session lifecycle as the runtime foundation.
|
||||||
|
- Use bounded adoption first (one module or feature area), then expand after validation.
|
||||||
|
- If project is already heavily SQLAlchemy-only and stable, document rationale for staying SQLAlchemy-only.
|
||||||
|
|
||||||
|
Completion check: plan includes an explicit SQLModel branch with target modules and non-goals.
|
||||||
|
|
||||||
### Step 2: Plan Engine Modernization
|
### Step 2: Plan Engine Modernization
|
||||||
|
|
||||||
Plan engine creation and pool behavior.
|
Plan engine creation and pool behavior.
|
||||||
@@ -199,6 +214,7 @@ Create modernization quality gates.
|
|||||||
- Concurrency tests confirming one-session-per-task behavior.
|
- Concurrency tests confirming one-session-per-task behavior.
|
||||||
- Lifespan tests verifying cleanup calls and ordering.
|
- Lifespan tests verifying cleanup calls and ordering.
|
||||||
- Health/readiness tests including DB connectivity checks.
|
- Health/readiness tests including DB connectivity checks.
|
||||||
|
- If SQLModel is adopted, model-validation tests cover SQLModel table models and API models at module boundaries.
|
||||||
|
|
||||||
Completion check: all quality gates pass under the target async configuration.
|
Completion check: all quality gates pass under the target async configuration.
|
||||||
|
|
||||||
@@ -223,6 +239,7 @@ A plan is complete only when it includes:
|
|||||||
- Explicit context-manager patterns for resource ownership.
|
- Explicit context-manager patterns for resource ownership.
|
||||||
- AsyncExitStack composition strategy.
|
- AsyncExitStack composition strategy.
|
||||||
- Transaction policy and exception behavior.
|
- Transaction policy and exception behavior.
|
||||||
|
- SQLModel adoption branch (use/adopt/defer) with rationale.
|
||||||
- Concrete tests and rollout checkpoints.
|
- Concrete tests and rollout checkpoints.
|
||||||
- A documented advisory backlog for non-critical implicit I/O improvements.
|
- A documented advisory backlog for non-critical implicit I/O improvements.
|
||||||
|
|
||||||
@@ -233,6 +250,7 @@ A plan is complete only when it includes:
|
|||||||
- Implicit commit/rollback behavior with unclear ownership.
|
- Implicit commit/rollback behavior with unclear ownership.
|
||||||
- Global mutable session state.
|
- Global mutable session state.
|
||||||
- Lifespan cleanup that depends on implicit garbage collection.
|
- Lifespan cleanup that depends on implicit garbage collection.
|
||||||
|
- Forcing SQLModel rewrites across the entire codebase in one phase without module-level rollout.
|
||||||
|
|
||||||
## Output Contract
|
## Output Contract
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ Purpose: concept registry for modernization guidance used by this skill.
|
|||||||
| Transaction boundaries | [transactions.md](transactions.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
| Transaction boundaries | [transactions.md](transactions.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||||
| Implicit ORM I/O under asyncio | [implicit_io.md](implicit_io.md) | adopted | advisory | platform/backend | 2026-06-17 |
|
| Implicit ORM I/O under asyncio | [implicit_io.md](implicit_io.md) | adopted | advisory | platform/backend | 2026-06-17 |
|
||||||
| Observability and resilience | [observability.md](observability.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
| Observability and resilience | [observability.md](observability.md) | adopted | mandatory | platform/backend | 2026-06-17 |
|
||||||
|
| SQLModel adoption and boundaries | [sqlmodel.md](sqlmodel.md) | adopted | advisory | platform/backend | 2026-06-26 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ async def get_db_session(
|
|||||||
session_factory: async_sessionmaker[AsyncSession] = Depends(get_session_factory),
|
session_factory: async_sessionmaker[AsyncSession] = Depends(get_session_factory),
|
||||||
) -> AsyncIterator[AsyncSession]:
|
) -> AsyncIterator[AsyncSession]:
|
||||||
async with session_factory() as session:
|
async with session_factory() as session:
|
||||||
yield session
|
yield session
|
||||||
```
|
```
|
||||||
|
|
||||||
Route usage:
|
Route usage:
|
||||||
@@ -72,8 +72,8 @@ router = APIRouter()
|
|||||||
@router.post("/items")
|
@router.post("/items")
|
||||||
async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
|
async def create_item(session: AsyncSession = Depends(get_db_session)) -> dict:
|
||||||
async with session.begin():
|
async with session.begin():
|
||||||
# write operations here
|
# write operations here
|
||||||
...
|
...
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -98,6 +98,12 @@ Notes:
|
|||||||
- `expire_on_commit=False` is commonly preferred in asyncio applications to reduce accidental post-commit reload behavior.
|
- `expire_on_commit=False` is commonly preferred in asyncio applications to reduce accidental post-commit reload behavior.
|
||||||
- `AsyncSession.refresh()` is preferred over broad expiration patterns when state refresh is needed.
|
- `AsyncSession.refresh()` is preferred over broad expiration patterns when state refresh is needed.
|
||||||
|
|
||||||
|
## SQLModel Alignment
|
||||||
|
|
||||||
|
- If using SQLModel, keep the same session ownership model: one `async_sessionmaker`, one `AsyncSession` per request/unit-of-work.
|
||||||
|
- SQLModel does not replace SQLAlchemy async lifecycle primitives; it complements model declaration and typed data handling.
|
||||||
|
- During migration, avoid mixed ad hoc patterns where some handlers create SQLAlchemy sessions directly while others use SQLModel-specific wrappers.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Concurrency Rules
|
## Concurrency Rules
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# SQLModel Adoption and Boundaries
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [SQLModel documentation](https://sqlmodel.tiangolo.com/)
|
||||||
|
- [SQLModel FastAPI session dependency tutorial](https://sqlmodel.tiangolo.com/tutorial/fastapi/session-with-dependency/)
|
||||||
|
- [SQLModel release notes](https://sqlmodel.tiangolo.com/release-notes/)
|
||||||
|
- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/21/orm/extensions/asyncio.html)
|
||||||
|
|
||||||
|
??? abstract "Decision metadata"
|
||||||
|
- Status: adopted
|
||||||
|
- Decision level: advisory
|
||||||
|
- Applies to: api-runtime, workers, tests
|
||||||
|
- Last reviewed: 2026-06-26
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Define when and how to use SQLModel in an async FastAPI + SQLAlchemy modernization effort.
|
||||||
|
|
||||||
|
The goal is pragmatic adoption: use SQLModel where it reduces model duplication and improves typing ergonomics, without disrupting established async engine/session lifecycle rules.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope and Non-Goals
|
||||||
|
|
||||||
|
- In scope: model-layer decisions, integration boundaries, phased adoption strategy.
|
||||||
|
- Out of scope: full framework rewrites and all-at-once model migration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- Keep SQLAlchemy async primitives as the runtime base: `create_async_engine`, `async_sessionmaker`, and `AsyncSession`.
|
||||||
|
- Prefer SQLModel for new domain modules where table models and API schemas would otherwise be duplicated.
|
||||||
|
- Migrate by bounded module or feature area; do not force whole-repo conversion in one phase.
|
||||||
|
- Keep transaction and session ownership policies identical whether models are SQLAlchemy Declarative or SQLModel.
|
||||||
|
- Document explicit reasons when SQLModel is deferred for a module.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Patterns
|
||||||
|
|
||||||
|
### Pattern A: Bounded module adoption
|
||||||
|
|
||||||
|
- Choose one feature slice (for example, billing, projects, or auth profile data).
|
||||||
|
- Introduce SQLModel models for that slice only.
|
||||||
|
- Keep unchanged modules on existing SQLAlchemy models until a dedicated migration phase.
|
||||||
|
|
||||||
|
### Pattern B: Data model split for API boundaries
|
||||||
|
|
||||||
|
Use distinct models for persistence and external contracts.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserBase(SQLModel):
|
||||||
|
email: str
|
||||||
|
display_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class User(UserBase, table=True):
|
||||||
|
id: int | None = Field(default=None, primary_key=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(UserBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(UserBase):
|
||||||
|
id: int
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern C: Keep async lifecycle unchanged
|
||||||
|
|
||||||
|
```python
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||||
|
session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Interoperability Notes
|
||||||
|
|
||||||
|
- SQLModel is designed as a thin layer over SQLAlchemy and Pydantic, so mixed codebases are expected during migration.
|
||||||
|
- Prefer one query style per module to reduce cognitive overhead.
|
||||||
|
- Keep loader strategies explicit in async paths to avoid implicit I/O surprises.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Anti-Patterns
|
||||||
|
|
||||||
|
- Treating SQLModel adoption as equivalent to async-session modernization.
|
||||||
|
- Rewriting all models at once without rollback checkpoints.
|
||||||
|
- Introducing SQLModel in handlers while keeping old global/shared session patterns.
|
||||||
|
- Mixing multiple query/session idioms within the same module without clear conventions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operational Checks
|
||||||
|
|
||||||
|
- Modernized module documents whether it is SQLModel-first or SQLAlchemy-only.
|
||||||
|
- Session/transaction ownership remains consistent across both model styles.
|
||||||
|
- New model modules use explicit API boundary models where needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Checks
|
||||||
|
|
||||||
|
- Module-level tests verify CRUD semantics for adopted SQLModel models.
|
||||||
|
- API tests verify response/request model behavior for SQLModel-based endpoints.
|
||||||
|
- Regression tests confirm unchanged modules continue to function during phased rollout.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
- Start with low-risk bounded domains.
|
||||||
|
- Expand only after validation of session lifecycle, transaction behavior, and endpoint correctness.
|
||||||
|
- Maintain a tracked backlog of deferred modules with rationale and planned phase.
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: fastapi-uv-docker
|
name: fastapi-uv-docker
|
||||||
description: 'Audit and migrate an existing Python project to best practices for a cloud-native ASGI FastAPI app managed with uv and run with uvicorn in Docker. Use when: conforming a project to production standards, setting up src layout, configuring pyproject.toml, writing multi-stage Dockerfiles, wiring lifespan and settings, adding health endpoints, enforcing non-root container user, migrating from requirements.txt to uv.'
|
description: 'Audit and migrate an existing Python project to best practices for a cloud-native ASGI FastAPI app managed with uv and run with uvicorn in Docker. Use when: conforming a project to production standards, setting up src layout, configuring pyproject.toml, writing multi-stage Dockerfiles, wiring lifespan and settings, adding health endpoints, enforcing non-root container user, migrating from requirements.txt to uv.'
|
||||||
argument-hint: 'What is the current state of the project (bare Python, requirements.txt, pip, etc.)?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: fastapi-uv-docker
|
id: fastapi-uv-docker
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
tags:
|
tags:
|
||||||
- fastapi
|
- fastapi
|
||||||
- uv
|
- uv
|
||||||
|
- uvicorn
|
||||||
- docker
|
- docker
|
||||||
|
- architecture
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/fastapi-uv-docker/document
|
- resource://skills/fastapi-uv-docker/document
|
||||||
depends_on: []
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# FastAPI Project Best Practices
|
# FastAPI Project Best Practices
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: mcp-details
|
||||||
|
description: "Reference hub for MCP and FastMCP source documentation links. Use when you need authoritative protocol, SDK, transport, and deployment docs without loading broad implementation guidance."
|
||||||
|
x-personal-mcp:
|
||||||
|
id: mcp-details
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- mcp
|
||||||
|
- model-context-protocol
|
||||||
|
- fastmcp
|
||||||
|
- references
|
||||||
|
- source-docs
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/mcp-details/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP Details
|
||||||
|
|
||||||
|
This skill is a reference index only. It is optimized for fast retrieval of upstream documentation links for MCP and FastMCP.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- You need official MCP protocol and architecture docs.
|
||||||
|
- You need MCP SDK links for Python or TypeScript.
|
||||||
|
- You need FastMCP docs and source references.
|
||||||
|
- You need ecosystem links for tooling, inspection, and client configuration.
|
||||||
|
|
||||||
|
## How To Use This Skill
|
||||||
|
|
||||||
|
1. Classify the request by intent: protocol, SDK usage, FastMCP, or ecosystem integration.
|
||||||
|
2. Open only the matching reference page first.
|
||||||
|
3. Load at most one additional reference page if the request spans multiple areas.
|
||||||
|
4. Return links grouped by category, with a one-line reason for each group.
|
||||||
|
|
||||||
|
## Intent Router
|
||||||
|
|
||||||
|
1. MCP fundamentals, protocol architecture, resources, tools, prompts, transports, security: [mcp-protocol-and-spec.md](./references/mcp-protocol-and-spec.md)
|
||||||
|
2. MCP SDK and FastMCP implementation references for Python and TypeScript: [sdk-and-fastmcp.md](./references/sdk-and-fastmcp.md)
|
||||||
|
3. MCP client integration and operational tooling references: [ecosystem-and-tooling.md](./references/ecosystem-and-tooling.md)
|
||||||
|
|
||||||
|
## Load Order
|
||||||
|
|
||||||
|
1. Start with the single best-match reference page from the Intent Router.
|
||||||
|
2. If the question includes both protocol and implementation details, load [mcp-protocol-and-spec.md](./references/mcp-protocol-and-spec.md) then [sdk-and-fastmcp.md](./references/sdk-and-fastmcp.md).
|
||||||
|
3. Load [ecosystem-and-tooling.md](./references/ecosystem-and-tooling.md) only when the request includes client setup, inspector usage, or deployment/operations context.
|
||||||
|
|
||||||
|
## Load Budget
|
||||||
|
|
||||||
|
1. Single-focus request: 1 reference page.
|
||||||
|
2. Mixed protocol and implementation request: 2 reference pages.
|
||||||
|
3. Broad audit or migration planning request: up to 3 reference pages.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
When this skill is applied, return:
|
||||||
|
1. Which reference files were consulted.
|
||||||
|
2. The discovery path used (intent classification and load order).
|
||||||
|
3. Curated source-document links grouped by topic.
|
||||||
|
4. Any notable gaps or ambiguities in the currently indexed links.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Ecosystem and Tooling
|
||||||
|
|
||||||
|
Use this page for MCP client setup, operational tools, and integration references.
|
||||||
|
|
||||||
|
## VS Code and Copilot MCP Integration
|
||||||
|
|
||||||
|
!!! info "VS Code MCP docs"
|
||||||
|
- [VS Code MCP servers overview](https://code.visualstudio.com/docs/agent-customization/mcp-servers)
|
||||||
|
- [VS Code MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
|
||||||
|
- [VS Code Copilot customization overview](https://code.visualstudio.com/docs/copilot/customization/overview)
|
||||||
|
|
||||||
|
## Debugging and Inspection
|
||||||
|
|
||||||
|
!!! info "Inspector and diagnostics"
|
||||||
|
- [MCP inspector repository](https://github.com/modelcontextprotocol/inspector)
|
||||||
|
- [MCP protocol repository issues](https://github.com/modelcontextprotocol/spec/issues)
|
||||||
|
- [Python logging configuration docs](https://docs.python.org/3/library/logging.config.html)
|
||||||
|
|
||||||
|
## Runtime and API Framework References
|
||||||
|
|
||||||
|
!!! info "Runtime references"
|
||||||
|
- [FastAPI documentation](https://fastapi.tiangolo.com/)
|
||||||
|
- [Uvicorn settings](https://www.uvicorn.org/settings/)
|
||||||
|
- [AnyIO documentation](https://anyio.readthedocs.io/en/stable/)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Use these links when tasks include IDE wiring, MCP server runtime setup, or production operations.
|
||||||
|
- Keep protocol and SDK references separate to avoid overloading implementation prompts.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# MCP Protocol and Specification
|
||||||
|
|
||||||
|
Use this page for authoritative links about MCP concepts, protocol shape, and official specification assets.
|
||||||
|
|
||||||
|
## Official Documentation
|
||||||
|
|
||||||
|
!!! info "MCP docs"
|
||||||
|
- [MCP introduction](https://modelcontextprotocol.io/docs/getting-started/intro)
|
||||||
|
- [Architecture overview](https://modelcontextprotocol.io/docs/learn/architecture)
|
||||||
|
- [Server concepts](https://modelcontextprotocol.io/docs/learn/server-concepts)
|
||||||
|
- [Client concepts](https://modelcontextprotocol.io/docs/learn/client-concepts)
|
||||||
|
- [Security overview](https://modelcontextprotocol.io/docs/learn/security-overview)
|
||||||
|
|
||||||
|
## Protocol and Schema Sources
|
||||||
|
|
||||||
|
!!! info "Specification repositories"
|
||||||
|
- [MCP specification repository](https://github.com/modelcontextprotocol/spec)
|
||||||
|
- [Specification schema directory](https://github.com/modelcontextprotocol/spec/tree/main/schema)
|
||||||
|
- [Specification issues and proposals](https://github.com/modelcontextprotocol/spec/issues)
|
||||||
|
|
||||||
|
## Core Capability References
|
||||||
|
|
||||||
|
!!! info "Capability details"
|
||||||
|
- [Resources concept docs](https://modelcontextprotocol.io/docs/learn/server-concepts#resources)
|
||||||
|
- [Tools concept docs](https://modelcontextprotocol.io/docs/learn/server-concepts#tools)
|
||||||
|
- [Prompt objects concept docs](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts)
|
||||||
|
- [Sampling concept docs](https://modelcontextprotocol.io/docs/learn/client-concepts)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Prefer these links when the user asks about protocol correctness, transport semantics, capability naming, or compatibility.
|
||||||
|
- For implementation-level examples, use [sdk-and-fastmcp.md](./sdk-and-fastmcp.md).
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# SDK and FastMCP
|
||||||
|
|
||||||
|
Use this page for implementation-oriented links across MCP SDKs and FastMCP.
|
||||||
|
|
||||||
|
## MCP SDKs
|
||||||
|
|
||||||
|
!!! info "SDK sources"
|
||||||
|
- [Python SDK repository](https://github.com/modelcontextprotocol/python-sdk)
|
||||||
|
- [TypeScript SDK repository](https://github.com/modelcontextprotocol/typescript-sdk)
|
||||||
|
- [Python SDK documentation](https://modelcontextprotocol.github.io/python-sdk/)
|
||||||
|
|
||||||
|
## FastMCP
|
||||||
|
|
||||||
|
!!! info "FastMCP sources"
|
||||||
|
- [FastMCP project documentation](https://gofastmcp.com/)
|
||||||
|
- [FastMCP GitHub repository](https://github.com/jlowin/fastmcp)
|
||||||
|
- [FastMCP examples directory](https://github.com/jlowin/fastmcp/tree/main/examples)
|
||||||
|
- [FastMCP PyPI package](https://pypi.org/project/fastmcp/)
|
||||||
|
|
||||||
|
## Server Implementation Patterns
|
||||||
|
|
||||||
|
!!! info "Implementation references"
|
||||||
|
- [MCP server concepts](https://modelcontextprotocol.io/docs/learn/server-concepts)
|
||||||
|
- [MCP architecture patterns](https://modelcontextprotocol.io/docs/learn/architecture)
|
||||||
|
- [Python packaging and resources](https://docs.python.org/3/library/importlib.resources.html)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Prefer official SDK repositories for API shape and compatibility checks.
|
||||||
|
- Use FastMCP references for rapid server scaffolding and implementation examples.
|
||||||
|
- For protocol-first questions, start from [mcp-protocol-and-spec.md](./mcp-protocol-and-spec.md).
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
name: new-skill
|
|
||||||
description: Provide a practical checklist and baseline template for creating a new docs-first MCP skill in this repository.
|
|
||||||
argument-hint: What skill are you creating, and what problem should it solve?
|
|
||||||
x-personal-mcp:
|
|
||||||
id: new-skill
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- fastmcp
|
|
||||||
- bootstrap
|
|
||||||
- scaffolding
|
|
||||||
- skills
|
|
||||||
- mcp
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/new-skill/document
|
|
||||||
depends_on: []
|
|
||||||
references: {}
|
|
||||||
---
|
|
||||||
|
|
||||||
# New Skill Bootstrap
|
|
||||||
|
|
||||||
Use this skill to bootstrap a new skill in the docs-first architecture.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
1. Create docs under docs/skills/<skill-id>/.
|
|
||||||
2. Define SKILL frontmatter with Anthropic and x-personal-mcp fields.
|
|
||||||
3. Declare references via x-personal-mcp.references when needed.
|
|
||||||
4. Validate the docs build and MCP resource reads.
|
|
||||||
|
|
||||||
## Authoring Checklist
|
|
||||||
|
|
||||||
1. Create docs/skills/<skill-id>/SKILL.md.
|
|
||||||
2. Add docs/skills/<skill-id>/references/ files as needed.
|
|
||||||
3. Keep skill id and directory name aligned.
|
|
||||||
4. Keep frontmatter name equal to x-personal-mcp.id.
|
|
||||||
5. Include resource://skills/<skill-id>/document in capabilities.
|
|
||||||
|
|
||||||
## Validation
|
|
||||||
|
|
||||||
1. uv run zensical build
|
|
||||||
2. uv run pytest -q
|
|
||||||
@@ -1,18 +1,17 @@
|
|||||||
---
|
---
|
||||||
name: nicegui-ui-customization
|
name: nicegui-ui-customization
|
||||||
description: 'Design and implement production NiceGUI UIs with reusable components, Tailwind-first styling, event-driven interactions, and troubleshooting for uploads, state, and static assets. Use when building or refactoring NiceGUI pages and interaction flows.'
|
description: 'Design and implement production NiceGUI UIs with reusable components, Tailwind-first styling, event-driven interactions, and troubleshooting for uploads, state, and static assets. Use when building or refactoring NiceGUI pages and interaction flows.'
|
||||||
argument-hint: 'What UI outcome should this workflow produce?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: nicegui-ui-customization
|
id: nicegui-ui-customization
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
tags:
|
tags:
|
||||||
- nicegui
|
- nicegui
|
||||||
|
- fastapi
|
||||||
- ui
|
- ui
|
||||||
- customization
|
- customization
|
||||||
- frontend
|
- frontend
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/nicegui-ui-customization/document
|
- resource://skills/nicegui-ui-customization/document
|
||||||
depends_on: []
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# NiceGUI UI Customization Workflow
|
# NiceGUI UI Customization Workflow
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: nicegui
|
name: nicegui
|
||||||
description: 'Design and scaffold a production-ready NiceGUI + FastAPI application architecture. Use for multi-page app planning, package boundaries, optional DB/LangGraph/docs integration, and implementation checklists.'
|
description: 'Design and scaffold a production-ready NiceGUI + FastAPI application architecture. Use for multi-page app planning, package boundaries, optional DB/LangGraph/docs integration, and implementation checklists.'
|
||||||
argument-hint: 'What should this app include (pages, DB, AI, docs, constraints)?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: nicegui
|
id: nicegui
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -12,7 +11,6 @@ x-personal-mcp:
|
|||||||
- architecture
|
- architecture
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/nicegui/document
|
- resource://skills/nicegui/document
|
||||||
depends_on: []
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# NiceGUI
|
# NiceGUI
|
||||||
@@ -204,4 +202,5 @@ Return:
|
|||||||
## References
|
## References
|
||||||
|
|
||||||
- Architecture and integration details: [NiceGUI architecture reference](./references/architecture.md)
|
- Architecture and integration details: [NiceGUI architecture reference](./references/architecture.md)
|
||||||
|
- Dataclass binding deep dive: [Bindable dataclasses in NiceGUI](./references/binding-dataclasses.md)
|
||||||
- Source documentation links: [NiceGUI source documentation](./references/source-documentation.md)
|
- Source documentation links: [NiceGUI source documentation](./references/source-documentation.md)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Binding Dataclasses Deep Dive
|
||||||
|
|
||||||
|
This reference explains how to model state with NiceGUI bindable dataclasses and how to avoid common update and performance pitfalls.
|
||||||
|
|
||||||
|
## Primary Sources
|
||||||
|
|
||||||
|
- NiceGUI binding docs: [Binding properties](https://www.nicegui.io/documentation/section_binding_properties)
|
||||||
|
- Python dataclass docs: [dataclasses module](https://docs.python.org/3/library/dataclasses.html)
|
||||||
|
- Data class design rationale: [PEP 557](https://peps.python.org/pep-0557/)
|
||||||
|
|
||||||
|
## What bindable_dataclass changes
|
||||||
|
|
||||||
|
`@binding.bindable_dataclass` extends standard dataclasses by turning fields into bindable properties so UI bindings can propagate immediately when a field is assigned.
|
||||||
|
|
||||||
|
Baseline pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import binding, ui
|
||||||
|
|
||||||
|
@binding.bindable_dataclass
|
||||||
|
class Profile:
|
||||||
|
name: str = 'Ada'
|
||||||
|
age: int = 37
|
||||||
|
|
||||||
|
profile = Profile()
|
||||||
|
|
||||||
|
ui.input('Name').bind_value(profile, 'name')
|
||||||
|
ui.number('Age', min=0).bind_value(profile, 'age')
|
||||||
|
ui.label().bind_text_from(profile, 'name', backward=lambda n: f'User: {n}')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Propagation model and performance
|
||||||
|
|
||||||
|
NiceGUI distinguishes between two link types:
|
||||||
|
|
||||||
|
- Bindable properties: efficient, event-like propagation on assignment.
|
||||||
|
- Active links: polled in a refresh loop (default every 0.1s).
|
||||||
|
|
||||||
|
Practical implications:
|
||||||
|
|
||||||
|
- Prefer bindable dataclasses for frequently updated form state.
|
||||||
|
- Keep transform functions pure and side-effect free.
|
||||||
|
- If many active links exist, tune `binding_refresh_interval` in `ui.run(...)` carefully.
|
||||||
|
|
||||||
|
## Dataclass modeling rules that matter for binding
|
||||||
|
|
||||||
|
- Use `field(default_factory=...)` for mutable defaults.
|
||||||
|
- Avoid `frozen=True` for models that should be edited from UI controls.
|
||||||
|
- Use `slots=True` only when you have confirmed compatibility with your inheritance and extension needs.
|
||||||
|
- Keep UI-editable fields explicit and typed.
|
||||||
|
|
||||||
|
Example with safe mutable defaults:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import field
|
||||||
|
from nicegui import binding
|
||||||
|
|
||||||
|
@binding.bindable_dataclass
|
||||||
|
class Filters:
|
||||||
|
query: str = ''
|
||||||
|
tags: list[str] = field(default_factory=list)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nested structures and binding paths
|
||||||
|
|
||||||
|
NiceGUI supports nested key paths via tuples for nested data (for example dictionaries and nested structures).
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import ui
|
||||||
|
|
||||||
|
data = {'user': {'name': 'Ada'}}
|
||||||
|
|
||||||
|
ui.input('Name').bind_value(data, ('user', 'name'))
|
||||||
|
ui.label().bind_text_from(data, ('user', 'name'))
|
||||||
|
```
|
||||||
|
|
||||||
|
When using nested dataclasses, keep updates explicit and predictable at the field level.
|
||||||
|
|
||||||
|
## Strictness and refactor safety
|
||||||
|
|
||||||
|
Binding can warn when attributes do not exist.
|
||||||
|
|
||||||
|
- Object attributes are checked by default.
|
||||||
|
- Dictionary keys are not checked by default.
|
||||||
|
- Use `strict=True` when you want missing-key warnings for dict-backed state.
|
||||||
|
|
||||||
|
```python
|
||||||
|
from nicegui import app, ui
|
||||||
|
|
||||||
|
ui.input().bind_value(app.storage.user, 'display_name', strict=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common pitfalls and safer alternatives
|
||||||
|
|
||||||
|
- Pitfall: mutating nested mutable values in place and expecting immediate UI sync.
|
||||||
|
- Safer alternative: assign back to the bound field after updates so change propagation is explicit.
|
||||||
|
|
||||||
|
- Pitfall: heavy transform functions in bindings.
|
||||||
|
- Safer alternative: keep transformations cheap and deterministic; move heavy work to event handlers.
|
||||||
|
|
||||||
|
- Pitfall: one model shared across unrelated pages or users.
|
||||||
|
- Safer alternative: scope model instances to page/client/user context as needed.
|
||||||
|
|
||||||
|
## Version notes to remember
|
||||||
|
|
||||||
|
- `bindable_dataclass` added in NiceGUI 2.11.0.
|
||||||
|
- Binding `strict` behavior documented as added in NiceGUI 3.0.0.
|
||||||
|
- Tuple paths for nested properties documented as added in NiceGUI 3.10.0.
|
||||||
|
- Depth-first binding propagation update documented in NiceGUI 2.16.0.
|
||||||
|
|
||||||
|
Verify behavior against the NiceGUI version pinned in your project before relying on version-specific semantics.
|
||||||
|
|
||||||
|
## Quick checklist
|
||||||
|
|
||||||
|
- Choose bindable dataclasses for interactive form-like state.
|
||||||
|
- Use `default_factory` for mutable fields.
|
||||||
|
- Keep transform functions pure.
|
||||||
|
- Use strict mode intentionally.
|
||||||
|
- Re-check version notes before migration work.
|
||||||
@@ -26,8 +26,15 @@ Use these links for framework-specific details.
|
|||||||
|
|
||||||
!!! info "NiceGUI sources"
|
!!! info "NiceGUI sources"
|
||||||
- [Pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
- [Pages, routing, and FastAPI integration](https://www.nicegui.io/documentation/section_pages_routing)
|
||||||
|
- [Binding properties and bindable dataclass](https://www.nicegui.io/documentation/section_binding_properties)
|
||||||
- [Security best practices](https://www.nicegui.io/documentation/section_security)
|
- [Security best practices](https://www.nicegui.io/documentation/section_security)
|
||||||
|
|
||||||
|
## Python Dataclasses
|
||||||
|
|
||||||
|
!!! info "Python sources"
|
||||||
|
- [dataclasses module reference](https://docs.python.org/3/library/dataclasses.html)
|
||||||
|
- [PEP 557: Data Classes](https://peps.python.org/pep-0557/)
|
||||||
|
|
||||||
## LangGraph
|
## LangGraph
|
||||||
|
|
||||||
!!! info "LangGraph sources"
|
!!! info "LangGraph sources"
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
---
|
||||||
|
name: pydantic-settings
|
||||||
|
description: "Practical guide for implementing typed application configuration with pydantic-settings. Use when designing BaseSettings models, choosing env naming strategy, configuring dotenv or secrets, and customizing source priority safely."
|
||||||
|
x-personal-mcp:
|
||||||
|
id: pydantic-settings
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- pydantic
|
||||||
|
- pydantic-settings
|
||||||
|
- configuration
|
||||||
|
- env-vars
|
||||||
|
- secrets
|
||||||
|
- dotenv
|
||||||
|
- source-priority
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/pydantic-settings/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pydantic Settings Implementation Guide
|
||||||
|
|
||||||
|
Use this skill to implement robust, typed application configuration with `pydantic-settings` in production Python services.
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- You need a single typed configuration model for app settings.
|
||||||
|
- You are migrating from ad-hoc `os.getenv(...)` calls.
|
||||||
|
- You need predictable precedence across init args, env vars, dotenv files, and secrets.
|
||||||
|
- You need nested settings models and reliable parsing behavior.
|
||||||
|
- You need to customize settings sources or source order safely.
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
### 1. Baseline Model
|
||||||
|
|
||||||
|
Create a single settings model for the service boundary:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseSettings(BaseModel):
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5432
|
||||||
|
user: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
debug: bool = False
|
||||||
|
log_level: str = "info"
|
||||||
|
database: DatabaseSettings
|
||||||
|
api_key: str = Field(validation_alias="MY_API_KEY")
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Required fields fail fast when missing.
|
||||||
|
2. Defaults are intentional and safe.
|
||||||
|
|
||||||
|
### 2. Pick Env Naming Rules
|
||||||
|
|
||||||
|
1. Choose one prefix and apply it consistently.
|
||||||
|
2. Use aliases only for compatibility or external contracts.
|
||||||
|
3. Document whether env names are case-sensitive.
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Team can derive env variable names without guessing.
|
||||||
|
2. Legacy names are supported only where needed.
|
||||||
|
|
||||||
|
### 3. Decide Nested Parsing
|
||||||
|
|
||||||
|
For nested models via env vars, configure delimiters intentionally:
|
||||||
|
|
||||||
|
```python
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_nested_delimiter="__",
|
||||||
|
env_nested_max_split=1,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical vars:
|
||||||
|
|
||||||
|
1. `APP_DATABASE={"host": "db", "port": 5432, "user": "svc", "password": "pw"}`
|
||||||
|
2. `APP_DATABASE__HOST=db.internal`
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Nested overrides behave as expected.
|
||||||
|
2. Delimiter choice does not collide with field names.
|
||||||
|
|
||||||
|
### 4. Confirm Source Priority
|
||||||
|
|
||||||
|
Default priority (higher first):
|
||||||
|
|
||||||
|
1. CLI args (if enabled)
|
||||||
|
2. init kwargs
|
||||||
|
3. env vars
|
||||||
|
4. dotenv
|
||||||
|
5. secrets dir
|
||||||
|
6. defaults
|
||||||
|
|
||||||
|
Only customize when required:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from pydantic_settings import PydanticBaseSettingsSource
|
||||||
|
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def settings_customise_sources(
|
||||||
|
cls,
|
||||||
|
settings_cls: type[BaseSettings],
|
||||||
|
init_settings: PydanticBaseSettingsSource,
|
||||||
|
env_settings: PydanticBaseSettingsSource,
|
||||||
|
dotenv_settings: PydanticBaseSettingsSource,
|
||||||
|
file_secret_settings: PydanticBaseSettingsSource,
|
||||||
|
) -> tuple[PydanticBaseSettingsSource, ...]:
|
||||||
|
return (init_settings, env_settings, dotenv_settings, file_secret_settings)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Priority order is explicit in code.
|
||||||
|
2. Tests verify conflict resolution.
|
||||||
|
|
||||||
|
### 5. Add Secrets Strategy
|
||||||
|
|
||||||
|
1. In local development, dotenv is acceptable for non-production values.
|
||||||
|
2. In deployed environments, prefer env vars or secret managers.
|
||||||
|
3. For file-mounted secrets, use `secrets_dir`.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="APP_",
|
||||||
|
env_file=".env",
|
||||||
|
secrets_dir="/run/secrets",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. No secret literals in repository code.
|
||||||
|
2. Missing secrets behavior is understood per environment.
|
||||||
|
|
||||||
|
### 6. Add ContextVar-Scoped Constructors And Accessors
|
||||||
|
|
||||||
|
When configuration and database resources should be request- or context-scoped, use `ContextVar` backed constructor and accessor methods.
|
||||||
|
|
||||||
|
Example pattern:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from contextvars import ContextVar
|
||||||
|
from functools import cache
|
||||||
|
|
||||||
|
from pydantic import SecretStr
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
from sqlmodel import Session, create_engine
|
||||||
|
from sqlalchemy import Engine
|
||||||
|
|
||||||
|
|
||||||
|
class DbSettings(BaseSettings):
|
||||||
|
model_config = {
|
||||||
|
"env_prefix": "DB_",
|
||||||
|
"extra": "ignore",
|
||||||
|
}
|
||||||
|
|
||||||
|
host: str = "localhost"
|
||||||
|
port: int = 5432
|
||||||
|
username: str
|
||||||
|
password: SecretStr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def dsn(self) -> str:
|
||||||
|
return (
|
||||||
|
"postgresql://"
|
||||||
|
f"{self.username}:{self.password.get_secret_value()}"
|
||||||
|
f"@{self.host}:{self.port}/mydatabase"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_db_settings: ContextVar[DbSettings | None] = ContextVar("db_settings", default=None)
|
||||||
|
_db_conn: ContextVar[Engine | None] = ContextVar("db_conn", default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_settings(**kwargs) -> DbSettings:
|
||||||
|
settings = _db_settings.get()
|
||||||
|
if settings is None:
|
||||||
|
settings = DbSettings(**kwargs)
|
||||||
|
_db_settings.set(settings)
|
||||||
|
cleanup_engine()
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@cache
|
||||||
|
def get_db_engine() -> Engine:
|
||||||
|
engine = _db_conn.get()
|
||||||
|
if engine is None:
|
||||||
|
engine = create_engine(get_db_settings().dsn)
|
||||||
|
_db_conn.set(engine)
|
||||||
|
return engine
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_engine() -> None:
|
||||||
|
engine = _db_conn.get()
|
||||||
|
if engine is not None:
|
||||||
|
engine.dispose()
|
||||||
|
_db_conn.set(None)
|
||||||
|
get_db_engine.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def get_session():
|
||||||
|
with Session(get_db_engine()) as session:
|
||||||
|
yield session
|
||||||
|
```
|
||||||
|
|
||||||
|
Design notes:
|
||||||
|
|
||||||
|
1. `get_db_settings` is the constructor/accessor for settings and can accept explicit overrides in tests.
|
||||||
|
2. `get_db_engine` is the constructor/accessor for the engine and reuses context-local state.
|
||||||
|
3. `cleanup_engine` must run when settings change so stale DSNs do not leak across contexts.
|
||||||
|
4. `get_session` centralizes session creation so call sites never build engines directly.
|
||||||
|
|
||||||
|
Quality gate:
|
||||||
|
|
||||||
|
1. Overriding settings triggers engine cleanup and cache invalidation.
|
||||||
|
2. No module-level global engine is created outside accessors.
|
||||||
|
3. Session creation always goes through `get_session()`.
|
||||||
|
|
||||||
|
### 7. Add Focused Resource-Lifecycle Test
|
||||||
|
|
||||||
|
Do not add tests that re-validate baseline `pydantic-settings` functionality (for example env parsing, alias semantics, or source precedence) unless you have custom behavior layered on top.
|
||||||
|
|
||||||
|
Minimum test to add (only when an engine accessor exists):
|
||||||
|
|
||||||
|
1. assert the database engine is not instantiated more than once for repeated accessor calls in the same lifecycle/context
|
||||||
|
|
||||||
|
If the project has no database engine accessor, skip this section.
|
||||||
|
|
||||||
|
Suggested invocation:
|
||||||
|
|
||||||
|
1. `uv run pytest -q`
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. A single typed settings model exists for the service boundary.
|
||||||
|
2. Source precedence is documented and tested.
|
||||||
|
3. Env naming conventions and aliases are explicit and stable.
|
||||||
|
4. Nested parsing behavior is tested when custom parsing behavior is added.
|
||||||
|
5. Secrets and dotenv usage are environment-appropriate and do not leak sensitive defaults.
|
||||||
|
6. Validation errors are actionable and fail fast for required values.
|
||||||
|
7. If an engine accessor exists, engine construction occurs at most once per lifecycle/context.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
When this skill is applied, return:
|
||||||
|
|
||||||
|
1. Which references were consulted.
|
||||||
|
2. The chosen source-precedence model and why.
|
||||||
|
3. The exact parsing and alias decisions made.
|
||||||
|
4. Any deferred choices and their risk.
|
||||||
|
5. The validation commands or tests run to confirm behavior.
|
||||||
|
|
||||||
|
Use these upstream docs when implementing or reviewing `pydantic-settings` behavior.
|
||||||
|
|
||||||
|
## Source Docs
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
|
||||||
|
- [Settings Management](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/)
|
||||||
|
- [pydantic-settings package repository](https://github.com/pydantic/pydantic-settings)
|
||||||
|
|
||||||
|
### Core Concepts
|
||||||
|
|
||||||
|
- [Field aliases](https://pydantic.dev/docs/validation/latest/concepts/fields/#field-aliases)
|
||||||
|
- [Alias choices](https://pydantic.dev/docs/validation/latest/concepts/alias#aliaspath-and-aliaschoices)
|
||||||
|
- [Validation default behavior](https://pydantic.dev/docs/validation/latest/concepts/fields#validate-default-values)
|
||||||
|
- [ImportString type](https://pydantic.dev/docs/validation/latest/api/pydantic/types/#pydantic.types.ImportString)
|
||||||
|
|
||||||
|
### Priority And Sources
|
||||||
|
|
||||||
|
- [Field value priority](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#field-value-priority)
|
||||||
|
- [Customise settings sources](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#customise-settings-sources)
|
||||||
|
- [Other settings source types](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#other-settings-source)
|
||||||
|
|
||||||
|
### Environment And Parsing
|
||||||
|
|
||||||
|
- [Environment variable names and prefix behavior](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#environment-variable-names)
|
||||||
|
- [Case sensitivity behavior](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#case-sensitivity)
|
||||||
|
- [Parsing environment variable values](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#parsing-environment-variable-values)
|
||||||
|
- [Nested model default partial updates](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#nested-model-default-partial-updates)
|
||||||
|
|
||||||
|
### Dotenv And Secrets
|
||||||
|
|
||||||
|
- [Dotenv support](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#dotenv-env-support)
|
||||||
|
- [Secrets](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#secrets)
|
||||||
|
- [Nested secrets](https://pydantic.dev/docs/validation/latest/concepts/pydantic_settings/#nested-secrets)
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
---
|
|
||||||
name: pytest-scaffolding
|
|
||||||
description: "Scaffold a maintainable, hierarchical pytest suite with fast defaults and clear escalation paths for FastAPI and SQLAlchemy tests. Use when creating or reorganizing tests, defining fixture/marker boundaries, or making test strategy progressively discoverable."
|
|
||||||
argument-hint: "Target scope plus stack details (pure Python, FastAPI, SQLAlchemy sync, SQLAlchemy async, or mixed)"
|
|
||||||
x-personal-mcp:
|
|
||||||
id: pytest-scaffolding
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- pytest
|
|
||||||
- testing
|
|
||||||
- python
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/pytest-scaffolding/document
|
|
||||||
depends_on: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# Pytest Scaffolding
|
|
||||||
|
|
||||||
Create test scaffolding that stays fast for daily work and scales safely as dependencies increase.
|
|
||||||
|
|
||||||
This skill is optimized for progressive discoverability:
|
|
||||||
1. Start with the shortest path in this file.
|
|
||||||
2. Load exactly one deeper reference only when a decision requires it.
|
|
||||||
3. Continue only as far as needed for the current task.
|
|
||||||
|
|
||||||
Repository defaults:
|
|
||||||
- `uv run pytest` is the canonical invocation.
|
|
||||||
- pytest settings live in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
|
||||||
- strict marker checking is expected (`--strict-markers`).
|
|
||||||
|
|
||||||
## Discovery Ladder
|
|
||||||
|
|
||||||
### Level 0: Scope And Stack Triage (always)
|
|
||||||
Collect:
|
|
||||||
1. Target scope (repo, package, module).
|
|
||||||
2. Stack shape (pure Python, FastAPI, SQLAlchemy sync, SQLAlchemy async, or mixed).
|
|
||||||
3. Speed target (what must stay instant).
|
|
||||||
4. CI gate policy (which marker groups block merge).
|
|
||||||
|
|
||||||
If any are missing, ask concise clarifying questions before scaffolding.
|
|
||||||
|
|
||||||
### Level 1: Core pytest scaffold (default)
|
|
||||||
Use this for all stacks first:
|
|
||||||
1. Mirror `src/` into `tests/` with one starter file per core module.
|
|
||||||
2. Classify test intent by cost:
|
|
||||||
- `unit`: no DB/network/filesystem side effects.
|
|
||||||
- `integration`: framework, DB, or multi-layer contracts.
|
|
||||||
- `smoke`: thin critical-path checks.
|
|
||||||
3. Scaffold each new module with:
|
|
||||||
- one happy-path test,
|
|
||||||
- one failure/edge test,
|
|
||||||
- TODO anchors for deeper assertions.
|
|
||||||
4. Keep fixtures layered:
|
|
||||||
- global lightweight fixtures in `tests/conftest.py`,
|
|
||||||
- domain fixtures in subtree `conftest.py` only when needed.
|
|
||||||
5. Register markers early: `unit`, `integration`, `smoke`, `slow`, `external`.
|
|
||||||
6. Validate in order:
|
|
||||||
- `uv run pytest --collect-only -q`
|
|
||||||
- `uv run pytest -m unit -q`
|
|
||||||
- `uv run pytest -q` when dependencies are available.
|
|
||||||
|
|
||||||
Load next reference only if needed:
|
|
||||||
- Baseline details and rationale: [pytest-docs.md](./references/pytest-docs.md)
|
|
||||||
|
|
||||||
### Level 2: FastAPI branch (only for HTTP/dependency/lifespan concerns)
|
|
||||||
Escalate here when testing API routes, dependency injection boundaries, or app lifespan behavior.
|
|
||||||
|
|
||||||
Apply these defaults:
|
|
||||||
1. Prefer `TestClient` with sync `def` tests for route behavior.
|
|
||||||
2. Use `AsyncClient` + `@pytest.mark.anyio` only when test logic must await other async work.
|
|
||||||
3. Prefer `app.dependency_overrides` over patching internals.
|
|
||||||
4. Reset dependency overrides in teardown after every test/fixture.
|
|
||||||
5. For startup/shutdown semantics:
|
|
||||||
- use `TestClient` as context manager, or
|
|
||||||
- use `LifespanManager` with async client.
|
|
||||||
|
|
||||||
Marker intent in FastAPI-heavy suites:
|
|
||||||
- `unit`: service logic without HTTP/DB.
|
|
||||||
- `integration`: route + DI + DB contract checks.
|
|
||||||
- `smoke`: one request per critical user path.
|
|
||||||
|
|
||||||
Reference: [fastapi-testing.md](./references/fastapi-testing.md)
|
|
||||||
|
|
||||||
### Level 3: SQLAlchemy branch (only for DB transaction/session design)
|
|
||||||
Escalate here when session lifecycle, transaction isolation, or async ORM behavior matters.
|
|
||||||
|
|
||||||
Apply these defaults:
|
|
||||||
1. Create engine once per test session.
|
|
||||||
2. Open connection + outer transaction per test.
|
|
||||||
3. Bind session with `join_transaction_mode="create_savepoint"`.
|
|
||||||
4. Allow code under test to call `commit()` safely; rollback outer transaction at test end.
|
|
||||||
5. Keep unit tests DB-free; DB tests belong under `integration`.
|
|
||||||
|
|
||||||
Async additions:
|
|
||||||
- use async fixtures and `@pytest.mark.anyio`.
|
|
||||||
- set `expire_on_commit=False` for `AsyncSession`.
|
|
||||||
- avoid implicit lazy IO; use eager loading (`selectinload`) or explicit refresh.
|
|
||||||
|
|
||||||
SQLite in-memory with threaded test clients:
|
|
||||||
- use `StaticPool` when required by thread/connection sharing.
|
|
||||||
|
|
||||||
Reference: [sqlalchemy-testing.md](./references/sqlalchemy-testing.md)
|
|
||||||
|
|
||||||
## Branching Logic Summary
|
|
||||||
- If pure logic can be faked cleanly, keep in `unit`.
|
|
||||||
- If framework/DB contract is the behavior under test, use `integration`.
|
|
||||||
- If external service credentials/network is required, gate behind `external`.
|
|
||||||
- If suite slows down, split by marker before broadening fixture scope.
|
|
||||||
- If async relationship access raises `MissingGreenlet`, switch to eager loading strategy.
|
|
||||||
|
|
||||||
## Completion Checks
|
|
||||||
A scaffold pass is complete when all are true:
|
|
||||||
1. Core source areas map to clear test modules.
|
|
||||||
2. Fast path (`-m unit`) is deterministic and quick.
|
|
||||||
3. Integration and external paths are isolated by fixtures and markers.
|
|
||||||
4. No unregistered-marker failures occur.
|
|
||||||
5. Structure is understandable without extra oral context.
|
|
||||||
6. Clear TODO extension points exist for deeper assertions.
|
|
||||||
|
|
||||||
## Output Contract
|
|
||||||
When this skill is applied, return:
|
|
||||||
1. Proposed test tree diff.
|
|
||||||
2. Marker and fixture plan.
|
|
||||||
3. Exact fast-path and full-path commands.
|
|
||||||
4. Which reference level was loaded and why.
|
|
||||||
5. Risks or open questions before expanding assertions.
|
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
---
|
||||||
|
name: pytesting
|
||||||
|
description: "Reference hub for pytest suite structure, naming, markers, and stack-specific testing patterns. Optimized for progressive discovery so naming and hierarchy guidance are loaded first when shaping or reorganizing tests."
|
||||||
|
x-personal-mcp:
|
||||||
|
id: pytesting
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- pytest
|
||||||
|
- testing
|
||||||
|
- python
|
||||||
|
- fastapi
|
||||||
|
- asyncio
|
||||||
|
- anyio
|
||||||
|
- deterministic
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/pytesting/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pytesting
|
||||||
|
|
||||||
|
This skill is a collection of preferences and links to source documentation for building and maintaining pytest suites.
|
||||||
|
|
||||||
|
Use it to quickly find the right guidance for:
|
||||||
|
1. Baseline pytest structure and marker strategy.
|
||||||
|
2. Naming conventions and test hierarchy organization.
|
||||||
|
3. FastAPI route, dependency override, and lifespan testing patterns.
|
||||||
|
4. SQLAlchemy transaction and session testing patterns.
|
||||||
|
5. AsyncIO loop-scope, fixture-lifecycle, and cancellation-safe testing patterns.
|
||||||
|
|
||||||
|
Repository defaults:
|
||||||
|
- `uv run pytest` is the canonical invocation.
|
||||||
|
- pytest settings live in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||||
|
- strict marker checking is expected (`--strict-markers`).
|
||||||
|
|
||||||
|
## Progressive Discovery Start
|
||||||
|
|
||||||
|
Use this load order by default so guidance stays targeted and naming conventions are pulled in early:
|
||||||
|
|
||||||
|
1. Classify intent first: naming and organization, baseline pytest mechanics, FastAPI testing, SQLAlchemy testing, or mixed.
|
||||||
|
2. For create/restructure/rename tasks, load [naming-and-organization.md](./references/naming-and-organization.md) first.
|
||||||
|
3. Load [pytest-docs.md](./references/pytest-docs.md) next for fixture and marker defaults.
|
||||||
|
4. Load at most one stack-specific reference unless the request is explicitly mixed stack.
|
||||||
|
5. If confidence is low after two references, ask one clarifying question before loading more.
|
||||||
|
|
||||||
|
Load budget defaults:
|
||||||
|
|
||||||
|
1. Single-stack task: 1 to 2 references.
|
||||||
|
2. Mixed-stack task: up to 3 references.
|
||||||
|
3. Avoid loading all references unless the user explicitly asks for a broad audit.
|
||||||
|
|
||||||
|
## Intent Router
|
||||||
|
|
||||||
|
Open only the reference that matches the immediate task.
|
||||||
|
|
||||||
|
1. Naming, file layout, discovery prefixes, class/function naming: [naming-and-organization.md](./references/naming-and-organization.md)
|
||||||
|
2. Fixture layering, marker policy, collect-only and fast-path commands: [pytest-docs.md](./references/pytest-docs.md)
|
||||||
|
3. Route tests, dependency overrides, lifespan handling: [fastapi-testing.md](./references/fastapi-testing.md)
|
||||||
|
4. Session and transaction fixtures, async ORM behavior: [sqlalchemy-testing.md](./references/sqlalchemy-testing.md)
|
||||||
|
5. Async test mode selection, event loop scope, cancel-scope teardown issues: [asyncio-testing.md](./references/asyncio-testing.md)
|
||||||
|
|
||||||
|
## Naming Pull-In Triggers
|
||||||
|
|
||||||
|
Always consult [naming-and-organization.md](./references/naming-and-organization.md) before recommending structure when any of these are true:
|
||||||
|
|
||||||
|
1. New tests are being added.
|
||||||
|
2. Existing tests are being reorganized or renamed.
|
||||||
|
3. The request mentions conventions, readability, hierarchy, or discoverability.
|
||||||
|
4. The task introduces parametrization where case naming affects failure readability.
|
||||||
|
|
||||||
|
## Guiding Principles
|
||||||
|
|
||||||
|
These principles are abstract, but are the highest priority to follow.
|
||||||
|
|
||||||
|
- Much of testing is very well-trodden. In general, tests should follow whatever conventions there are.
|
||||||
|
- Tests will be run very frequently, so it's important that they run quickly and deterministically.
|
||||||
|
- When tests fail, it should be easy to determine what failed and fix it.
|
||||||
|
- Always be on guard against tests that are tautological. Every test should provide specific value by capturing something about the intent of the program.
|
||||||
|
|
||||||
|
## Pytest Best Practices
|
||||||
|
|
||||||
|
These are stable defaults regardless of stack:
|
||||||
|
|
||||||
|
1. Apply pytest naming and hierarchy conventions first so discovery and ownership stay predictable; see [naming-and-organization.md](./references/naming-and-organization.md).
|
||||||
|
2. Mirror `src/` into `tests/` so ownership and coverage are obvious.
|
||||||
|
3. Keep fixtures explicit and layered (`tests/conftest.py` globally, subtree `conftest.py` for domain-specific fixtures).
|
||||||
|
4. Register markers up front (`unit`, `integration`, `smoke`, `slow`, `external`) and keep strict marker checks enabled.
|
||||||
|
5. Separate fast feedback (`-m unit`) from broader integration/external lanes.
|
||||||
|
6. Validate structure early with collection checks before expanding assertions.
|
||||||
|
7. Keep test scope tight and count intentional; add tests only when each case protects a distinct behavior.
|
||||||
|
8. Start with the single core-intent behavior path, then add edge cases based on real risk.
|
||||||
|
9. Prefer parametrized tests for behavior variants instead of cloning near-identical test functions.
|
||||||
|
10. Reject low-signal assertions (for example `assert True` patterns) and avoid tests that only assert a mock was called.
|
||||||
|
11. Prefer behavior-first tests that exercise real code paths and concrete inputs over patching internals.
|
||||||
|
12. Use monkeypatching, mocks, and fakes extremely sparingly, only when no practical real-input alternative exists, and only after explicit user confirmation.
|
||||||
|
|
||||||
|
## Universal Test Double Policy (Repo-Local Placement)
|
||||||
|
|
||||||
|
To avoid over-using monkeypatching, mocks, fakes, etc, apply this policy whenever a test change introduces one of them:
|
||||||
|
|
||||||
|
1. Attempt a real-input, real-object test design first.
|
||||||
|
2. If that approach is impractical, explain why and request user confirmation before adding monkeypatching, mocks, or fakes.
|
||||||
|
3. Keep any approved test double narrowly scoped and document the exact boundary it replaces.
|
||||||
|
4. Do not treat call-only verification as sufficient; pair any test double with assertions on observable behavior or outputs.
|
||||||
|
5. Revisit approved test doubles when implementation seams improve so they can be removed.
|
||||||
|
|
||||||
|
## Stack-Specific Guidance
|
||||||
|
|
||||||
|
- For FastAPI, prefer dependency overrides and clear lifecycle handling; see [fastapi-testing.md](./references/fastapi-testing.md).
|
||||||
|
- For SQLAlchemy, prefer transaction-safe session fixtures and explicit async loading strategy; see [sqlalchemy-testing.md](./references/sqlalchemy-testing.md).
|
||||||
|
- For async fixtures, loop-scope selection, and cancellation-safe teardown, see [asyncio-testing.md](./references/asyncio-testing.md).
|
||||||
|
- For naming and tree organization, use the conventions in [naming-and-organization.md](./references/naming-and-organization.md).
|
||||||
|
|
||||||
|
## Source Documentation Entry Points
|
||||||
|
|
||||||
|
Primary upstream docs are curated in each reference page. Start with:
|
||||||
|
|
||||||
|
1. Pytest good practices: [pytest docs](https://docs.pytest.org/en/stable/explanation/goodpractices.html)
|
||||||
|
2. Pytest fixtures: [fixture how-to](https://docs.pytest.org/en/stable/how-to/fixtures.html)
|
||||||
|
3. Pytest markers: [marker examples](https://docs.pytest.org/en/stable/example/markers.html)
|
||||||
|
4. FastAPI testing: [FastAPI testing tutorial](https://fastapi.tiangolo.com/tutorial/testing/)
|
||||||
|
5. SQLAlchemy transaction testing: [SQLAlchemy external transaction pattern](https://docs.sqlalchemy.org/en/20/orm/session_transaction.html#joining-a-session-into-an-external-transaction-such-as-for-test-suites)
|
||||||
|
6. Pytest monkeypatch usage and limits: [monkeypatch how-to](https://docs.pytest.org/en/stable/how-to/monkeypatch.html)
|
||||||
|
7. pytest-asyncio configuration: [pytest-asyncio config](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html)
|
||||||
|
8. AnyIO cancellation semantics: [AnyIO cancellation and timeouts](https://anyio.readthedocs.io/en/stable/cancellation.html)
|
||||||
|
|
||||||
|
## Quick Validation Commands
|
||||||
|
|
||||||
|
Use these commands to check structure and execution lanes:
|
||||||
|
|
||||||
|
1. `uv run pytest --collect-only -q`
|
||||||
|
2. `uv run pytest -m unit -q`
|
||||||
|
3. `uv run pytest -m "not external" -q`
|
||||||
|
4. `uv run pytest -q`
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
When this skill is applied, return:
|
||||||
|
1. Which references were consulted.
|
||||||
|
2. The discovery path used (intent classification, load order, and why).
|
||||||
|
3. Recommended structure, naming, fixture, and marker decisions.
|
||||||
|
4. Concrete naming outcomes: file/module naming pattern, class usage decision, and any parametrization `ids` conventions.
|
||||||
|
5. Exact validation commands.
|
||||||
|
6. Relevant source-doc links for any non-trivial recommendation.
|
||||||
|
7. Risks, assumptions, or open questions.
|
||||||
|
8. Explicit confirmation status if monkeypatching, mocks, or fakes were requested or used.
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# AsyncIO Testing Patterns (Pytest, FastAPI, AnyIO)
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [pytest-asyncio configuration](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html)
|
||||||
|
- [pytest-asyncio concepts](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html)
|
||||||
|
- [pytest-asyncio fixture loop scope how-to](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/change_fixture_loop.html)
|
||||||
|
- [pytest-asyncio default fixture loop scope how-to](https://pytest-asyncio.readthedocs.io/en/stable/how-to-guides/change_default_fixture_loop.html)
|
||||||
|
- [AnyIO cancellation and cancel-scope safety](https://anyio.readthedocs.io/en/stable/cancellation.html)
|
||||||
|
- [FastAPI async tests](https://fastapi.tiangolo.com/advanced/async-tests/)
|
||||||
|
|
||||||
|
## Agent Quick Path
|
||||||
|
Use this reference when tests involve asynchronous fixtures, HTTP clients, task groups, or teardown failures.
|
||||||
|
|
||||||
|
1. Confirm async plugin mode in pytest config (`asyncio_mode`).
|
||||||
|
2. Keep async fixture loop scope predictable, defaulting to `function` unless there is a measured need to broaden it.
|
||||||
|
3. Prefer one async testing model per lane (pytest-asyncio or AnyIO-style markers), and keep it consistent.
|
||||||
|
4. Keep async fixtures small and isolate stateful resources to the narrowest useful scope.
|
||||||
|
5. If teardown errors mention cancel scopes or task groups, validate that setup and teardown run in the same task context.
|
||||||
|
|
||||||
|
## Baseline Configuration
|
||||||
|
|
||||||
|
Recommended defaults for most projects using `pytest-asyncio`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
```
|
||||||
|
|
||||||
|
Why:
|
||||||
|
- [Strict mode](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html#test-discovery-modes) is safer for multi-plugin environments, but [auto mode](https://pytest-asyncio.readthedocs.io/en/stable/concepts.html#test-discovery-modes) is often simpler when the suite is primarily asyncio-based.
|
||||||
|
- [Function loop scope](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html#asyncio-default-fixture-loop-scope) minimizes cross-test coupling and avoids many lifecycle surprises.
|
||||||
|
|
||||||
|
If a fixture or test needs broader loop sharing, make it explicit instead of changing suite-wide defaults:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(loop_scope="module")
|
||||||
|
async def shared_resource():
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio(loop_scope="module")
|
||||||
|
async def test_uses_shared_loop(shared_resource):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
## FastAPI Endpoint Test Patterns
|
||||||
|
|
||||||
|
Use [FastAPI's async testing guidance](https://fastapi.tiangolo.com/advanced/async-tests/) as the default:
|
||||||
|
|
||||||
|
1. Use `httpx.AsyncClient` with `ASGITransport` for async endpoint tests.
|
||||||
|
2. Mark async tests with one consistent marker style for the suite.
|
||||||
|
3. If app lifespan hooks matter, add [LifespanManager](https://fastapi.tiangolo.com/advanced/async-tests/#httpx) support because `AsyncClient` alone does not trigger lifespan events.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_healthz(app):
|
||||||
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
response = await client.get("/healthz")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
```
|
||||||
|
|
||||||
|
## Fixture Design For Async Reliability
|
||||||
|
|
||||||
|
Apply these patterns first:
|
||||||
|
|
||||||
|
1. Keep async fixtures narrow (`function` scope by default).
|
||||||
|
2. Keep one responsibility per fixture when possible.
|
||||||
|
3. Prefer yield fixtures and pair each setup step with teardown in the same fixture.
|
||||||
|
4. Avoid mixing many independent event-loop lifecycles in one fixture chain.
|
||||||
|
|
||||||
|
When using transports that manage internal task groups (for example, streaming clients), avoid patterns that risk splitting lifecycle across different task contexts.
|
||||||
|
|
||||||
|
## Troubleshooting Cancel-Scope Teardown Failures
|
||||||
|
|
||||||
|
When you see errors like `Attempted to exit cancel scope in a different task than it was entered in`, treat it as an async lifecycle-ownership issue first.
|
||||||
|
|
||||||
|
Checklist:
|
||||||
|
|
||||||
|
1. Verify fixture and test loop scopes are compatible and explicit.
|
||||||
|
2. Confirm async resource setup and teardown are owned by the same fixture context.
|
||||||
|
3. Reduce fixture scope (`module` or `session` -> `function`) to test for loop/task ownership drift.
|
||||||
|
4. Ensure the suite uses one primary async plugin model for the failing lane.
|
||||||
|
5. Re-run with focused selection and skip reasons to isolate first failing fixture:
|
||||||
|
- `uv run --group test python -m pytest -m smoke tests/web -q -rs`
|
||||||
|
|
||||||
|
Relevant references:
|
||||||
|
- [Avoiding cancel scope stack corruption](https://anyio.readthedocs.io/en/stable/cancellation.html#avoiding-cancel-scope-stack-corruption)
|
||||||
|
- [pytest-asyncio configuration](https://pytest-asyncio.readthedocs.io/en/stable/reference/configuration.html)
|
||||||
|
- [pytest fixture teardown behavior](https://docs.pytest.org/en/stable/how-to/fixtures.html#teardown-cleanup-aka-fixture-finalization)
|
||||||
|
|
||||||
|
## Commands Worth Remembering
|
||||||
|
|
||||||
|
- `uv run --group test python -m pytest --collect-only -q`
|
||||||
|
- `uv run --group test python -m pytest -m smoke tests/web -q -rs`
|
||||||
|
- `uv run --group test python -m pytest -m integration -q`
|
||||||
|
- `uv run --group test python -m pytest -q`
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Pytest Naming Conventions and Test Organization
|
||||||
|
|
||||||
|
!!! info "Primary sources"
|
||||||
|
- [Good integration practices](https://docs.pytest.org/en/stable/explanation/goodpractices.html)
|
||||||
|
- [Changing standard (Python) test discovery](https://docs.pytest.org/en/stable/example/pythoncollection.html)
|
||||||
|
- [How to use fixtures](https://docs.pytest.org/en/stable/how-to/fixtures.html)
|
||||||
|
- [How to parametrize fixtures and test functions](https://docs.pytest.org/en/stable/how-to/parametrize.html)
|
||||||
|
- [Marker examples](https://docs.pytest.org/en/stable/example/markers.html)
|
||||||
|
|
||||||
|
## Agent Quick Path
|
||||||
|
Use this when creating or reorganizing test modules so naming and hierarchy stay predictable.
|
||||||
|
|
||||||
|
1. Mirror the product domain structure in `tests/` so ownership is obvious.
|
||||||
|
2. Encode broad context in module and class names (`test_*.py`, `Test*`).
|
||||||
|
3. Keep leaf test names short and behavior-focused (`test_*`).
|
||||||
|
4. Use `class Test<Subject>:` only for grouping related scenarios.
|
||||||
|
5. Place fixtures in the nearest `conftest.py` needed by scope.
|
||||||
|
6. Separate expensive tests with markers first, directories second.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
### File and directory naming
|
||||||
|
- Use lowercase snake_case for test file names: `test_user_service.py`.
|
||||||
|
- Keep directories domain-oriented and stable over time: `tests/orders/`, `tests/billing/`.
|
||||||
|
- Prefer descriptive test names over internal ticket numbers or implementation details.
|
||||||
|
|
||||||
|
### Test function naming
|
||||||
|
- Prefer hierarchical naming: put broad context in folder/module/class, and keep the function name focused on the final assertion.
|
||||||
|
- Keep pytest discovery prefixes intact:
|
||||||
|
- modules start with `test_`
|
||||||
|
- classes start with `Test`
|
||||||
|
- functions start with `test_`
|
||||||
|
- Start with user-visible behavior or contract, not private helper names.
|
||||||
|
|
||||||
|
Recommended pattern:
|
||||||
|
- module: `test_<subject>.py`
|
||||||
|
- class: `Test<Operation>` or `Test<Scenario>`
|
||||||
|
- function: `test_<expected_outcome>`
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- Flat (still valid): `test_create_order_rejects_invalid_currency`
|
||||||
|
- Class-context: `TestOrder -> TestCreate -> test_rejects_invalid_currency`
|
||||||
|
- Module-context: `test_order.py -> TestCreate -> test_rejects_invalid_currency`
|
||||||
|
- Module + class context can similarly shorten:
|
||||||
|
- `test_token.py -> TestRefresh -> test_rotates_session_id`
|
||||||
|
- `test_user_list.py -> TestListUsers -> test_returns_empty_for_new_tenant`
|
||||||
|
|
||||||
|
### Test class naming
|
||||||
|
- Use `class Test<SubjectOrScenario>:` for scenario grouping and context reduction.
|
||||||
|
- Keep class names noun-focused (`TestOrderService`) rather than action-focused.
|
||||||
|
- Avoid xUnit style setup inheritance when fixtures can express dependencies directly.
|
||||||
|
|
||||||
|
## Hierarchy and Organization Patterns
|
||||||
|
|
||||||
|
Two patterns work well; choose one and apply it consistently.
|
||||||
|
|
||||||
|
### Pattern A: Source-mirror hierarchy (default for product code ownership)
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
app/
|
||||||
|
orders/service.py
|
||||||
|
billing/invoice.py
|
||||||
|
|
||||||
|
tests/
|
||||||
|
app/
|
||||||
|
orders/test_service.py
|
||||||
|
billing/test_invoice.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this when teams own modules by source path and want direct test-to-source mapping.
|
||||||
|
|
||||||
|
### Pattern B: Cost-lane hierarchy (default for CI policy clarity)
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/
|
||||||
|
unit/
|
||||||
|
orders/test_service.py
|
||||||
|
integration/
|
||||||
|
api/test_orders.py
|
||||||
|
persistence/test_order_repository.py
|
||||||
|
smoke/
|
||||||
|
test_health.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Use this when CI gating is based on cost lanes and marker filtering.
|
||||||
|
|
||||||
|
### Hybrid rule (recommended)
|
||||||
|
- Keep a source-mirror tree for local ownership.
|
||||||
|
- Add markers (`unit`, `integration`, `smoke`, `external`) for runtime policy.
|
||||||
|
- Avoid duplicating both trees unless the repository already requires it.
|
||||||
|
|
||||||
|
## Fixture Placement Strategy
|
||||||
|
- Put universal lightweight fixtures in `tests/conftest.py`.
|
||||||
|
- Put domain fixtures in subtree `conftest.py` files close to where they are used.
|
||||||
|
- Keep fixtures composable and explicit; avoid large fixture "god objects".
|
||||||
|
- Use `yield` fixtures for teardown so cleanup is always paired with setup.
|
||||||
|
|
||||||
|
## Parametrize and ID Naming
|
||||||
|
- Use `pytest.mark.parametrize` for behavior matrices instead of copy/paste tests.
|
||||||
|
- Provide explicit `ids=` labels when case names are not obvious.
|
||||||
|
- Keep IDs business-meaningful (`"expired-token"`, `"zero-balance"`) so failures are readable.
|
||||||
|
|
||||||
|
## Collection and Structure Checks
|
||||||
|
Use these checks after introducing new test files or renaming modules:
|
||||||
|
|
||||||
|
- `uv run pytest --collect-only -q`
|
||||||
|
- `uv run pytest -m unit -q`
|
||||||
|
- `uv run pytest -m "not external" -q`
|
||||||
|
|
||||||
|
If collection surprises appear, verify file names, marker registration, and directory placement first.
|
||||||
|
|
||||||
|
## Common Anti-Patterns
|
||||||
|
- Mixed naming styles (`testFoo.py`, `test_foo.py`, `foo_test.py`) in one repository.
|
||||||
|
- Deep fixture chains that hide setup behavior.
|
||||||
|
- Test names that encode implementation details instead of behavior.
|
||||||
|
- Moving slow tests into `unit` directories without marker updates.
|
||||||
|
- Sharing mutable module-level state across tests.
|
||||||
+1
@@ -19,6 +19,7 @@ Use this file when you need fast pytest scaffolding defaults without framework-s
|
|||||||
Load other references only when needed:
|
Load other references only when needed:
|
||||||
- FastAPI routes/dependency injection/lifespan: `fastapi-testing.md`
|
- FastAPI routes/dependency injection/lifespan: `fastapi-testing.md`
|
||||||
- SQLAlchemy sessions/transactions/DB fixtures: `sqlalchemy-testing.md`
|
- SQLAlchemy sessions/transactions/DB fixtures: `sqlalchemy-testing.md`
|
||||||
|
- Naming conventions and test hierarchy: `naming-and-organization.md`
|
||||||
|
|
||||||
## Practical Guidance For This Skill
|
## Practical Guidance For This Skill
|
||||||
- Use src-aligned test layout and keep test discovery conventional.
|
- Use src-aligned test layout and keep test discovery conventional.
|
||||||
@@ -1,117 +0,0 @@
|
|||||||
---
|
|
||||||
name: python-logging-dictconfig
|
|
||||||
description: 'Set up idiomatic Python logging with logging.config.dictConfig. Use when creating or refactoring logging setup, standardizing handlers/formatters, and enforcing centralized config.'
|
|
||||||
argument-hint: 'Target context (single script, package, FastAPI app, or CLI) and desired log destinations'
|
|
||||||
x-personal-mcp:
|
|
||||||
id: python-logging-dictconfig
|
|
||||||
version: 1.0.0
|
|
||||||
tags:
|
|
||||||
- logging
|
|
||||||
- python
|
|
||||||
- observability
|
|
||||||
capabilities:
|
|
||||||
- resource://skills/python-logging-dictconfig/document
|
|
||||||
depends_on: []
|
|
||||||
---
|
|
||||||
|
|
||||||
# Idiomatic Python Logging with dictConfig
|
|
||||||
|
|
||||||
Use this skill to produce a minimal, centralized logging setup using `logging.config.dictConfig`.
|
|
||||||
|
|
||||||
Load references only when needed:
|
|
||||||
- Python logging overview and hierarchy: [Python logging references](./references/python-logging-docs.md)
|
|
||||||
|
|
||||||
## When to Use
|
|
||||||
|
|
||||||
- A project configures logging ad hoc with `basicConfig` across multiple modules.
|
|
||||||
- You need one canonical logging configuration for app startup.
|
|
||||||
- You need consistent formatting and levels across console/file handlers.
|
|
||||||
- You want library modules to use named loggers without configuring logging themselves.
|
|
||||||
|
|
||||||
## Inputs To Collect
|
|
||||||
|
|
||||||
1. Runtime type: script, library, web app, worker, CLI.
|
|
||||||
2. Destinations: stdout only, file only, or both.
|
|
||||||
3. Desired default level: `INFO`, `DEBUG`, etc.
|
|
||||||
4. Whether third-party loggers should be tuned (for example `uvicorn`, `sqlalchemy`).
|
|
||||||
|
|
||||||
If missing, assume:
|
|
||||||
- stdout handler
|
|
||||||
- human-readable formatter
|
|
||||||
- root level `INFO`
|
|
||||||
- `disable_existing_loggers: False`
|
|
||||||
|
|
||||||
## Procedure
|
|
||||||
|
|
||||||
1. Define a single `LOGGING` dictionary in one startup-oriented module (for example `logging_config.py`).
|
|
||||||
2. Include `version: 1` and set `disable_existing_loggers: False` unless there is a specific reason to silence existing loggers.
|
|
||||||
3. Define formatters first, then handlers, then logger routing (`root` and optional named `loggers`).
|
|
||||||
4. Use `logging.config.dictConfig(LOGGING)` exactly once during application startup.
|
|
||||||
5. In all modules, get loggers via `logger = logging.getLogger(__name__)` and never call `basicConfig`.
|
|
||||||
6. Keep libraries configuration-free: libraries should emit logs, applications decide routing.
|
|
||||||
7. Verify behavior with a quick smoke check at multiple levels (`DEBUG`, `INFO`, `WARNING`, `ERROR`).
|
|
||||||
|
|
||||||
## Minimal Baseline Templates
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
!!! warning "Don't use the name `logging.py` because it will conflict
|
|
||||||
|
|
||||||
```python title="logging_config.py"
|
|
||||||
import logging.config
|
|
||||||
|
|
||||||
LOGGING = {
|
|
||||||
"version": 1,
|
|
||||||
"disable_existing_loggers": False,
|
|
||||||
"formatters": {
|
|
||||||
"basic": {
|
|
||||||
"format": "%(asctime)s.%(msecs)03d [%(levelname)s] %(message)s",
|
|
||||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"handlers": {
|
|
||||||
"console": {
|
|
||||||
"class": "logging.StreamHandler",
|
|
||||||
"formatter": "basic",
|
|
||||||
"stream": "ext://sys.stdout",
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"level": "INFO",
|
|
||||||
"handlers": ["console"],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
|
||||||
logging.config.dictConfig(LOGGING)
|
|
||||||
```
|
|
||||||
|
|
||||||
```python title="app.py"
|
|
||||||
# app startup
|
|
||||||
from .logging_config import configure_logging
|
|
||||||
|
|
||||||
configure_logging()
|
|
||||||
```
|
|
||||||
|
|
||||||
### Usage
|
|
||||||
|
|
||||||
The preferred way of instantiating loggers is at the top of modules like this:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import logging
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Completion Checks
|
|
||||||
1. `dictConfig` is called once at startup, not per module.
|
|
||||||
2. No `basicConfig` calls remain.
|
|
||||||
3. Modules use `getLogger(__name__)`.
|
|
||||||
4. Logs appear at expected level and destination.
|
|
||||||
5. Third-party logger noise is intentionally configured or left at defaults.
|
|
||||||
6. No module named `logging.py` in the project.
|
|
||||||
|
|
||||||
## Branching Guidance
|
|
||||||
- If structured logs are required: switch formatter output to JSON while keeping `dictConfig` topology unchanged.
|
|
||||||
- If both console and file output are needed: add a file handler and attach it to `root`.
|
|
||||||
- If a specific framework logger is too noisy: add a named logger override under `loggers`.
|
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
---
|
||||||
|
name: python-logging
|
||||||
|
description: 'Design, review, or refactor Python logging. Use when choosing logger names, levels, handlers, library/application boundaries, basicConfig, dictConfig, structured logs, or operational logging defaults.'
|
||||||
|
x-personal-mcp:
|
||||||
|
id: python-logging
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- logging
|
||||||
|
- python
|
||||||
|
- observability
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/python-logging/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Python Logging
|
||||||
|
|
||||||
|
Use this skill to produce idiomatic Python logging guidance or a small logging setup for an application, library, CLI, worker, or web service.
|
||||||
|
|
||||||
|
Load references only when needed:
|
||||||
|
- Python logging overview, library guidance, handlers, and dictConfig schema: [Python logging references](./references/python-logging-docs.md)
|
||||||
|
- Minimal network logging example with a receiver and queue-backed client: [Network logging minimal example](./references/network-logging-minimal-example.md)
|
||||||
|
- HTTP JSON logging example with `httpx` and a queue-backed client: [HTTPX logging handler example](./references/httpx-logging-handler-example.md)
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- A project mixes `print`, root logger calls, scattered `basicConfig`, or ad hoc handlers.
|
||||||
|
- You need to choose logging levels, destinations, formatter fields, or logger names.
|
||||||
|
- You need a clear boundary between library logging and application logging configuration.
|
||||||
|
- You need a centralized logging setup, including a `logging.config.dictConfig` section.
|
||||||
|
- You are tuning framework or third-party loggers such as `uvicorn`, `sqlalchemy`, or HTTP clients.
|
||||||
|
|
||||||
|
## Inputs To Collect
|
||||||
|
|
||||||
|
1. Runtime type: script, library, CLI, web app, worker, service, or notebook.
|
||||||
|
2. Audience: humans in a terminal, operators in files, machines in JSON, or test assertions.
|
||||||
|
3. Destinations: stdout/stderr, file, rotating file, queue, syslog, external collector, or none for libraries.
|
||||||
|
4. Default level and verbosity controls: `INFO`, `DEBUG`, CLI flag, environment variable, or config file.
|
||||||
|
5. Operational constraints: async event loop, multiprocessing, container logs, sensitive data, or high-volume paths.
|
||||||
|
|
||||||
|
If missing, assume:
|
||||||
|
- application code, not a reusable library
|
||||||
|
- stdout console logging
|
||||||
|
- human-readable formatter
|
||||||
|
- root level `INFO`
|
||||||
|
- no file logging unless requested
|
||||||
|
|
||||||
|
## Procedure
|
||||||
|
|
||||||
|
1. Classify the project boundary first: application code configures logging; library code emits logs and avoids configuring handlers.
|
||||||
|
2. In modules, create loggers with `logger = logging.getLogger(__name__)` so logger names follow the package hierarchy.
|
||||||
|
3. Use level semantics consistently: `DEBUG` for diagnosis, `INFO` for normal milestones, `WARNING` for notable recoverable conditions, `ERROR` for failed operations, and `CRITICAL` for process-threatening failures.
|
||||||
|
4. Prefer parameterized logging calls such as `logger.info("Processed %s items", count)` so message formatting is deferred until the record is emitted.
|
||||||
|
5. Configure handlers and formatters once during application startup. For small scripts, `basicConfig` can be enough; for applications, prefer a centralized configuration function.
|
||||||
|
6. Keep third-party logger overrides explicit and narrow. Tune noisy loggers by name instead of muting broad logger hierarchies.
|
||||||
|
7. Smoke-check output at expected levels and destinations, including one suppressed `DEBUG` message and one exception path if errors are logged.
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
- Do not name a module `logging.py`; it shadows the standard library package.
|
||||||
|
- Do not call `basicConfig` or attach handlers in every module.
|
||||||
|
- Do not log to the root logger from libraries. Use named loggers and, only if needed, attach `logging.NullHandler()` to the library's top-level logger.
|
||||||
|
- Do not create loggers per request, user, file, or connection. Use contextual fields, adapters, or filters instead.
|
||||||
|
- Use `logger.exception(...)` only inside an exception handler when the traceback is useful.
|
||||||
|
- For async or high-throughput code, avoid slow network or file handlers on the hot path; consider `QueueHandler` and a listener.
|
||||||
|
- Avoid custom levels unless there is a strong interoperability reason.
|
||||||
|
|
||||||
|
## Using dictConfig
|
||||||
|
|
||||||
|
Use `logging.config.dictConfig` when configuration should be centralized, data-driven, or richer than `basicConfig`.
|
||||||
|
|
||||||
|
1. Define one `LOGGING` dictionary in a startup-oriented module such as `logging_config.py`.
|
||||||
|
2. Include `version: 1` and usually set `disable_existing_loggers: False` so existing named loggers are not silently disabled.
|
||||||
|
3. Define formatters, then handlers, then logger routing with `root` and optional named `loggers`.
|
||||||
|
4. Call `logging.config.dictConfig(LOGGING)` once during application startup.
|
||||||
|
5. Keep application logging calls unchanged when adding new destinations or formats.
|
||||||
|
|
||||||
|
### Minimal dictConfig Baseline
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import logging.config
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s.%(msecs)03d %(levelname)s %(name)s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"level": "INFO",
|
||||||
|
"handlers": ["console"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="app.py"
|
||||||
|
from .logging_config import configure_logging
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="feature.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run(count: int) -> None:
|
||||||
|
logger.info("Processing %s items", count)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branching Guidance
|
||||||
|
|
||||||
|
- If the code is a tiny script: use `basicConfig` once near the entry point and module loggers elsewhere.
|
||||||
|
- If the code is a library: remove handlers and configuration calls; document logger names and optionally add `NullHandler` at the package root.
|
||||||
|
- If structured logs are required: keep the same logger and handler topology, but switch formatter output to JSON or a structured formatter.
|
||||||
|
- If console and file output are needed: add one file or rotating-file handler and attach it centrally.
|
||||||
|
- If multiple processes write to one file: use a queue/listener or process-safe collection path rather than opening the same file independently in each process.
|
||||||
|
- If logs must cross a network: send records to a receiver or collector from a queue-backed handler, keep the receiver responsible for final destinations, and avoid exposing unauthenticated logging ports.
|
||||||
|
- If a framework logger is noisy: add a named logger override with a level and leave unrelated logger propagation alone.
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. Modules use `logging.getLogger(__name__)`.
|
||||||
|
2. Application startup configures logging once.
|
||||||
|
3. Libraries do not configure application handlers.
|
||||||
|
4. Levels match the severity semantics in this skill.
|
||||||
|
5. Logs include enough context to identify source, severity, and event without leaking secrets.
|
||||||
|
6. Expected destinations receive messages and suppressed levels stay quiet.
|
||||||
|
7. No source file or package is named `logging.py`.
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
# HTTPX Logging Handler Example
|
||||||
|
|
||||||
|
Use this reference when a Python application needs to send log records to an HTTP endpoint with [`httpx`](https://www.python-httpx.org/). The example follows the same boundaries as the TCP network example: feature modules use normal named loggers, application startup configures logging once, a queue keeps HTTP I/O off the caller path, and the receiver or collector owns final routing.
|
||||||
|
|
||||||
|
Source docs to keep nearby:
|
||||||
|
|
||||||
|
- [HTTPX clients](https://www.python-httpx.org/advanced/clients/) for connection pooling and shared request configuration.
|
||||||
|
- [HTTPX timeouts](https://www.python-httpx.org/advanced/timeouts/) for connect, read, write, and pool timeout behavior.
|
||||||
|
- [HTTPX JSON requests](https://www.python-httpx.org/quickstart/#sending-json-encoded-data) for posting JSON payloads.
|
||||||
|
- [HTTPX exceptions](https://www.python-httpx.org/quickstart/#exceptions) for `RequestError`, `HTTPStatusError`, and `HTTPError` handling.
|
||||||
|
- [Dealing with handlers that block](https://docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block) for why slow handlers should sit behind `QueueHandler` and `QueueListener`.
|
||||||
|
- [`QueueHandler`](https://docs.python.org/3/library/logging.handlers.html#queuehandler) and [`QueueListener`](https://docs.python.org/3/library/logging.handlers.html#queuelistener) for queue-backed logging mechanics.
|
||||||
|
- [`logging.makeLogRecord`](https://docs.python.org/3/library/logging.html#logging.makeLogRecord) for rebuilding records from serialized fields.
|
||||||
|
|
||||||
|
## Minimal Topology
|
||||||
|
|
||||||
|
Send each record as a JSON HTTP request to a collector endpoint. Use `httpx.Client`, not the top-level `httpx.post`, because a handler may send many records to the same host and should reuse connections.
|
||||||
|
|
||||||
|
```text
|
||||||
|
application code -> named logger -> QueueHandler -> QueueListener -> HTTPX JSON handler -> HTTP receiver or collector
|
||||||
|
```
|
||||||
|
|
||||||
|
Application modules stay ordinary:
|
||||||
|
|
||||||
|
```python title="feature.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def sync_customer(customer_id: str) -> None:
|
||||||
|
logger.info("Syncing customer %s", customer_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
The module does not know whether logs are written locally, sent over HTTP, or forwarded by a platform collector.
|
||||||
|
|
||||||
|
## Minimal Receiver
|
||||||
|
|
||||||
|
This receiver is for local testing. A production deployment would usually send the same JSON shape to a managed log collector, OpenTelemetry collector, service endpoint, or internal ingestion API.
|
||||||
|
|
||||||
|
```python title="log_http_receiver.py"
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {"level": "INFO", "handlers": ["console"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LogRecordRequestHandler(BaseHTTPRequestHandler):
|
||||||
|
def do_POST(self) -> None:
|
||||||
|
if self.path != "/logs":
|
||||||
|
self.send_error(404)
|
||||||
|
return
|
||||||
|
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(body.decode("utf-8"))
|
||||||
|
record = logging.makeLogRecord(payload)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception("Dropped malformed log record")
|
||||||
|
self.send_error(400)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger = logging.getLogger(record.name)
|
||||||
|
if logger.isEnabledFor(record.levelno):
|
||||||
|
logger.handle(record)
|
||||||
|
|
||||||
|
self.send_response(204)
|
||||||
|
self.end_headers()
|
||||||
|
|
||||||
|
def log_message(self, format: str, *args: object) -> None:
|
||||||
|
logging.getLogger("http.server").debug(format, *args)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
server = ThreadingHTTPServer(("127.0.0.1", 9021), LogRecordRequestHandler)
|
||||||
|
with server:
|
||||||
|
logging.getLogger(__name__).info("Listening for HTTP log records")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Receiver Mechanics
|
||||||
|
|
||||||
|
- The receiver accepts `POST /logs` with a JSON body and returns `204` when the record is accepted.
|
||||||
|
- `makeLogRecord` turns the JSON dictionary back into a standard `LogRecord`, so the receiver can use the normal logger hierarchy.
|
||||||
|
- Receiver-side filtering still works, but client-side filtering is better when volume matters because it avoids serializing and transmitting records that will be discarded.
|
||||||
|
- `log_message` is redirected into the logging system at `DEBUG` so access logs do not pollute normal output.
|
||||||
|
- Binding to `127.0.0.1` keeps the demo local. If the receiver is reachable across a network, put authentication, TLS, rate limits, and request size limits in front of it.
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
The client side uses a queue-backed logging handler. The listener thread owns the `httpx.Client`, posts JSON records, and closes the connection pool during shutdown.
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import queue
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class PreservingQueueHandler(logging.handlers.QueueHandler):
|
||||||
|
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
||||||
|
copied = copy.copy(record)
|
||||||
|
copied.message = copied.getMessage()
|
||||||
|
copied.msg = copied.message
|
||||||
|
copied.args = None
|
||||||
|
|
||||||
|
if copied.exc_info is not None and copied.exc_text is None:
|
||||||
|
copied.exc_text = logging.Formatter().formatException(copied.exc_info)
|
||||||
|
copied.exc_info = None
|
||||||
|
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
class HttpxJsonLogHandler(logging.Handler):
|
||||||
|
def __init__(self, collector_url: str, token: str | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
headers = {"content-type": "application/json"}
|
||||||
|
if token is not None:
|
||||||
|
headers["authorization"] = f"Bearer {token}"
|
||||||
|
|
||||||
|
timeout = httpx.Timeout(2.0, connect=1.0, write=2.0, pool=1.0)
|
||||||
|
self.client = httpx.Client(base_url=collector_url, headers=headers, timeout=timeout)
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
response = self.client.post("/logs", json=self._payload_from_record(record))
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError:
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.client.close()
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def _payload_from_record(self, record: logging.LogRecord) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": record.name,
|
||||||
|
"levelno": record.levelno,
|
||||||
|
"levelname": record.levelname,
|
||||||
|
"pathname": record.pathname,
|
||||||
|
"lineno": record.lineno,
|
||||||
|
"funcName": record.funcName,
|
||||||
|
"created": record.created,
|
||||||
|
"process": record.process,
|
||||||
|
"processName": record.processName,
|
||||||
|
"threadName": record.threadName,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
"args": None,
|
||||||
|
}
|
||||||
|
if record.exc_text is not None:
|
||||||
|
payload["exc_text"] = record.exc_text
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(
|
||||||
|
collector_url: str = "http://127.0.0.1:9021",
|
||||||
|
token: str | None = None,
|
||||||
|
) -> logging.handlers.QueueListener:
|
||||||
|
log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=1000)
|
||||||
|
queue_handler = PreservingQueueHandler(log_queue)
|
||||||
|
http_handler = HttpxJsonLogHandler(collector_url, token)
|
||||||
|
listener = logging.handlers.QueueListener(log_queue, http_handler, respect_handler_level=True)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(logging.INFO)
|
||||||
|
root.handlers[:] = [queue_handler]
|
||||||
|
|
||||||
|
listener.start()
|
||||||
|
return listener
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="app.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from logging_config import configure_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
listener = configure_logging()
|
||||||
|
try:
|
||||||
|
logger.info("Service started")
|
||||||
|
logger.warning("Example warning from the HTTPX client")
|
||||||
|
finally:
|
||||||
|
listener.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
Start `log_http_receiver.py` first, then run `app.py`. The receiver should print the records using its own formatter.
|
||||||
|
|
||||||
|
## HTTPX Mechanics
|
||||||
|
|
||||||
|
- `httpx.Client` keeps a connection pool. That matters for log handlers because repeated top-level `httpx.post(...)` calls would create new connections instead of reusing them.
|
||||||
|
- `base_url` makes the handler explicit about the collector host while keeping the endpoint path short.
|
||||||
|
- `timeout` is explicit. HTTPX has default timeouts, but logging code should state its tolerance for connect, write, read, and pool waits.
|
||||||
|
- `response.raise_for_status()` turns non-2xx responses into `HTTPStatusError`, which then goes through the logging handler's normal error path.
|
||||||
|
- `close()` closes the HTTPX connection pool. Pair this with `listener.stop()` during application shutdown so queued records are sent and resources are released.
|
||||||
|
|
||||||
|
## Logging Mechanics
|
||||||
|
|
||||||
|
- The application still configures logging once. Feature modules only call named loggers.
|
||||||
|
- The HTTP handler sits behind `QueueHandler` and `QueueListener` because HTTP requests can block on DNS, connection pooling, TLS, request writes, response reads, and collector back pressure.
|
||||||
|
- `PreservingQueueHandler` copies the record and formats exception text before clearing `exc_info`, so the queued record is safe to serialize and still carries useful traceback information.
|
||||||
|
- The handler serializes a deliberate subset of `LogRecord` fields. Sending `record.__dict__` wholesale is easy, but it can include unserializable objects, accidental high-cardinality fields, or data the collector should not receive.
|
||||||
|
- Authentication is represented as an optional bearer token header. In real applications, read tokens from a secret manager or runtime configuration, not from source code.
|
||||||
|
|
||||||
|
## Rationale Behind The Pattern
|
||||||
|
|
||||||
|
### Prefer HTTP When The Receiver Is Already An HTTP API
|
||||||
|
|
||||||
|
HTTP is a good fit when logs go to a collector, gateway, ingestion service, or internal API that already expects JSON over HTTPS. It also gives you familiar deployment controls: TLS termination, authentication, reverse proxies, rate limiting, request size limits, and conventional status codes.
|
||||||
|
|
||||||
|
### Keep HTTP Off The Caller Path
|
||||||
|
|
||||||
|
Even a fast collector can become slow during deploys, network incidents, or downstream outages. Queueing makes that failure mode a logging concern instead of a request-latency concern.
|
||||||
|
|
||||||
|
### Use A Bounded Queue For Honest Back Pressure
|
||||||
|
|
||||||
|
The example uses `queue.Queue(maxsize=1000)` so overload becomes visible. The default `QueueHandler.enqueue()` uses `put_nowait()`, so a full queue calls `handleError()`. For production, decide whether to drop logs, block briefly, spill to disk, or switch to a platform collector.
|
||||||
|
|
||||||
|
### Filter Before Sending
|
||||||
|
|
||||||
|
The collector can reject records, but rejected records already consumed CPU, queue capacity, and network bandwidth. Use client-side logger and handler levels to avoid sending noisy records unless the deployment explicitly needs them.
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
Before using this beyond a local demo:
|
||||||
|
|
||||||
|
1. Use HTTPS and authenticate clients. Treat the collector endpoint as an ingestion boundary, not a public anonymous API.
|
||||||
|
2. Set request size limits and reject malformed payloads early.
|
||||||
|
3. Decide the outage policy for collector failures and full queues.
|
||||||
|
4. Add service, environment, instance, request, trace, or tenant identifiers as explicit serialized fields when operators need correlation.
|
||||||
|
5. Redact or avoid secrets before records leave the process.
|
||||||
|
6. Normalize untrusted newline-containing values if the final destination is line-oriented.
|
||||||
|
7. Tune timeouts and queue size under load, not only with a happy-path local receiver.
|
||||||
|
8. Prefer a managed collector, OpenTelemetry pipeline, or platform-native logging when one already exists.
|
||||||
|
|
||||||
|
## Review Questions
|
||||||
|
|
||||||
|
Use these questions when reviewing HTTPX logging code:
|
||||||
|
|
||||||
|
- Does application code only call named loggers, without direct HTTP calls from feature modules?
|
||||||
|
- Is the HTTP handler behind a queue for web, async, worker, or high-throughput paths?
|
||||||
|
- Does the handler use a reusable `httpx.Client` rather than top-level request functions?
|
||||||
|
- Are timeouts explicit and short enough for a logging path?
|
||||||
|
- Are collector failures, non-2xx responses, and full queues handled deliberately?
|
||||||
|
- Does shutdown stop the listener and close the HTTPX client?
|
||||||
|
- Are authentication, TLS, secrets, request size, and high-cardinality context handled deliberately?
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
# Network Logging Minimal Example
|
||||||
|
|
||||||
|
Use this reference when an application needs to send Python logs across a network to a receiver process. The example is intentionally small, but it keeps the important production-shaped boundaries: application modules use normal named loggers, startup code configures routing once, network I/O happens away from the caller path, and the receiver owns final formatting and destinations.
|
||||||
|
|
||||||
|
Source docs to keep nearby:
|
||||||
|
|
||||||
|
- [Sending and receiving logging events across a network](https://docs.python.org/3/howto/logging-cookbook.html#sending-and-receiving-logging-events-across-a-network) for the standard socket-listener recipe.
|
||||||
|
- [Dealing with handlers that block](https://docs.python.org/3/howto/logging-cookbook.html#dealing-with-handlers-that-block) for why `QueueHandler` and `QueueListener` belong in front of slow handlers.
|
||||||
|
- [`SocketHandler`](https://docs.python.org/3/library/logging.handlers.html#sockethandler) for the built-in network handler and its pickle-based default wire format.
|
||||||
|
- [`QueueHandler`](https://docs.python.org/3/library/logging.handlers.html#queuehandler) and [`QueueListener`](https://docs.python.org/3/library/logging.handlers.html#queuelistener) for queue-backed logging mechanics.
|
||||||
|
- [`logging.makeLogRecord`](https://docs.python.org/3/library/logging.html#logging.makeLogRecord) for recreating a `LogRecord` from serialized fields.
|
||||||
|
- [`socketserver`](https://docs.python.org/3/library/socketserver.html) for a tiny TCP receiver.
|
||||||
|
- [logging configuration security considerations](https://docs.python.org/3/library/logging.config.html#security-considerations) for treating remote logging configuration and importable config objects as trusted inputs only.
|
||||||
|
|
||||||
|
## Minimal Topology
|
||||||
|
|
||||||
|
Run one receiver process near the final logging destination. Application processes send serialized records to it, and the receiver decides how those records are formatted, filtered, written, rotated, or forwarded.
|
||||||
|
|
||||||
|
```text
|
||||||
|
application code -> named logger -> QueueHandler -> QueueListener -> JSON TCP handler -> receiver -> final handlers
|
||||||
|
```
|
||||||
|
|
||||||
|
This shape is useful because network handlers can block. Even a socket handler can pause on DNS, connection setup, back pressure, or a slow collector. The queue keeps normal request, worker, or CLI code from doing that work directly.
|
||||||
|
|
||||||
|
It also keeps application code boring in the best way:
|
||||||
|
|
||||||
|
```python title="feature.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def process_order(order_id: str) -> None:
|
||||||
|
logger.info("Processing order %s", order_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
The feature module does not know whether logs go to a terminal, a file, a socket, or a collector. That decision belongs to application startup.
|
||||||
|
|
||||||
|
## Receiver
|
||||||
|
|
||||||
|
The receiver accepts newline-delimited JSON records, recreates `LogRecord` objects, and routes them through local logging configuration.
|
||||||
|
|
||||||
|
```python title="log_receiver.py"
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.config
|
||||||
|
import socketserver
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
"version": 1,
|
||||||
|
"disable_existing_loggers": False,
|
||||||
|
"formatters": {
|
||||||
|
"console": {
|
||||||
|
"format": "%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"handlers": {
|
||||||
|
"console": {
|
||||||
|
"class": "logging.StreamHandler",
|
||||||
|
"formatter": "console",
|
||||||
|
"stream": "ext://sys.stdout",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {"level": "INFO", "handlers": ["console"]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LogRecordHandler(socketserver.StreamRequestHandler):
|
||||||
|
def handle(self) -> None:
|
||||||
|
for line in self.rfile:
|
||||||
|
try:
|
||||||
|
payload = json.loads(line.decode("utf-8"))
|
||||||
|
record = logging.makeLogRecord(payload)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception("Dropped malformed log record")
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger = logging.getLogger(record.name)
|
||||||
|
if logger.isEnabledFor(record.levelno):
|
||||||
|
logger.handle(record)
|
||||||
|
|
||||||
|
|
||||||
|
class LogRecordServer(socketserver.ThreadingTCPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
logging.config.dictConfig(LOGGING)
|
||||||
|
with LogRecordServer(("127.0.0.1", 9020), LogRecordHandler) as server:
|
||||||
|
logging.getLogger(__name__).info("Listening for log records")
|
||||||
|
server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Receiver Mechanics
|
||||||
|
|
||||||
|
- `dictConfig` is local to the receiver. Client processes do not decide the final formatter, file handler, rotation policy, or downstream sink.
|
||||||
|
- `makeLogRecord` rebuilds a logging record from plain fields. This is the same reconstruction step used by the cookbook socket receiver, but this example uses JSON instead of unpickling bytes from the network.
|
||||||
|
- The receiver looks up `logging.getLogger(record.name)` so package-level logger names still route through the normal logging hierarchy.
|
||||||
|
- The `isEnabledFor` check lets receiver-side logger levels suppress records before handlers run. Client-side filtering is still preferred when possible because it avoids wasted network traffic.
|
||||||
|
- Binding to `127.0.0.1` makes the demo local-only. Binding to `0.0.0.0` changes the trust boundary and should be paired with network controls, authentication, or a real collector protocol.
|
||||||
|
|
||||||
|
## Client Configuration
|
||||||
|
|
||||||
|
Configure logging once at application startup. The root logger writes to a queue, and a listener thread sends records over TCP.
|
||||||
|
|
||||||
|
```python title="logging_config.py"
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import logging.handlers
|
||||||
|
import queue
|
||||||
|
import socket
|
||||||
|
|
||||||
|
|
||||||
|
class PreservingQueueHandler(logging.handlers.QueueHandler):
|
||||||
|
def prepare(self, record: logging.LogRecord) -> logging.LogRecord:
|
||||||
|
copied = copy.copy(record)
|
||||||
|
copied.message = copied.getMessage()
|
||||||
|
copied.msg = copied.message
|
||||||
|
copied.args = None
|
||||||
|
|
||||||
|
if copied.exc_info is not None and copied.exc_text is None:
|
||||||
|
copied.exc_text = logging.Formatter().formatException(copied.exc_info)
|
||||||
|
copied.exc_info = None
|
||||||
|
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
class JsonTcpHandler(logging.Handler):
|
||||||
|
def __init__(self, host: str, port: int, timeout: float = 2.0) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self._socket: socket.socket | None = None
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
try:
|
||||||
|
payload = self._payload_from_record(record)
|
||||||
|
message = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
||||||
|
self._send(message + b"\n")
|
||||||
|
except Exception:
|
||||||
|
self._close_socket()
|
||||||
|
self.handleError(record)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._close_socket()
|
||||||
|
super().close()
|
||||||
|
|
||||||
|
def _close_socket(self) -> None:
|
||||||
|
if self._socket is not None:
|
||||||
|
self._socket.close()
|
||||||
|
self._socket = None
|
||||||
|
|
||||||
|
def _send(self, message: bytes) -> None:
|
||||||
|
if self._socket is None:
|
||||||
|
self._socket = socket.create_connection((self.host, self.port), self.timeout)
|
||||||
|
self._socket.sendall(message)
|
||||||
|
|
||||||
|
def _payload_from_record(self, record: logging.LogRecord) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": record.name,
|
||||||
|
"levelno": record.levelno,
|
||||||
|
"levelname": record.levelname,
|
||||||
|
"pathname": record.pathname,
|
||||||
|
"lineno": record.lineno,
|
||||||
|
"funcName": record.funcName,
|
||||||
|
"created": record.created,
|
||||||
|
"process": record.process,
|
||||||
|
"processName": record.processName,
|
||||||
|
"threadName": record.threadName,
|
||||||
|
"msg": record.getMessage(),
|
||||||
|
"args": None,
|
||||||
|
}
|
||||||
|
if record.exc_text is not None:
|
||||||
|
payload["exc_text"] = record.exc_text
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(host: str = "127.0.0.1", port: int = 9020) -> logging.handlers.QueueListener:
|
||||||
|
log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=1000)
|
||||||
|
queue_handler = PreservingQueueHandler(log_queue)
|
||||||
|
network_handler = JsonTcpHandler(host, port)
|
||||||
|
listener = logging.handlers.QueueListener(log_queue, network_handler, respect_handler_level=True)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(logging.INFO)
|
||||||
|
root.handlers[:] = [queue_handler]
|
||||||
|
|
||||||
|
listener.start()
|
||||||
|
return listener
|
||||||
|
```
|
||||||
|
|
||||||
|
```python title="app.py"
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from logging_config import configure_logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
listener = configure_logging()
|
||||||
|
try:
|
||||||
|
logger.info("Service started")
|
||||||
|
logger.warning("Example warning from the client")
|
||||||
|
finally:
|
||||||
|
listener.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
Start `log_receiver.py` first, then run `app.py`. The receiver should print the records using its own formatter.
|
||||||
|
|
||||||
|
### Client Mechanics
|
||||||
|
|
||||||
|
- `PreservingQueueHandler` copies the record before mutating it for queue transfer. The standard `QueueHandler.prepare()` intentionally formats and strips unpickleable fields; overriding it is the documented escape hatch when the listener side needs custom serialization or exception text.
|
||||||
|
- `queue.Queue(maxsize=1000)` makes back pressure visible. An unbounded queue is simpler, but a bounded queue forces a production decision about whether to drop, block, buffer elsewhere, or fail when the collector cannot keep up.
|
||||||
|
- `QueueListener` owns the slow handler thread. It should be stopped during application shutdown so queued records are processed before exit.
|
||||||
|
- `JsonTcpHandler` keeps a TCP connection open after the first event. That avoids a connection handshake per log record while keeping the example small enough to inspect.
|
||||||
|
- `handleError()` preserves standard logging error behavior. In production, set an explicit policy for dropped records and collector outages rather than assuming logs always arrive.
|
||||||
|
|
||||||
|
## Rationale Behind The Pattern
|
||||||
|
|
||||||
|
### Use JSON Instead Of The Default Pickle Payload
|
||||||
|
|
||||||
|
The built-in `SocketHandler` sends a pickled record dictionary. That is convenient on a trusted local path, but unpickling network input is a poor default at a trust boundary. JSON is not a complete security boundary by itself, but it is inspectable, language-neutral, and avoids executing pickle payloads.
|
||||||
|
|
||||||
|
If you use `SocketHandler` anyway, override `makePickle()` with a safer encoding, sign payloads with a scheme such as HMAC, or keep the listener strictly inside a trusted local network.
|
||||||
|
|
||||||
|
### Put The Network Handler Behind A Queue
|
||||||
|
|
||||||
|
The cookbook explicitly calls out network handlers as potentially blocking. A queue is the smallest standard-library pattern that separates business code from slow handler work. This matters for web requests, async event loops, worker hot paths, and CLIs where the user should not wait on a collector timeout.
|
||||||
|
|
||||||
|
### Centralize Final Destinations In The Receiver
|
||||||
|
|
||||||
|
Multiple processes writing one file directly is a common source of garbled output, failed rotation, and confusing retention behavior. A receiver process serializes that responsibility: clients emit events, and one process writes or forwards them according to one logging configuration.
|
||||||
|
|
||||||
|
### Keep Logger Names Stable
|
||||||
|
|
||||||
|
Use module loggers such as `logging.getLogger(__name__)`. Do not create a logger per request, user, socket, tenant, or connection. Put those values in structured fields or formatted messages instead. Logger objects are singletons and are not freed during normal execution, so unbounded logger names become an avoidable memory and routing problem.
|
||||||
|
|
||||||
|
### Filter Early When Volume Matters
|
||||||
|
|
||||||
|
The receiver can filter records, but records already crossed the network by then. Set client-side logger or handler levels so routine `DEBUG` records are not serialized and transmitted unless the deployment is intentionally collecting them.
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
Before using this beyond a local demo:
|
||||||
|
|
||||||
|
1. Protect the receiver with a trusted network boundary, TLS, a VPN, mutual authentication, or a real log collector. Do not expose an unauthenticated logging port to untrusted clients.
|
||||||
|
2. Decide the outage policy: drop records, block briefly, buffer locally, retry with backoff, or fail startup when the receiver is unavailable.
|
||||||
|
3. Size the queue and choose the overflow behavior deliberately. The default `QueueHandler.enqueue()` uses `put_nowait()`, so a full bounded queue goes through `handleError()`.
|
||||||
|
4. Include service, environment, instance, request, trace, or tenant identifiers when operators need cross-service correlation.
|
||||||
|
5. Redact or avoid secrets before records leave the process.
|
||||||
|
6. Escape or normalize untrusted newline-containing values if the final destination is line-oriented and vulnerable to log injection confusion.
|
||||||
|
7. Load-test the receiver and validate shutdown behavior before relying on the logs during incidents.
|
||||||
|
8. Prefer a managed collector, OpenTelemetry pipeline, syslog, container stdout collection, or platform-native logging when the deployment environment already provides one.
|
||||||
|
|
||||||
|
## Review Questions
|
||||||
|
|
||||||
|
Use these questions when reviewing network logging code:
|
||||||
|
|
||||||
|
- Does normal application code only call named loggers, without attaching handlers in feature modules?
|
||||||
|
- Is network or file I/O behind a queue for web, async, worker, or high-throughput paths?
|
||||||
|
- Is the wire format safe for the trust boundary, or does it rely on unpickling unauthenticated input?
|
||||||
|
- Are logger and handler levels set so noisy records are filtered before crossing the network?
|
||||||
|
- Is collector failure behavior explicit and tested?
|
||||||
|
- Does shutdown stop the listener and flush the queue?
|
||||||
|
- Are secrets, user-controlled newlines, and high-cardinality context handled deliberately?
|
||||||
+8
-6
@@ -1,6 +1,6 @@
|
|||||||
# Python Logging References
|
# Python Logging Source References
|
||||||
|
|
||||||
Use these official Python docs when applying this skill.
|
Use these official Python docs when applying the Python logging skill.
|
||||||
|
|
||||||
## Core Documentation
|
## Core Documentation
|
||||||
|
|
||||||
@@ -10,13 +10,15 @@ Use these official Python docs when applying this skill.
|
|||||||
- [logging API reference](https://docs.python.org/3/library/logging.html)
|
- [logging API reference](https://docs.python.org/3/library/logging.html)
|
||||||
- [logging.config reference](https://docs.python.org/3/library/logging.config.html)
|
- [logging.config reference](https://docs.python.org/3/library/logging.config.html)
|
||||||
|
|
||||||
## dictConfig-Specific
|
## Configuration And dictConfig
|
||||||
|
|
||||||
!!! info "dictConfig references"
|
!!! info "dictConfig references"
|
||||||
- [Dictionary schema details](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) for `version`, formatters, handlers, loggers, and root.
|
- [Dictionary schema details](https://docs.python.org/3/library/logging.config.html#logging-config-dictschema) for `version`, formatters, handlers, loggers, and root.
|
||||||
- [`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig) function reference.
|
- [`logging.config.dictConfig`](https://docs.python.org/3/library/logging.config.html#logging.config.dictConfig) function reference.
|
||||||
|
|
||||||
## Practical Notes
|
## Practical Notes
|
||||||
- Prefer app-level centralized config with one startup call to `dictConfig`.
|
- Prefer module loggers created with `logging.getLogger(__name__)`.
|
||||||
- In modules, use `logging.getLogger(__name__)`.
|
- Let applications configure handlers and formatters; libraries should emit logs without taking over routing.
|
||||||
- Avoid calling `basicConfig` in libraries or scattered modules.
|
- Use `basicConfig` for simple scripts and `dictConfig` for centralized application configuration.
|
||||||
|
- Explicitly set `disable_existing_loggers: False` in `dictConfig` unless disabling existing non-root loggers is intentional.
|
||||||
|
- Use queue-based handlers when slow handlers would block async, threaded, or high-volume code paths.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
---
|
||||||
|
name: python-typing
|
||||||
|
description: "Reference-first skill for reviewing and modernizing Python typing to the newest supported best practices. Use when auditing annotations, replacing legacy typing syntax, and enforcing latest-syntax-first conventions."
|
||||||
|
x-personal-mcp:
|
||||||
|
id: python-typing
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- python
|
||||||
|
- typing
|
||||||
|
- type-hints
|
||||||
|
- pep-695
|
||||||
|
- modernization
|
||||||
|
- static-analysis
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/python-typing/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Modern Python Typing Review Reference
|
||||||
|
|
||||||
|
Use this skill to enforce a latest-syntax-first typing standard grounded in current Python language guidance.
|
||||||
|
|
||||||
|
Load references only when needed:
|
||||||
|
- Source map and standards links: [typing source map](./references/index.md)
|
||||||
|
- Practical review workflow and quality gates: [typing review workflow](./references/review-workflow.md)
|
||||||
|
- Astral ty adoption and operation guidance: [Astral ty usage reference](./references/astral-ty.md)
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- A codebase still uses legacy `typing` patterns and should be updated to modern syntax.
|
||||||
|
- You need a repeatable process for type-focused code review across a package or module.
|
||||||
|
- You want references to official Python docs and PEPs attached to recommendations.
|
||||||
|
- You need to decide whether a modern feature is allowed under the project Python version.
|
||||||
|
|
||||||
|
## How To Use This Skill
|
||||||
|
|
||||||
|
1. Confirm the effective Python baseline from project config (for example `pyproject.toml` and lint target version).
|
||||||
|
2. Scan target files for legacy patterns and prioritize newest canonical syntax first.
|
||||||
|
3. Apply modern typing upgrades aggressively, keeping runtime behavior stable unless explicitly requested otherwise.
|
||||||
|
4. Validate with project lint and diagnostics.
|
||||||
|
5. Report what changed and list only hard-blocker deferrals (for example incompatible Python baseline).
|
||||||
|
|
||||||
|
## Intent Router
|
||||||
|
|
||||||
|
- Baseline and compatibility checks: [typing source map](./references/index.md)
|
||||||
|
- Exact modernization sequence and branching logic: [typing review workflow](./references/review-workflow.md)
|
||||||
|
- Integrating or tuning Astral ty: [Astral ty usage reference](./references/astral-ty.md)
|
||||||
|
- Need official rationale for a specific feature: [typing source map](./references/index.md)
|
||||||
|
|
||||||
|
## Load Order
|
||||||
|
|
||||||
|
1. Start with [typing source map](./references/index.md) for authoritative links.
|
||||||
|
2. Load [typing review workflow](./references/review-workflow.md) to execute the review.
|
||||||
|
3. Load [Astral ty usage reference](./references/astral-ty.md) when the workflow includes `ty` setup, configuration, migration, or editor integration.
|
||||||
|
4. Return to source links for any feature-level recommendation included in the final output.
|
||||||
|
|
||||||
|
## Load Budget
|
||||||
|
|
||||||
|
1. Default: load one reference (`index.md`) for lightweight guidance.
|
||||||
|
2. Standard review: load two references (`index.md` and `review-workflow.md`).
|
||||||
|
3. Add `astral-ty.md` only when `ty` is in scope.
|
||||||
|
4. Do not load additional docs unless a project-specific edge case requires it.
|
||||||
|
|
||||||
|
## Decision Baseline
|
||||||
|
|
||||||
|
Use these defaults unless a hard compatibility constraint prevents them:
|
||||||
|
|
||||||
|
1. Prefer built-in generics (`list[str]`, `dict[str, int]`) over `typing.List` and `typing.Dict`.
|
||||||
|
2. Prefer `X | Y` over `typing.Optional[X]` or `typing.Union[X, Y]`.
|
||||||
|
3. Prefer PEP 695 generics (`class Box[T]`, `def fn[T](...)`) for Python 3.12+ codebases and use them by default.
|
||||||
|
4. Prefer `typing.Self` for fluent instance/class method return typing.
|
||||||
|
5. Use `typing.Literal` when a finite value set is the real contract.
|
||||||
|
6. Remove legacy typing aliases and module-level `TypeVar` declarations when PEP 695 can replace them.
|
||||||
|
7. Keep runtime behavior unchanged unless the task explicitly requests behavior refactors.
|
||||||
|
8. Treat `typing.cast(...)` as a last resort, not a default fix for type-checker complaints.
|
||||||
|
9. Before adding a cast, prefer real narrowing (`isinstance`, `TypeIs`/`TypeGuard`), explicit control-flow checks, or small annotation refactors that preserve behavior.
|
||||||
|
10. Reject casts whose only purpose is to silence the checker without a clear runtime invariant.
|
||||||
|
11. For closed variant sets (`Literal`/`Enum`/tagged unions), prefer structural pattern matching with exhaustiveness checks (`assert_never`) for deterministic narrowing.
|
||||||
|
|
||||||
|
## Cast Discipline
|
||||||
|
|
||||||
|
Use this policy whenever a modernization pass encounters a potential cast:
|
||||||
|
|
||||||
|
1. Confirm whether the checker can be satisfied with stronger narrowing first (for example `isinstance` or assertion-based narrowing).
|
||||||
|
2. If a cast is still necessary, keep it narrowly scoped to the exact expression rather than widening an entire variable flow.
|
||||||
|
3. Document the invariant that makes the cast valid in human terms, not just "type checker requires this".
|
||||||
|
4. Prefer fixing imprecise annotations at the source over stacking repeated casts downstream.
|
||||||
|
5. If multiple casts appear in one code path, treat that as a design smell and propose a structural typing fix.
|
||||||
|
|
||||||
|
## Completion Checks
|
||||||
|
|
||||||
|
1. Modern syntax aligns with the project Python baseline.
|
||||||
|
2. Linting and diagnostics are clean for edited files.
|
||||||
|
3. Public APIs are unchanged unless explicitly requested.
|
||||||
|
4. Feature-level recommendations include source links.
|
||||||
|
5. Any deferral is backed by a specific hard constraint (for example Python version floor).
|
||||||
|
6. New casts, if any, are minimal, justified by an explicit invariant, and not used as checker-silencing shortcuts.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Return:
|
||||||
|
|
||||||
|
1. Files reviewed and files changed.
|
||||||
|
2. Applied typing upgrades with brief rationale.
|
||||||
|
3. Deferred upgrades only when blocked by explicit hard constraints.
|
||||||
|
4. Validation results (lint/tests/diagnostics).
|
||||||
|
5. References consulted and discovery path used.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
# Astral ty Usage Reference
|
||||||
|
|
||||||
|
Use this page when you want to run or adopt [ty](https://docs.astral.sh/ty/), Astral's Python type checker and language server, in a typing-focused workflow.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
- Run a one-off check without installing globally: `uvx ty check`
|
||||||
|
- Run checks in the current project: `ty check`
|
||||||
|
- Explore behavior quickly in the [ty playground](https://play.ty.dev/)
|
||||||
|
|
||||||
|
Primary docs:
|
||||||
|
|
||||||
|
- [Getting started](https://docs.astral.sh/ty/#getting-started)
|
||||||
|
- [Installation](https://docs.astral.sh/ty/installation/)
|
||||||
|
- [Type checking](https://docs.astral.sh/ty/type-checking/)
|
||||||
|
- [CLI reference](https://docs.astral.sh/ty/reference/cli/)
|
||||||
|
|
||||||
|
## Editor Integration
|
||||||
|
|
||||||
|
Use ty as a language server in supported editors.
|
||||||
|
|
||||||
|
- [Editor integration overview](https://docs.astral.sh/ty/editors/)
|
||||||
|
- [VS Code setup](https://docs.astral.sh/ty/editors/#vs-code)
|
||||||
|
- [Language server capabilities](https://docs.astral.sh/ty/features/language-server/)
|
||||||
|
- [Editor settings reference](https://docs.astral.sh/ty/reference/editor-settings/)
|
||||||
|
|
||||||
|
## Configuration Surface
|
||||||
|
|
||||||
|
Start from project defaults, then add targeted overrides only where needed.
|
||||||
|
|
||||||
|
- [Configuration guide](https://docs.astral.sh/ty/configuration/)
|
||||||
|
- [Configuration reference](https://docs.astral.sh/ty/reference/configuration/)
|
||||||
|
- [Python version handling](https://docs.astral.sh/ty/python-version/)
|
||||||
|
- [Module discovery](https://docs.astral.sh/ty/modules/)
|
||||||
|
- [File exclusions](https://docs.astral.sh/ty/exclusions/)
|
||||||
|
|
||||||
|
## Rule And Suppression Controls
|
||||||
|
|
||||||
|
Use this set when tuning signal-to-noise in large or partially typed codebases.
|
||||||
|
|
||||||
|
- [Rules overview](https://docs.astral.sh/ty/rules/)
|
||||||
|
- [Rules reference](https://docs.astral.sh/ty/reference/rules/)
|
||||||
|
- [Suppression comments and directives](https://docs.astral.sh/ty/suppression/)
|
||||||
|
- [Diagnostics feature docs](https://docs.astral.sh/ty/features/diagnostics/)
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
|
||||||
|
For teams moving from existing type checkers, use Astral's migration guidance first.
|
||||||
|
|
||||||
|
- [Coming from mypy or pyright](https://docs.astral.sh/ty/coming-from-mypy-or-pyright/)
|
||||||
|
- [Typing FAQ](https://docs.astral.sh/ty/reference/typing-faq)
|
||||||
|
|
||||||
|
## Suggested Review Flow With ty
|
||||||
|
|
||||||
|
1. Confirm Python baseline and project targets.
|
||||||
|
2. Run `uvx ty check` for an initial signal pass.
|
||||||
|
3. Configure version/module/discovery settings as needed.
|
||||||
|
4. Triage diagnostics and tune rules or suppressions deliberately.
|
||||||
|
5. Re-run checks and keep modernization changes behavior-preserving unless explicitly requested.
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Python Typing Source Map
|
||||||
|
|
||||||
|
Use this page as the canonical source index when making typing modernization recommendations.
|
||||||
|
|
||||||
|
## Core Language and Library Docs
|
||||||
|
|
||||||
|
- [Typing module documentation](https://docs.python.org/3/library/typing.html)
|
||||||
|
- [Typing specification (typing.python.org)](https://typing.python.org/)
|
||||||
|
- [Built-in types and generic aliases](https://docs.python.org/3/library/stdtypes.html)
|
||||||
|
- [Python language reference: `match` statement](https://docs.python.org/3/reference/compound_stmts.html#the-match-statement)
|
||||||
|
- [PEP 634: Structural Pattern Matching specification](https://peps.python.org/pep-0634/)
|
||||||
|
- [typing.cast reference (runtime no-op)](https://docs.python.org/3/library/typing.html#typing.cast)
|
||||||
|
- [Typing spec directives for `cast()`](https://typing.python.org/en/latest/spec/directives.html#cast)
|
||||||
|
- [Mypy type narrowing and casts guidance](https://mypy.readthedocs.io/en/stable/type_narrowing.html#casts)
|
||||||
|
- [Typing guide: exhaustiveness and `assert_never`](https://typing.python.org/en/latest/guides/unreachable.html#assert-never-and-exhaustiveness-checking)
|
||||||
|
- [Mypy: `Literal`/`Enum` exhaustiveness with `match`](https://mypy.readthedocs.io/en/stable/literal_types.html#exhaustiveness-checking)
|
||||||
|
|
||||||
|
## Tooling References
|
||||||
|
|
||||||
|
- [Astral ty documentation](https://docs.astral.sh/ty/)
|
||||||
|
- [Astral ty usage reference (this skill)](./astral-ty.md)
|
||||||
|
|
||||||
|
## Modernization PEPs
|
||||||
|
|
||||||
|
- [PEP 585: Type Hinting Generics In Standard Collections](https://peps.python.org/pep-0585/)
|
||||||
|
- [PEP 604: Allow writing union types as `X | Y`](https://peps.python.org/pep-0604/)
|
||||||
|
- [PEP 673: Self Type](https://peps.python.org/pep-0673/)
|
||||||
|
- [PEP 695: Type Parameter Syntax](https://peps.python.org/pep-0695/)
|
||||||
|
|
||||||
|
## Advanced Typing PEPs (Load on Demand)
|
||||||
|
|
||||||
|
- [PEP 612: Parameter Specification Variables](https://peps.python.org/pep-0612/)
|
||||||
|
- [PEP 646: Variadic Generics](https://peps.python.org/pep-0646/)
|
||||||
|
- [PEP 647: User-Defined Type Guards](https://peps.python.org/pep-0647/)
|
||||||
|
- [PEP 655: Required and NotRequired for TypedDict](https://peps.python.org/pep-0655/)
|
||||||
|
- [PEP 742: Narrowing types with TypeIs](https://peps.python.org/pep-0742/)
|
||||||
|
|
||||||
|
## Version Gate Reminder
|
||||||
|
|
||||||
|
Before recommending syntax upgrades, verify the project's supported Python range and lint target so recommendations match runtime constraints.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Typing Review Workflow
|
||||||
|
|
||||||
|
This workflow is distilled from practical typing modernization passes and is designed for latest-syntax-first upgrades.
|
||||||
|
|
||||||
|
## Step-by-Step Process
|
||||||
|
|
||||||
|
1. Identify the Python baseline from project config (`requires-python`, lint target version, toolchain constraints).
|
||||||
|
2. Scan target files for legacy typing patterns and repeated opportunities.
|
||||||
|
3. Apply highest-value modern syntax updates first:
|
||||||
|
- `typing.List`/`typing.Dict` -> built-in generics.
|
||||||
|
- `Optional[T]`/`Union[A, B]` -> `T | None` / `A | B`.
|
||||||
|
4. Upgrade generic declarations to PEP 695 syntax where baseline allows:
|
||||||
|
- `TypeVar` module globals -> local type parameters in classes/functions.
|
||||||
|
5. Tighten domain contracts where clear:
|
||||||
|
- replace unconstrained `str` with `Literal[...]` for finite known values.
|
||||||
|
- use `Self` for fluent APIs.
|
||||||
|
6. Keep edits minimal and avoid behavior changes unless requested.
|
||||||
|
7. Validate with lint and editor diagnostics.
|
||||||
|
8. Report applied changes, hard-blocker deferrals, and sources consulted.
|
||||||
|
|
||||||
|
## Decision Points and Branching
|
||||||
|
|
||||||
|
- If Python baseline is below 3.12:
|
||||||
|
- use the newest syntax available under that baseline, and document exactly what blocked PEP 695.
|
||||||
|
- If a legacy annotation is public API and downstream tooling compatibility is unknown:
|
||||||
|
- still modernize syntax unless there is a confirmed breakage risk with a named downstream constraint.
|
||||||
|
- If replacing `TypeVar` with PEP 695 affects readability debates only:
|
||||||
|
- still prefer PEP 695; readability preference alone is not a blocker.
|
||||||
|
- If a stricter type (for example `Literal`) may reject existing runtime inputs:
|
||||||
|
- apply only when the input contract is already finite; otherwise defer with a contract-change note.
|
||||||
|
|
||||||
|
## Deterministic Narrowing with `match`
|
||||||
|
|
||||||
|
Use structural pattern matching when the domain is a closed set (for example tagged unions, enum dispatch, or finite literal variants).
|
||||||
|
|
||||||
|
1. Prefer `match` over long `if`/`elif` ladders when each branch represents a distinct variant.
|
||||||
|
2. For tagged unions, match the discriminant and extract payload fields in the same case.
|
||||||
|
3. Add a default `case _:` branch with `assert_never(...)` to enforce exhaustiveness in static analysis.
|
||||||
|
4. Keep patterns explicit and side-effect-light; avoid relying on bindings from failed matches.
|
||||||
|
|
||||||
|
Example with a tagged `TypedDict` union:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Literal, TypedDict, assert_never
|
||||||
|
|
||||||
|
|
||||||
|
class NewJobEvent(TypedDict):
|
||||||
|
tag: Literal["new-job"]
|
||||||
|
job_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class CancelJobEvent(TypedDict):
|
||||||
|
tag: Literal["cancel-job"]
|
||||||
|
job_id: int
|
||||||
|
|
||||||
|
|
||||||
|
type Event = NewJobEvent | CancelJobEvent
|
||||||
|
|
||||||
|
|
||||||
|
def route(event: Event) -> str:
|
||||||
|
match event:
|
||||||
|
case {"tag": "new-job", "job_name": job_name}:
|
||||||
|
return f"enqueue:{job_name}"
|
||||||
|
case {"tag": "cancel-job", "job_id": job_id}:
|
||||||
|
return f"cancel:{job_id}"
|
||||||
|
case _:
|
||||||
|
assert_never(event)
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern makes narrowing deterministic per branch and surfaces missing variants as type-checker errors during review.
|
||||||
|
|
||||||
|
## Quality Criteria
|
||||||
|
|
||||||
|
1. All edits are syntax-valid for the target Python versions.
|
||||||
|
2. Lint and diagnostics pass for edited files.
|
||||||
|
3. Runtime behavior is unchanged for modernization-only tasks.
|
||||||
|
4. Recommendations cite authoritative sources.
|
||||||
|
5. Output clearly separates "changed now" from hard-blocked follow-up items.
|
||||||
|
|
||||||
|
## Suggested Validation Commands
|
||||||
|
|
||||||
|
- `uv run ruff check <paths>`
|
||||||
|
- `uv run pytest -q` (or targeted tests where available)
|
||||||
|
|
||||||
|
Use repository-preferred test invocation conventions when they differ.
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: ruff-linting-formating
|
||||||
|
description: "Reference-first Ruff skill for repository preferences, baseline defaults, and source links. Use to pick consistent Ruff conventions and integration references, not to run migration playbooks."
|
||||||
|
x-personal-mcp:
|
||||||
|
id: ruff-linting-formating
|
||||||
|
version: 1.0.0
|
||||||
|
tags:
|
||||||
|
- ruff
|
||||||
|
- linting
|
||||||
|
- formatting
|
||||||
|
- python
|
||||||
|
- ci
|
||||||
|
capabilities:
|
||||||
|
- resource://skills/ruff-linting-formating/document
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ruff Preferences and References
|
||||||
|
|
||||||
|
Use this skill as a reference index for Ruff preferences, conventions, and source documentation.
|
||||||
|
|
||||||
|
This document is intentionally not a migration or transition playbook.
|
||||||
|
|
||||||
|
Load references only when needed:
|
||||||
|
- Ruff core documentation: [Ruff docs](./references/ruff-docs.md)
|
||||||
|
- Tooling integrations (pre-commit and GitHub Actions): [Ruff integrations](./references/ruff-integrations.md)
|
||||||
|
|
||||||
|
## When To Use
|
||||||
|
|
||||||
|
- You want canonical Ruff preferences for this repository context.
|
||||||
|
- You need source links for rule selection, formatter behavior, and integrations.
|
||||||
|
- You are deciding configuration defaults, not planning a migration sequence.
|
||||||
|
|
||||||
|
## Preference Baseline
|
||||||
|
|
||||||
|
Use these as default preferences unless the target repository states otherwise:
|
||||||
|
|
||||||
|
1. Keep linting and formatting both enabled.
|
||||||
|
2. Keep imports sorted via Ruff (`I` rules) rather than a separate import tool.
|
||||||
|
3. Prefer explicit, small rule-family selection first (`E`, `F`, `I`, `UP`) and expand deliberately.
|
||||||
|
4. Keep line length, target Python, and formatter settings aligned to repository policy.
|
||||||
|
5. Keep local and CI execution behavior equivalent.
|
||||||
|
|
||||||
|
### Rule Link Requirement
|
||||||
|
|
||||||
|
When adding a specific rule or ruleset to `ruff.toml`, search for the authoritative Ruff documentation page for that rule or ruleset and include a link to it. You may add the URL as a nearby comment in `ruff.toml` or record it in the repository docs (for example in a CONTRIBUTING or linting section). Prefer links to the official [Ruff rules reference](https://docs.astral.sh/ruff/rules/).
|
||||||
|
|
||||||
|
### Version Discovery Requirement
|
||||||
|
|
||||||
|
When integrating Ruff or any third-party Action for the first time, always search for the latest stable release of:
|
||||||
|
|
||||||
|
- the `ruff` package ([releases](https://github.com/astral-sh/ruff/releases))
|
||||||
|
- the `astral-sh/ruff-pre-commit` hook ([releases](https://github.com/astral-sh/ruff-pre-commit/releases))
|
||||||
|
- the `astral-sh/ruff-action` ([releases](https://github.com/astral-sh/ruff-action/releases))
|
||||||
|
- the `astral-sh/setup-uv` action ([releases](https://github.com/astral-sh/setup-uv/releases))
|
||||||
|
|
||||||
|
Document the version you chose in the example snippet or in a nearby docs file and prefer pinning to a released tag in CI examples. If you intentionally use `latest`, note the reason and the associated risk in repo docs.
|
||||||
|
|
||||||
|
## Decision Inputs
|
||||||
|
|
||||||
|
Collect only the minimum context needed for preference decisions:
|
||||||
|
|
||||||
|
1. Supported Python versions.
|
||||||
|
2. Existing `pyproject.toml` constraints.
|
||||||
|
3. CI provider and required checks.
|
||||||
|
4. Whether pre-commit is in use.
|
||||||
|
|
||||||
|
## Template
|
||||||
|
|
||||||
|
[Full template ruff.toml](https://gitea.john-stream.com/john/python-template/src/branch/main/project/ruff.toml)
|
||||||
|
|
||||||
|
```toml title="Preferred Baseline"
|
||||||
|
line-length = 120
|
||||||
|
indent-width = 4
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
".git",
|
||||||
|
".venv",
|
||||||
|
".devenv",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
extend-fixable = ["ALL"]
|
||||||
|
extend-select = [
|
||||||
|
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
|
||||||
|
"E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
|
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
|
||||||
|
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
|
||||||
|
"PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
|
||||||
|
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint.isort]
|
||||||
|
force-single-line = true
|
||||||
|
|
||||||
|
[format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reference Map
|
||||||
|
|
||||||
|
1. Rules and settings source of truth: [Ruff docs](./references/ruff-docs.md)
|
||||||
|
2. pre-commit and GitHub Actions examples: [Ruff integrations](./references/ruff-integrations.md)
|
||||||
|
3. Template to copy from or compare against: [python-template ruff.toml](https://gitea.john-stream.com/john/python-template/src/branch/main/project/ruff.toml)
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This skill does not define:
|
||||||
|
|
||||||
|
1. Step-by-step migration phases.
|
||||||
|
2. Rollout modes or cutover timelines.
|
||||||
|
3. Mechanical rewrite plans for legacy tooling.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Ruff Source Documentation
|
||||||
|
|
||||||
|
Use this reference when implementing or tuning Ruff in repositories.
|
||||||
|
|
||||||
|
## Core Docs
|
||||||
|
|
||||||
|
- [Ruff overview](https://docs.astral.sh/ruff/)
|
||||||
|
- [Rules reference](https://docs.astral.sh/ruff/rules/)
|
||||||
|
- [Settings reference](https://docs.astral.sh/ruff/settings/)
|
||||||
|
- [Formatter docs](https://docs.astral.sh/ruff/formatter/)
|
||||||
|
- [The Ruff linter](https://docs.astral.sh/ruff/linter/)
|
||||||
|
|
||||||
|
## Migration And Integration
|
||||||
|
|
||||||
|
- [Migrating from Black](https://docs.astral.sh/ruff/formatter/#migrating-from-black)
|
||||||
|
- [Migrating from Flake8](https://docs.astral.sh/ruff/linter/#migrating-from-flake8)
|
||||||
|
- [Migrating from isort](https://docs.astral.sh/ruff/formatter/#sorting-imports)
|
||||||
|
- [Pre-commit integration](https://docs.astral.sh/ruff/integrations/#pre-commit)
|
||||||
|
- [GitHub Actions integration](https://docs.astral.sh/ruff/integrations/#github-actions)
|
||||||
|
|
||||||
|
## Python Packaging Context
|
||||||
|
|
||||||
|
- [PEP 621 project metadata in pyproject.toml](https://peps.python.org/pep-0621/)
|
||||||
|
- [uv project and workflow docs](https://docs.astral.sh/uv/)
|
||||||
|
|
||||||
|
## Suggested Reading Order
|
||||||
|
|
||||||
|
1. Overview and settings.
|
||||||
|
2. Rules and linter behavior.
|
||||||
|
3. Formatter and migration references.
|
||||||
|
4. CI and pre-commit integration notes.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Ruff Integrations: Tooling Patterns
|
||||||
|
|
||||||
|
Use this page when wiring Ruff into local developer workflows and CI.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This reference covers:
|
||||||
|
|
||||||
|
1. [pre-commit](https://pre-commit.com/) hooks for local and pre-push enforcement.
|
||||||
|
2. [GitHub Actions](https://docs.github.com/en/actions) checks for pull request and branch protection gates.
|
||||||
|
|
||||||
|
For Ruff-specific flags and settings, see [Ruff docs](./ruff-docs.md).
|
||||||
|
|
||||||
|
## pre-commit Integration
|
||||||
|
|
||||||
|
### Why use it
|
||||||
|
|
||||||
|
Use pre-commit when you want fast feedback before code reaches CI and consistent checks across contributors.
|
||||||
|
|
||||||
|
### Add hooks
|
||||||
|
|
||||||
|
Create or update [.pre-commit-config.yaml](https://pre-commit.com/#2-add-a-pre-commit-configuration) with Ruff hooks from [astral-sh/ruff-pre-commit](https://github.com/astral-sh/ruff-pre-commit):
|
||||||
|
|
||||||
|
```yaml title=".pre-commit-config.yaml"
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.15.18
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
```
|
||||||
|
|
||||||
|
Pin the hook revision and update intentionally during dependency maintenance.
|
||||||
|
|
||||||
|
### Install and run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pre-commit install
|
||||||
|
uv run pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
If the project does not manage pre-commit via uv, use your standard Python environment installation path.
|
||||||
|
|
||||||
|
### Recommended policy
|
||||||
|
|
||||||
|
1. Keep auto-fix enabled locally with ruff-check --fix.
|
||||||
|
2. Keep CI in check-only mode so violations fail loudly.
|
||||||
|
3. Run hooks on all files in migration PRs to avoid drift.
|
||||||
|
|
||||||
|
## GitHub Actions Integration
|
||||||
|
|
||||||
|
### Why use it
|
||||||
|
|
||||||
|
Use GitHub Actions when you need required status checks on pull requests and a single source of truth for lint and format gates.
|
||||||
|
|
||||||
|
### Minimal workflow
|
||||||
|
|
||||||
|
Create [.github/workflows/ruff.yml](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions):
|
||||||
|
|
||||||
|
```yaml title=".github/workflows/ruff.yml"
|
||||||
|
name: Ruff
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ruff:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v8.2.0
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
- name: Install project dependencies
|
||||||
|
run: uv sync --dev
|
||||||
|
|
||||||
|
- name: Ruff lint
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
|
- name: Ruff format check
|
||||||
|
run: uv run ruff format --check .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alternative: official Ruff action
|
||||||
|
|
||||||
|
If you want an action-focused setup, see [Ruff GitHub Actions integration](https://docs.astral.sh/ruff/integrations/#github-actions). The official Ruff action is commonly used pinned at `astral-sh/[email protected]`. Keep behavior equivalent to local commands so results do not diverge.
|
||||||
|
|
||||||
|
## Alignment Checklist
|
||||||
|
|
||||||
|
Keep local hooks and CI checks aligned:
|
||||||
|
|
||||||
|
1. Same rule set from pyproject.toml.
|
||||||
|
2. Same target Python version and dependency graph.
|
||||||
|
3. Clear developer remediation command in docs:
|
||||||
|
- uv run ruff check . --fix
|
||||||
|
- uv run ruff format .
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Hook passes locally but CI fails
|
||||||
|
|
||||||
|
1. Ensure CI uses the same pyproject.toml and not a stale cache.
|
||||||
|
2. Confirm matching Ruff versions in local and CI environments.
|
||||||
|
3. Verify CI is not running on a different Python target than local config.
|
||||||
|
|
||||||
|
### CI is slow
|
||||||
|
|
||||||
|
1. Keep Ruff in a dedicated job so failures return early.
|
||||||
|
2. Use dependency caching from your package workflow.
|
||||||
|
3. Avoid running both legacy linters and Ruff after migration completion.
|
||||||
|
|
||||||
|
## Source Links
|
||||||
|
|
||||||
|
- [Ruff integrations](https://docs.astral.sh/ruff/integrations/)
|
||||||
|
- [Ruff pre-commit docs](https://docs.astral.sh/ruff/integrations/#pre-commit)
|
||||||
|
- [Ruff GitHub Actions docs](https://docs.astral.sh/ruff/integrations/#github-actions)
|
||||||
|
- [pre-commit official docs](https://pre-commit.com/)
|
||||||
|
- [GitHub Actions documentation](https://docs.github.com/en/actions)
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: vscode-configuration
|
name: vscode-configuration
|
||||||
description: 'Create and troubleshoot VS Code workspace configuration for Python projects, with focused patterns for launch.json debugpy/FastAPI debugging and tasks.json task automation.'
|
description: 'Create and troubleshoot VS Code workspace configuration for Python projects, with focused patterns for launch.json debugpy/FastAPI debugging and tasks.json task automation.'
|
||||||
argument-hint: 'What do you need: debug setup, FastAPI debug run profile, tasks.json automation, or all of them?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: vscode-configuration
|
id: vscode-configuration
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -15,19 +14,26 @@ x-personal-mcp:
|
|||||||
- skills
|
- skills
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/vscode-configuration/document
|
- resource://skills/vscode-configuration/document
|
||||||
depends_on: []
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# VS Code Configuration
|
# VS Code Configuration
|
||||||
|
|
||||||
Use this skill to design or repair repeatable VS Code workspace configuration for local development workflows.
|
Use this skill to design or repair repeatable VS Code workspace configuration for local development workflows.
|
||||||
|
|
||||||
|
Primary VS Code source docs:
|
||||||
|
|
||||||
|
- [Python debugging in VS Code](https://code.visualstudio.com/docs/python/debugging)
|
||||||
|
- [Debug configuration (`launch.json`)](https://code.visualstudio.com/docs/debugtest/debugging-configuration)
|
||||||
|
- [Tasks (`tasks.json`)](https://code.visualstudio.com/docs/editor/tasks)
|
||||||
|
- [MCP servers in VS Code](https://code.visualstudio.com/docs/agent-customization/mcp-servers)
|
||||||
|
|
||||||
## When to Use
|
## When to Use
|
||||||
|
|
||||||
- You need to create or fix `.vscode/launch.json` debug profiles.
|
- You need to create or fix `.vscode/launch.json` debug profiles.
|
||||||
- You need robust Python debugging with `debugpy`.
|
- You need robust Python debugging with `debugpy`.
|
||||||
- You need FastAPI-specific launch profiles (app module, host/port, reload options, env files).
|
- You need FastAPI-specific launch profiles (app module, host/port, reload options, env files).
|
||||||
- You need `.vscode/tasks.json` build/test/run tasks and optional debug pre-launch integration.
|
- You need `.vscode/tasks.json` build/test/run tasks and optional debug pre-launch integration.
|
||||||
|
- You need `.vscode/mcp.json` workspace or user profile MCP server configuration.
|
||||||
- You need consistent workspace onboarding where users can run and debug from VS Code with minimal manual setup.
|
- You need consistent workspace onboarding where users can run and debug from VS Code with minimal manual setup.
|
||||||
|
|
||||||
## Progressive References
|
## Progressive References
|
||||||
@@ -37,6 +43,7 @@ Load only the page that matches the current request:
|
|||||||
- Launch profile mechanics and debugpy patterns: [debug launch configurations](./references/debug-launch-configurations.md)
|
- Launch profile mechanics and debugpy patterns: [debug launch configurations](./references/debug-launch-configurations.md)
|
||||||
- FastAPI-focused debug profiles using debugpy: [FastAPI + debugpy launch patterns](./references/fastapi-debugpy-launch.md)
|
- FastAPI-focused debug profiles using debugpy: [FastAPI + debugpy launch patterns](./references/fastapi-debugpy-launch.md)
|
||||||
- Task runner setup in VS Code: [tasks.json project tasks](./references/tasks-json-configuration.md)
|
- Task runner setup in VS Code: [tasks.json project tasks](./references/tasks-json-configuration.md)
|
||||||
|
- MCP server setup in VS Code: [mcp.json MCP server configuration](./references/mcp-server-configuration.md)
|
||||||
|
|
||||||
## Procedure
|
## Procedure
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Debug Launch Configurations in VS Code
|
# Debug Launch Configurations in VS Code
|
||||||
|
|
||||||
This reference focuses on Python debugging through `debugpy` using `.vscode/launch.json`.
|
This reference focuses on Python debugging through [`debugpy`](https://github.com/microsoft/debugpy) using [`.vscode/launch.json`](https://code.visualstudio.com/docs/debugtest/debugging-configuration).
|
||||||
|
|
||||||
## Core Structure
|
## Core Structure
|
||||||
|
|
||||||
@@ -15,15 +15,15 @@ A minimal launch file:
|
|||||||
|
|
||||||
Useful fields for Python configs:
|
Useful fields for Python configs:
|
||||||
|
|
||||||
- `type`: Use `debugpy`.
|
- `type`: Use [`debugpy`](https://code.visualstudio.com/docs/python/debugging).
|
||||||
- `request`: Usually `launch`, sometimes `attach`.
|
- `request`: Usually `launch`, sometimes `attach`.
|
||||||
- `name`: Friendly profile name shown in the Run and Debug panel.
|
- `name`: Friendly profile name shown in the Run and Debug panel.
|
||||||
- `program`: Script path for script-based entry.
|
- `program`: Script path for script-based entry.
|
||||||
- `module`: Module name for `python -m ...` style launches.
|
- `module`: Module name for `python -m ...` style launches.
|
||||||
- `args`: CLI arguments.
|
- `args`: CLI arguments.
|
||||||
- `cwd`: Working directory.
|
- `cwd`: Working directory (supports [variable substitution](https://code.visualstudio.com/docs/editor/variables-reference)).
|
||||||
- `env` / `envFile`: Environment variables.
|
- `env` / `envFile`: Environment variables (commonly from [environment variable definitions files](https://code.visualstudio.com/docs/python/environments#_environment-variable-definitions-file)).
|
||||||
- `console`: `integratedTerminal` is usually most practical.
|
- `console`: `integratedTerminal` is usually most practical ([launch options](https://code.visualstudio.com/docs/debugtest/debugging-configuration#_launchjson-attributes)).
|
||||||
- `justMyCode`: `true` by default; set `false` when stepping into dependencies.
|
- `justMyCode`: `true` by default; set `false` when stepping into dependencies.
|
||||||
|
|
||||||
## Launch vs Attach
|
## Launch vs Attach
|
||||||
@@ -46,7 +46,7 @@ Attach profile example:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Remote process side command example:
|
Remote process side command example (from [debugpy CLI usage](https://code.visualstudio.com/docs/python/debugging#_command-line-debugging)):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m debugpy --listen 5678 -m your_package.main
|
python -m debugpy --listen 5678 -m your_package.main
|
||||||
@@ -90,6 +90,13 @@ Prefer module mode when imports depend on package layout.
|
|||||||
- Keep secrets out of committed launch configs.
|
- Keep secrets out of committed launch configs.
|
||||||
- Ensure the selected VS Code interpreter matches project tooling.
|
- Ensure the selected VS Code interpreter matches project tooling.
|
||||||
|
|
||||||
|
## Source Documentation
|
||||||
|
|
||||||
|
- [Python debugging in VS Code](https://code.visualstudio.com/docs/python/debugging)
|
||||||
|
- [Debug configuration and launch.json](https://code.visualstudio.com/docs/debugtest/debugging-configuration)
|
||||||
|
- [Variables reference](https://code.visualstudio.com/docs/editor/variables-reference)
|
||||||
|
- [debugpy project](https://github.com/microsoft/debugpy)
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
If breakpoints do not hit:
|
If breakpoints do not hit:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# FastAPI Debug Launch with debugpy
|
# FastAPI Debug Launch with debugpy
|
||||||
|
|
||||||
This reference provides practical `.vscode/launch.json` patterns for FastAPI applications started with uvicorn.
|
This reference provides practical [`.vscode/launch.json`](https://code.visualstudio.com/docs/debugtest/debugging-configuration) patterns for [FastAPI](https://fastapi.tiangolo.com/) applications started with [uvicorn](https://www.uvicorn.org/).
|
||||||
|
|
||||||
## Launch FastAPI via Module
|
## Launch FastAPI via Module
|
||||||
|
|
||||||
@@ -57,9 +57,11 @@ If app is created via factory function:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Factory mode is powered by uvicorn's [`--factory`](https://www.uvicorn.org/settings/#application) option.
|
||||||
|
|
||||||
## Attach to an Existing FastAPI Process
|
## Attach to an Existing FastAPI Process
|
||||||
|
|
||||||
If the app is launched externally, start with debugpy:
|
If the app is launched externally, start with [`debugpy`](https://code.visualstudio.com/docs/python/debugging#_command-line-debugging):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m debugpy --listen 5678 -m uvicorn your_package.main:app --host 127.0.0.1 --port 8000 --reload
|
python -m debugpy --listen 5678 -m uvicorn your_package.main:app --host 127.0.0.1 --port 8000 --reload
|
||||||
@@ -96,3 +98,10 @@ A profile is considered valid when:
|
|||||||
2. A breakpoint inside an endpoint is hit on request.
|
2. A breakpoint inside an endpoint is hit on request.
|
||||||
3. A breakpoint in startup/lifespan logic is hit at app boot.
|
3. A breakpoint in startup/lifespan logic is hit at app boot.
|
||||||
4. Terminal output appears in integrated terminal with expected log level.
|
4. Terminal output appears in integrated terminal with expected log level.
|
||||||
|
|
||||||
|
## Source Documentation
|
||||||
|
|
||||||
|
- [FastAPI docs](https://fastapi.tiangolo.com/)
|
||||||
|
- [Uvicorn settings and CLI options](https://www.uvicorn.org/settings/)
|
||||||
|
- [Python debugging in VS Code](https://code.visualstudio.com/docs/python/debugging)
|
||||||
|
- [Debug configuration and launch.json](https://code.visualstudio.com/docs/debugtest/debugging-configuration)
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# Configure MCP Servers in VS Code
|
||||||
|
|
||||||
|
Use this reference to configure MCP servers for GitHub Copilot chat in VS Code with `.vscode/mcp.json` (workspace) or profile-level `mcp.json` (user scope).
|
||||||
|
|
||||||
|
## Where Configuration Lives
|
||||||
|
|
||||||
|
VS Code supports two MCP configuration locations:
|
||||||
|
|
||||||
|
- Workspace scope: `.vscode/mcp.json` in the repository.
|
||||||
|
- User profile scope: open with the `MCP: Open User Configuration` command.
|
||||||
|
|
||||||
|
Use workspace scope for shared team configuration, and user scope for personal or machine-specific servers.
|
||||||
|
|
||||||
|
## Minimal mcp.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"github": {
|
||||||
|
"type": "http",
|
||||||
|
"url": "https://api.githubcopilot.com/mcp"
|
||||||
|
},
|
||||||
|
"playwright": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@microsoft/mcp-server-playwright"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `servers` object keys are logical server names shown in VS Code MCP management surfaces.
|
||||||
|
|
||||||
|
## Add Servers Through VS Code UI
|
||||||
|
|
||||||
|
1. Run `MCP: Add Server` from the Command Palette.
|
||||||
|
2. Choose Workspace or Global target.
|
||||||
|
3. Review generated config in `mcp.json`.
|
||||||
|
4. Start or restart the server from `MCP: List Servers`.
|
||||||
|
|
||||||
|
This guided flow is usually safer than manual edits when onboarding teammates.
|
||||||
|
|
||||||
|
## Security and Secrets
|
||||||
|
|
||||||
|
1. Do not hardcode tokens or API keys in `mcp.json`.
|
||||||
|
2. Prefer input variables or environment-file patterns supported by the MCP configuration schema.
|
||||||
|
3. Start only trusted servers, because local servers can execute code on your machine.
|
||||||
|
4. Use trust prompts as a checkpoint instead of bypassing review.
|
||||||
|
|
||||||
|
## Security Best Practices
|
||||||
|
|
||||||
|
1. Apply least privilege by default.
|
||||||
|
2. Keep workspace `mcp.json` limited to team-safe, non-secret configuration.
|
||||||
|
3. Keep personal credentials and machine-specific settings in user-scope configuration, not repository files.
|
||||||
|
4. Prefer explicit allowlists for filesystem writes and outbound network access when sandboxing is enabled.
|
||||||
|
5. Use one server per trust boundary instead of one large multi-purpose server.
|
||||||
|
6. Review server `command` and `args` as code during pull requests.
|
||||||
|
7. Disable or uninstall unused MCP servers to reduce attack surface.
|
||||||
|
8. Use HTTPS endpoints for remote MCP servers whenever available.
|
||||||
|
9. Pin server packages or versions where practical to avoid accidental supply-chain drift.
|
||||||
|
10. Reset trust and re-review configuration after major server changes.
|
||||||
|
|
||||||
|
### Operational Guardrails
|
||||||
|
|
||||||
|
1. Treat MCP resources as publishable unless an explicit access control layer exists.
|
||||||
|
2. Capture server logs during onboarding so failures and suspicious behavior are easier to detect.
|
||||||
|
3. Define ownership for each server entry, including who approves changes and who rotates secrets.
|
||||||
|
4. Document upgrade triggers: if a server starts reading private data or executing side-effectful actions, require stronger access controls before rollout.
|
||||||
|
|
||||||
|
### Team Review Checklist
|
||||||
|
|
||||||
|
Use this checklist before merging workspace MCP configuration changes:
|
||||||
|
|
||||||
|
1. No plaintext secrets in `mcp.json`.
|
||||||
|
2. `command` and `args` are from trusted publishers and expected binaries.
|
||||||
|
3. Server scope is correct (workspace vs user profile).
|
||||||
|
4. Sandboxing is enabled for local `stdio` servers when supported.
|
||||||
|
5. Sandbox allowlists are narrow (minimum paths and domains).
|
||||||
|
6. The change includes an owner and rollback path.
|
||||||
|
|
||||||
|
## Sandbox Local stdio Servers (Linux/macOS)
|
||||||
|
|
||||||
|
For local `stdio` servers, enable sandboxing when possible:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"servers": {
|
||||||
|
"myServer": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@example/mcp-server"],
|
||||||
|
"sandboxEnabled": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sandbox": {
|
||||||
|
"filesystem": {
|
||||||
|
"allowWrite": ["${workspaceFolder}"]
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"allowedDomains": ["api.example.com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Sandboxing is currently available on Linux and macOS, not Windows.
|
||||||
|
|
||||||
|
## Troubleshooting Checklist
|
||||||
|
|
||||||
|
1. Open server logs from `MCP: List Servers` -> `Show Output`.
|
||||||
|
2. Confirm trust state (or run `MCP: Reset Trust` if needed).
|
||||||
|
3. Confirm server command and arguments run outside VS Code.
|
||||||
|
4. Confirm workspace-vs-user scope matches where you expect the server to run.
|
||||||
|
5. If using remote development, configure the server in the remote scope when needed.
|
||||||
|
|
||||||
|
## Source Documentation
|
||||||
|
|
||||||
|
- [Add and manage MCP servers in VS Code](https://code.visualstudio.com/docs/agent-customization/mcp-servers)
|
||||||
|
- [MCP configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
|
||||||
|
- [Input variables for sensitive data](https://code.visualstudio.com/docs/agents/reference/mcp-configuration#_input-variables-for-sensitive-data)
|
||||||
|
- [Sandbox configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration#_sandbox-configuration)
|
||||||
|
- [AI security guidance in VS Code](https://code.visualstudio.com/docs/agents/security)
|
||||||
|
- [Model Context Protocol overview](https://modelcontextprotocol.io/docs/getting-started/intro)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Configure Project Tasks in tasks.json
|
# Configure Project Tasks in tasks.json
|
||||||
|
|
||||||
Use `.vscode/tasks.json` to define repeatable project commands and optional hooks for debugging.
|
Use [`.vscode/tasks.json`](https://code.visualstudio.com/docs/editor/tasks) to define repeatable project commands and optional hooks for debugging.
|
||||||
|
|
||||||
## Minimal File
|
## Minimal File
|
||||||
|
|
||||||
@@ -14,12 +14,12 @@ Use `.vscode/tasks.json` to define repeatable project commands and optional hook
|
|||||||
## Task Fields You Will Use Most
|
## Task Fields You Will Use Most
|
||||||
|
|
||||||
- `label`: Task name shown in VS Code.
|
- `label`: Task name shown in VS Code.
|
||||||
- `type`: Usually `shell`.
|
- `type`: Usually [`shell`](https://code.visualstudio.com/docs/editor/tasks#_custom-tasks).
|
||||||
- `command`: Executable to run.
|
- `command`: Executable to run.
|
||||||
- `args`: Command arguments.
|
- `args`: Command arguments.
|
||||||
- `options.cwd`: Working directory.
|
- `options.cwd`: Working directory (supports [variable substitution](https://code.visualstudio.com/docs/editor/variables-reference)).
|
||||||
- `group`: Mark default build or test tasks.
|
- `group`: Mark default build or test tasks ([task groups](https://code.visualstudio.com/docs/editor/tasks#_grouping-tasks)).
|
||||||
- `problemMatcher`: Parse errors into the Problems panel.
|
- `problemMatcher`: Parse errors into the Problems panel ([problem matchers](https://code.visualstudio.com/docs/editor/tasks#_defining-a-problem-matcher)).
|
||||||
- `isBackground`: `true` for long-running tasks (for example dev server watch).
|
- `isBackground`: `true` for long-running tasks (for example dev server watch).
|
||||||
|
|
||||||
## Python Project Example
|
## Python Project Example
|
||||||
@@ -56,7 +56,7 @@ Use `.vscode/tasks.json` to define repeatable project commands and optional hook
|
|||||||
|
|
||||||
## Connect Tasks to Debug Profiles
|
## Connect Tasks to Debug Profiles
|
||||||
|
|
||||||
In `launch.json`, you can run a task first:
|
In [`launch.json`](https://code.visualstudio.com/docs/debugtest/debugging-configuration), you can run a task first with [`preLaunchTask`](https://code.visualstudio.com/docs/debugtest/debugging-configuration#_launchjson-attributes):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -90,3 +90,10 @@ If a task fails unexpectedly:
|
|||||||
3. Confirm tool availability in environment path.
|
3. Confirm tool availability in environment path.
|
||||||
4. Confirm quoting and argument boundaries in `args`.
|
4. Confirm quoting and argument boundaries in `args`.
|
||||||
5. Confirm the task is not blocked by an outdated background process.
|
5. Confirm the task is not blocked by an outdated background process.
|
||||||
|
|
||||||
|
## Source Documentation
|
||||||
|
|
||||||
|
- [VS Code Tasks (official)](https://code.visualstudio.com/docs/editor/tasks)
|
||||||
|
- [Tasks Appendix (schema and interfaces)](https://code.visualstudio.com/docs/reference/tasks-appendix)
|
||||||
|
- [Variables Reference](https://code.visualstudio.com/docs/editor/variables-reference)
|
||||||
|
- [Debug configuration and launch.json](https://code.visualstudio.com/docs/debugtest/debugging-configuration)
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: zensical-docs
|
name: zensical-docs
|
||||||
description: 'Reference skill for Zensical documentation mechanics. Use for quick lookup of docs structure, feature options, and source links. Prefer inline Markdown links to source docs and avoid bare URLs because this content is rendered as human docs and MCP resources.'
|
description: 'Reference skill for Zensical documentation mechanics. Use for quick lookup of docs structure, feature options, and source links. Prefer inline Markdown links to source docs and avoid bare URLs because this content is rendered as human docs and MCP resources.'
|
||||||
argument-hint: 'What are you documenting, who is the audience, and what Zensical features are in scope?'
|
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: zensical-docs
|
id: zensical-docs
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -19,7 +18,6 @@ x-personal-mcp:
|
|||||||
- authoring
|
- authoring
|
||||||
capabilities:
|
capabilities:
|
||||||
- resource://skills/zensical-docs/document
|
- resource://skills/zensical-docs/document
|
||||||
depends_on: []
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# Zensical Documentation Authoring
|
# Zensical Documentation Authoring
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
icon: lucide/flask-conical
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
|
||||||
|
This page describes the current test layout and execution model for this repository.
|
||||||
|
|
||||||
|
Primary guidance sources:
|
||||||
|
- [Pytest scaffolding skill](./skills/pytesting/SKILL.md)
|
||||||
|
- [Pytest docs reference](./skills/pytesting/references/pytest-docs.md)
|
||||||
|
- [FastAPI + uv + Docker skill](./skills/fastapi-uv-docker/SKILL.md)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. Keep local feedback fast with deterministic tests.
|
||||||
|
2. Mirror source modules with focused test groups.
|
||||||
|
3. Keep endpoint and MCP surface checks explicit.
|
||||||
|
4. Make marker usage strict and intentional.
|
||||||
|
|
||||||
|
## Current Test Layout
|
||||||
|
|
||||||
|
Current tree:
|
||||||
|
|
||||||
|
```text
|
||||||
|
tests/
|
||||||
|
__init__.py
|
||||||
|
conftest.py
|
||||||
|
registry/
|
||||||
|
ingest/
|
||||||
|
conftest.py
|
||||||
|
test_current_docs.py
|
||||||
|
test_document.py
|
||||||
|
test_prompt.py
|
||||||
|
test_skill.py
|
||||||
|
models/
|
||||||
|
test_document_validation.py
|
||||||
|
test_prompt_validation.py
|
||||||
|
test_registry_payload_models.py
|
||||||
|
test_skill_validation.py
|
||||||
|
web/
|
||||||
|
conftest.py
|
||||||
|
test_endpoint_connections.py
|
||||||
|
test_mcp_skills.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Source-to-test alignment today:
|
||||||
|
- `src/personal_mcp/registry/ingest/` -> `tests/registry/ingest/`
|
||||||
|
- `src/personal_mcp/registry/models/` -> `tests/registry/models/`
|
||||||
|
- `src/personal_mcp/web/` and MCP HTTP surface -> `tests/web/`
|
||||||
|
|
||||||
|
## Markers And Strictness
|
||||||
|
|
||||||
|
Configured markers in `pyproject.toml`:
|
||||||
|
- `unit`: fast deterministic tests with no external dependencies
|
||||||
|
- `integration`: framework or component integration tests
|
||||||
|
- `smoke`: thin critical-path checks
|
||||||
|
|
||||||
|
Pytest runs with `--strict-markers`, so any unregistered marker fails the test run.
|
||||||
|
|
||||||
|
## Fixture Layering
|
||||||
|
|
||||||
|
Fixture placement follows test scope:
|
||||||
|
1. `tests/conftest.py` for cross-suite defaults.
|
||||||
|
2. `tests/registry/ingest/conftest.py` for ingest-specific 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.
|
||||||
|
|
||||||
|
## Command Baseline
|
||||||
|
|
||||||
|
Canonical invocation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Useful filtered runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run pytest --collect-only -q
|
||||||
|
uv run pytest -m unit -q
|
||||||
|
uv run pytest -m integration -q
|
||||||
|
uv run pytest -m smoke -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
When adding coverage:
|
||||||
|
1. Place tests under the nearest existing module subtree (`registry/` or `web/`).
|
||||||
|
2. Mirror the source path where practical.
|
||||||
|
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.
|
||||||
|
|
||||||
|
This keeps the suite aligned with the current architecture while preserving a fast local test loop.
|
||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
icon: lucide/link
|
|
||||||
---
|
|
||||||
|
|
||||||
# URI Contract
|
|
||||||
|
|
||||||
This page defines the canonical resource URI contract, template parameter rules, and compatibility policy.
|
|
||||||
|
|
||||||
## Canonical URI Surface
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
1. Catalog URIs are discovery surfaces.
|
|
||||||
2. Skill URIs are the primary per-skill guidance surfaces.
|
|
||||||
3. The docs wildcard URI is a direct authored-markdown access surface under `docs/`.
|
|
||||||
|
|
||||||
## URI Semantics
|
|
||||||
|
|
||||||
### `resource://catalog/skills_index`
|
|
||||||
|
|
||||||
1. Returns a compact list of skill records for discovery.
|
|
||||||
2. Contains one entry per `skill_id`.
|
|
||||||
3. Includes enough metadata for client-side selection, at minimum `id`, `name`, `description`, `tags`, and `capabilities`.
|
|
||||||
|
|
||||||
### `resource://catalog/skills/{skill_id}`
|
|
||||||
|
|
||||||
1. Returns one normalized record for `skill_id`.
|
|
||||||
2. Includes the canonical document URI and declared reference ids.
|
|
||||||
3. Returns not found when `skill_id` does not exist.
|
|
||||||
|
|
||||||
### `resource://skills/{skill_id}/document`
|
|
||||||
|
|
||||||
1. Returns the canonical `SKILL.md` authored content for that skill.
|
|
||||||
2. `skill_id` must satisfy the stable skill id rules from the content contract.
|
|
||||||
|
|
||||||
### `resource://skills/{skill_id}/references/{ref_id}`
|
|
||||||
|
|
||||||
1. Returns one reference document declared in the skill frontmatter references manifest.
|
|
||||||
2. `ref_id` is the stable public handle for that reference document.
|
|
||||||
|
|
||||||
### `resource://docs/{path*}`
|
|
||||||
|
|
||||||
1. Returns authored markdown at a normalized relative path under `docs/`.
|
|
||||||
2. Supports nested paths via RFC6570 wildcard expansion.
|
|
||||||
3. Typical examples include `index.md`, `usage.md`, `skills/<skill-id>/SKILL.md`, and `skills/<skill-id>/references/<file>.md`.
|
|
||||||
|
|
||||||
## Template Parameter And Validation Rules
|
|
||||||
|
|
||||||
### `skill_id`
|
|
||||||
|
|
||||||
1. Lowercase kebab-case.
|
|
||||||
2. Must satisfy the stable skill id rules from the content contract.
|
|
||||||
|
|
||||||
### `ref_id`
|
|
||||||
|
|
||||||
1. Lowercase kebab-case.
|
|
||||||
2. Must be declared in the skill's references manifest.
|
|
||||||
|
|
||||||
### `path*`
|
|
||||||
|
|
||||||
1. Relative POSIX path only.
|
|
||||||
2. No leading slash.
|
|
||||||
3. No `..` traversal segments.
|
|
||||||
4. Resolves only inside `docs/`.
|
|
||||||
5. Markdown-only in the end state, meaning `.md` files.
|
|
||||||
|
|
||||||
## URI Versioning Policy
|
|
||||||
|
|
||||||
Default rule:
|
|
||||||
|
|
||||||
1. Keep URIs unversioned by default.
|
|
||||||
2. Allow URI and payload updates when they improve clarity or implementation simplicity.
|
|
||||||
|
|
||||||
Breaking-change rule:
|
|
||||||
|
|
||||||
1. Breaking changes use direct replacement of the canonical URI family.
|
|
||||||
2. No compatibility aliases or dual URI families are maintained.
|
|
||||||
|
|
||||||
FastMCP version metadata usage:
|
|
||||||
|
|
||||||
1. Resource `version` metadata may be used for implementation and version discovery.
|
|
||||||
2. 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:
|
|
||||||
|
|
||||||
1. Prefer keeping `ref_id` stable when practical.
|
|
||||||
2. File paths may change without URI churn as long as the mapped `ref_id` still resolves.
|
|
||||||
3. If a reference is renamed, introduce a new `ref_id` and treat the old one as retired.
|
|
||||||
4. Avoid reusing retired `ref_id` values for unrelated content.
|
|
||||||
|
|
||||||
## Invariants
|
|
||||||
|
|
||||||
This contract guarantees:
|
|
||||||
|
|
||||||
1. One canonical URI pattern per core capability surface.
|
|
||||||
2. Fast, low-friction URI evolution through direct replacement of canonical URIs.
|
|
||||||
3. A single canonical catalog URI family with no alias maintenance overhead.
|
|
||||||
4. Reference mappings can evolve with minimal churn.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This contract does not define:
|
|
||||||
|
|
||||||
1. Implementation-specific transform wiring details, such as `VersionFilter`, mounts, or provider composition.
|
|
||||||
2. Migration script mechanics for auto-generating aliases.
|
|
||||||
3. Authorization policy design for URI-level access control.
|
|
||||||
+169
-60
@@ -22,6 +22,8 @@ In Copilot Chat, there are two distinct mechanisms:
|
|||||||
|
|
||||||
In this repository, skill guidance is exposed as MCP resources, not as server-owned prompt execution. Copilot remains the orchestrator.
|
In this repository, skill guidance is exposed as MCP resources, not as server-owned prompt execution. Copilot remains the orchestrator.
|
||||||
|
|
||||||
|
Prompt guidance is now exposed through both prompt resources and MCP prompt objects. Prompt objects are additive; authored markdown remains the canonical source.
|
||||||
|
|
||||||
## Background Mechanics
|
## Background Mechanics
|
||||||
|
|
||||||
### What the server publishes
|
### What the server publishes
|
||||||
@@ -29,13 +31,21 @@ In this repository, skill guidance is exposed as MCP resources, not as server-ow
|
|||||||
`personal-mcp` registers resources from the validated docs registry and exposes catalog discovery resources:
|
`personal-mcp` registers resources from the validated docs registry and exposes catalog discovery resources:
|
||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
1. `resource://catalog/skills_index`
|
||||||
2. `resource://catalog/skills/{skill_id}`
|
2. `resource://catalog/skills_index{?q,tag,capability,cursor,limit}`
|
||||||
|
3. `resource://catalog/skills/{skill_id}`
|
||||||
|
4. `resource://catalog/prompts_index`
|
||||||
|
5. `resource://catalog/prompts_index{?q,tag,cursor,limit}`
|
||||||
|
6. `resource://catalog/prompts/{prompt_id}`
|
||||||
|
|
||||||
Each skill publishes a canonical Markdown document resource:
|
Each skill publishes a canonical Markdown document resource:
|
||||||
|
|
||||||
1. `resource://skills/<skill-id>/document`
|
1. `resource://skills/<skill-id>/document`
|
||||||
2. `resource://skills/<skill-id>/references/<ref-id>`
|
2. `resource://skills/<skill-id>/references/<ref-id>`
|
||||||
|
|
||||||
|
Prompts publish a canonical prompt document resource:
|
||||||
|
|
||||||
|
1. `resource://prompts/<prompt-id>/document`
|
||||||
|
|
||||||
The document payload is loaded from `docs/skills/<skill-id>/SKILL.md` and returned with metadata.
|
The document payload is loaded from `docs/skills/<skill-id>/SKILL.md` and returned with metadata.
|
||||||
|
|
||||||
### What Copilot does as the client
|
### What Copilot does as the client
|
||||||
@@ -74,8 +84,100 @@ In practice, there are two reliable ways to make skill content available in chat
|
|||||||
1. explicit resource attachment through `Add Context > MCP Resources` or `MCP: Browse Resources`
|
1. explicit resource attachment through `Add Context > MCP Resources` or `MCP: Browse Resources`
|
||||||
2. MCP tool invocation using `list_resources`/`read_resource` (ResourcesAsTools), with thin catalog tools as parity fallback
|
2. MCP tool invocation using `list_resources`/`read_resource` (ResourcesAsTools), with thin catalog tools as parity fallback
|
||||||
|
|
||||||
|
For prompt content, there is a third option when the client supports MCP prompt APIs:
|
||||||
|
|
||||||
|
1. prompt-object discovery and invocation through MCP prompt lists and `get_prompt`
|
||||||
|
|
||||||
Instruction quality and metadata quality still matter, because they influence whether Copilot recognizes that the MCP server is relevant and chooses the tool path well.
|
Instruction quality and metadata quality still matter, because they influence whether Copilot recognizes that the MCP server is relevant and chooses the tool path well.
|
||||||
|
|
||||||
|
## Invocation Mechanics Deep Dive
|
||||||
|
|
||||||
|
This section expands on how invocation works at runtime across chat entry points.
|
||||||
|
|
||||||
|
### Invocation Surfaces
|
||||||
|
|
||||||
|
A user request can arrive through one of these surfaces:
|
||||||
|
|
||||||
|
1. plain chat request in Ask/Edit/Agent mode
|
||||||
|
2. slash command invocation of a prompt or skill
|
||||||
|
3. chat request with manually attached MCP resources
|
||||||
|
|
||||||
|
Each surface changes how much discovery Copilot must do before applying guidance.
|
||||||
|
|
||||||
|
### Resolution Order
|
||||||
|
|
||||||
|
When multiple retrieval paths are possible, use this priority order:
|
||||||
|
|
||||||
|
1. attached MCP resources already in context
|
||||||
|
2. explicit slash-command workflow steps
|
||||||
|
3. catalog-first discovery via MCP resources
|
||||||
|
4. tool fallback (`list_resources` then `read_resource`, then thin catalog parity tools)
|
||||||
|
|
||||||
|
This ordering keeps behavior predictable while minimizing unnecessary context expansion.
|
||||||
|
|
||||||
|
### Prompt Invocation Pipeline
|
||||||
|
|
||||||
|
For prompt-oriented flows, treat invocation as this sequence:
|
||||||
|
|
||||||
|
1. parse prompt frontmatter and argument hints
|
||||||
|
2. validate required inputs and ask one clarifying question if blocked
|
||||||
|
3. run bounded discovery against prompt or skill catalogs
|
||||||
|
4. fetch only selected document resources
|
||||||
|
5. apply instructions to produce edits, recommendations, or commands
|
||||||
|
6. report what was loaded and why
|
||||||
|
|
||||||
|
Prompt objects and prompt document resources are additive mechanisms. The authored Markdown prompt document remains the canonical contract.
|
||||||
|
|
||||||
|
### Argument Syntax Nuance
|
||||||
|
|
||||||
|
Invocation strings such as target_modules=src/personal_mcp/registry/ingest/skill.py, mode=plan-only are a structured authoring convention, not a guaranteed client-level grammar.
|
||||||
|
|
||||||
|
In practice:
|
||||||
|
|
||||||
|
1. Prompt metadata defines expected argument names and intent.
|
||||||
|
2. Prompt body instructions define how those inputs should be interpreted.
|
||||||
|
3. Copilot may receive equivalent intent in freeform phrasing and still resolve it correctly.
|
||||||
|
|
||||||
|
Implication for authors:
|
||||||
|
|
||||||
|
1. Treat key=value examples as clarity aids for users.
|
||||||
|
2. Do not assume strict parser enforcement unless your prompt explicitly validates and rejects malformed input.
|
||||||
|
3. Include accepted invocation examples and one fallback freeform example so behavior is predictable for both humans and the model.
|
||||||
|
|
||||||
|
This distinction is important because argument hints improve discoverability, while robust prompt instructions determine actual runtime reliability.
|
||||||
|
|
||||||
|
### Skill Invocation Pipeline
|
||||||
|
|
||||||
|
For guided skill loading, use this sequence:
|
||||||
|
|
||||||
|
1. start from `resource://catalog/skills_index` or scoped index query
|
||||||
|
2. inspect one or two top candidates for intent and capability fit
|
||||||
|
3. fetch `resource://skills/<skill-id>/document`
|
||||||
|
4. load references only when the task needs deeper detail
|
||||||
|
5. apply only relevant sections and keep context bounded
|
||||||
|
|
||||||
|
This avoids the common failure mode where many skill documents are loaded up front.
|
||||||
|
|
||||||
|
### Determinism vs Flexibility
|
||||||
|
|
||||||
|
Use this decision rule:
|
||||||
|
|
||||||
|
1. choose slash-command invocation when repeatability and step order are critical
|
||||||
|
2. choose guided loading when requests vary and speed matters more than strict orchestration
|
||||||
|
3. escalate from guided loading to slash-command flow when confidence is low or conflicting skills appear
|
||||||
|
|
||||||
|
### Invocation Trace (What to Log in Results)
|
||||||
|
|
||||||
|
For transparent operation, include a concise invocation trace in task outputs:
|
||||||
|
|
||||||
|
1. entry surface used (plain chat, slash command, or attached resource)
|
||||||
|
2. discovery source used (catalog resource or tool path)
|
||||||
|
3. resources fetched (ids only)
|
||||||
|
4. clarifying questions asked (if any)
|
||||||
|
5. reason for fallback or escalation (if used)
|
||||||
|
|
||||||
|
This makes behavior auditable and easier to tune over time.
|
||||||
|
|
||||||
## Operating Pattern
|
## Operating Pattern
|
||||||
|
|
||||||
Use both modes intentionally in Copilot Chat.
|
Use both modes intentionally in Copilot Chat.
|
||||||
@@ -124,16 +226,9 @@ flowchart TD
|
|||||||
H --> I
|
H --> I
|
||||||
```
|
```
|
||||||
|
|
||||||
## Authoring Requirements For Reliable Matching
|
## Authoring Requirements
|
||||||
|
|
||||||
For resource selection or tool-based matching to work well, each skill should have:
|
Authoring rules for metadata quality and instruction patterns are maintained in [Authoring Guide](./authoring.md).
|
||||||
|
|
||||||
1. precise `description`
|
|
||||||
2. focused `tags`
|
|
||||||
3. explicit `capabilities`
|
|
||||||
4. stable `id` and slug naming
|
|
||||||
|
|
||||||
Weak metadata reduces Copilot match quality and increases wrong context injection.
|
|
||||||
|
|
||||||
## Practical Guidelines
|
## Practical Guidelines
|
||||||
|
|
||||||
@@ -161,54 +256,6 @@ Behavior:
|
|||||||
2. `bm25` uses ranked natural-language matching.
|
2. `bm25` uses ranked natural-language matching.
|
||||||
3. `list_resources` and `read_resource` stay visible so resource-backed fallback remains primary.
|
3. `list_resources` and `read_resource` stay visible so resource-backed fallback remains primary.
|
||||||
|
|
||||||
## Copilot Instruction Pattern
|
|
||||||
|
|
||||||
If you want Copilot to use `personal-mcp` skill content more reliably, the instruction file should describe three things clearly:
|
|
||||||
|
|
||||||
1. when MCP-backed skill guidance is relevant
|
|
||||||
2. which retrieval path Copilot should prefer first
|
|
||||||
3. how much skill context it should load before answering
|
|
||||||
|
|
||||||
That matters because instructions can strongly steer discovery behavior, but they do not force VS Code to auto-attach MCP resources. A good instruction tells Copilot to prefer the canonical MCP content path while remaining accurate about the fallback path.
|
|
||||||
|
|
||||||
In this repository, the right policy is:
|
|
||||||
|
|
||||||
1. start from catalog discovery
|
|
||||||
2. prefer MCP resources when the current chat surface exposes resource attachment
|
|
||||||
3. fall back to catalog tools when resource attachment is unavailable
|
|
||||||
4. keep loaded skill context bounded
|
|
||||||
|
|
||||||
Suggested instruction text:
|
|
||||||
|
|
||||||
```md
|
|
||||||
When a task may match a documented implementation pattern from `personal-mcp`:
|
|
||||||
|
|
||||||
1. Start with catalog-first discovery.
|
|
||||||
2. Prefer MCP resources when the chat surface exposes resource attachment.
|
|
||||||
3. If MCP resource attachment is unavailable, use `list_resources`/`read_resource` first, then thin catalog tools if needed.
|
|
||||||
4. Load only the most relevant skill document, or at most 2 skill documents.
|
|
||||||
5. Reconcile loaded skill guidance with the actual repository code before making changes.
|
|
||||||
|
|
||||||
Preferred resource order:
|
|
||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
|
||||||
2. `resource://catalog/skills/{skill_id}`
|
|
||||||
3. `resource://skills/<skill-id>/document`
|
|
||||||
4. `resource://skills/<skill-id>/references/<ref-id>` when needed
|
|
||||||
|
|
||||||
Preferred tool fallback order:
|
|
||||||
|
|
||||||
1. `list_resources`
|
|
||||||
2. `read_resource`
|
|
||||||
3. `search_patterns`
|
|
||||||
4. `get_pattern_by_id`
|
|
||||||
5. `get_skill_document_by_id`
|
|
||||||
|
|
||||||
If confidence is low after discovery, ask one clarifying question before loading more context.
|
|
||||||
```
|
|
||||||
|
|
||||||
This is intentionally guidance, not a guarantee. It gives Copilot a strong policy for when to use resources and when to fall back to discovery tools, while preserving the resource-first architecture.
|
|
||||||
|
|
||||||
## Failure Modes and Recovery
|
## Failure Modes and Recovery
|
||||||
|
|
||||||
Common failure modes:
|
Common failure modes:
|
||||||
@@ -234,11 +281,13 @@ Use this checklist when configuring GitHub Copilot in VS Code against `personal-
|
|||||||
2. verify catalog resources are readable
|
2. verify catalog resources are readable
|
||||||
3. verify at least one `resource://skills/<id>/document` can be fetched
|
3. verify at least one `resource://skills/<id>/document` can be fetched
|
||||||
4. add one deterministic `/` command for fallback
|
4. add one deterministic `/` command for fallback
|
||||||
5. add Copilot instruction: prefer catalog-first discovery, then targeted skill fetch
|
5. confirm your workspace instruction policy exists (see [Authoring Guide](./authoring.md))
|
||||||
6. verify context size remains bounded
|
6. verify context size remains bounded
|
||||||
7. validate behavior in Ask/Edit/Agent-style workflows with at least one task each
|
7. validate behavior in Ask/Edit/Agent-style workflows with at least one task each
|
||||||
|
|
||||||
Suggested instruction policy text:
|
## Runtime Discovery Workflow
|
||||||
|
|
||||||
|
Use this runtime sequence in chat sessions:
|
||||||
|
|
||||||
1. Start with catalog-first discovery.
|
1. Start with catalog-first discovery.
|
||||||
2. Prefer MCP resources when the chat surface exposes resource attachment.
|
2. Prefer MCP resources when the chat surface exposes resource attachment.
|
||||||
@@ -246,6 +295,66 @@ Suggested instruction policy text:
|
|||||||
4. Prefer `list_resources`/`read_resource` first when operating in tool-only clients.
|
4. Prefer `list_resources`/`read_resource` first when operating in tool-only clients.
|
||||||
5. If confidence is low, ask one clarifying question before loading more.
|
5. If confidence is low, ask one clarifying question before loading more.
|
||||||
|
|
||||||
|
## Thin Shim Path Binding Pattern
|
||||||
|
|
||||||
|
For repositories that consume this MCP server, thin shims are a usage pattern for binding path scopes to the right MCP resources. The "thin shims" are just lightweight, repo-specific instructions files that tell Copilot to use certain MCP resources when editing files that match a pattern. That helps with ensuring Copilot uses the intended resources without too much specific goading in the prompt.
|
||||||
|
|
||||||
|
Use thin shims in Copilot instruction files to bind file-path scopes to:
|
||||||
|
|
||||||
|
1. the most relevant docs page for human-readable conventions
|
||||||
|
2. the matching MCP resource URI for machine retrieval
|
||||||
|
|
||||||
|
Keep each shim short: trigger, primary resource, minimal execution pattern, and one fallback rule.
|
||||||
|
|
||||||
|
Recommended binding pattern:
|
||||||
|
|
||||||
|
1. Put shims in `.github/instructions/*.instructions.md`.
|
||||||
|
2. Scope each shim with `applyTo` so it activates only where needed.
|
||||||
|
3. Point to one primary `resource://skills/<skill-id>/document` URI.
|
||||||
|
4. Link one repository docs page as the human-facing companion.
|
||||||
|
5. Expand to references only when the task needs deeper detail.
|
||||||
|
|
||||||
|
Current repository examples:
|
||||||
|
|
||||||
|
| applyTo scope | Primary docs page | Primary MCP resource |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `**/*.md` | [docs/authoring.md](./authoring.md) | `resource://skills/zensical-docs/document` |
|
||||||
|
| `tests/**` | [docs/testing.md](./testing.md) | `resource://skills/pytesting/document` |
|
||||||
|
| `.vscode/**` | [docs/skills/vscode-configuration/SKILL.md](./skills/vscode-configuration/SKILL.md) | `resource://skills/vscode-configuration/document` |
|
||||||
|
|
||||||
|
Minimal shim shape:
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
name: <short scope name>
|
||||||
|
description: Route <path scope> edits to the Personal MCP <skill-id> resource.
|
||||||
|
applyTo: '<glob>'
|
||||||
|
---
|
||||||
|
|
||||||
|
When editing files matching <glob>, use `resource://skills/<skill-id>/document` as the primary guidance source.
|
||||||
|
|
||||||
|
Execution pattern:
|
||||||
|
|
||||||
|
1. Load the primary skill document first.
|
||||||
|
2. Apply only sections relevant to the file being edited.
|
||||||
|
3. Keep edits minimal and aligned with repository conventions.
|
||||||
|
4. If confidence is low, ask one clarifying question before editing.
|
||||||
|
|
||||||
|
Companion docs page: [docs/<page>.md](./<page>.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
When to use thin shims:
|
||||||
|
|
||||||
|
1. Repositories that want thin local policy while keeping canonical guidance in MCP resources.
|
||||||
|
2. Stable, repeated workflows with clear path ownership.
|
||||||
|
3. Cases where teams need predictable retrieval behavior.
|
||||||
|
|
||||||
|
When not to use thin shims:
|
||||||
|
|
||||||
|
1. Broad, ambiguous tasks with unclear ownership boundaries.
|
||||||
|
2. Cases where one shim would need many exceptions.
|
||||||
|
3. Situations better handled by catalog-first discovery at runtime.
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
The intended model is:
|
The intended model is:
|
||||||
|
|||||||
@@ -23,6 +23,26 @@ packages = ["src/personal_mcp"]
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
"ipywidgets>=8.1.8",
|
||||||
"pre-commit>=4.6.0",
|
"pre-commit>=4.6.0",
|
||||||
"ruff>=0.15.18",
|
"ruff>=0.15.18",
|
||||||
|
"ty>=0.0.51",
|
||||||
]
|
]
|
||||||
|
test = [
|
||||||
|
"pytest>=9.1.1",
|
||||||
|
"pytest-asyncio>=1.4.0",
|
||||||
|
"pytest-cov>=7.1.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
addopts = ["--strict-markers"]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
markers = [
|
||||||
|
"unit: fast deterministic tests with no external dependencies",
|
||||||
|
"integration: framework or component integration tests",
|
||||||
|
"smoke: thin critical-path checks",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ty.src]
|
||||||
|
include = ["src", "tests"]
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
line-length = 120
|
||||||
|
indent-width = 4
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
exclude = [
|
||||||
|
".venv",
|
||||||
|
".devenv",
|
||||||
|
".git",
|
||||||
|
".vscode",
|
||||||
|
"build",
|
||||||
|
"site",
|
||||||
|
"__pycache__",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint]
|
||||||
|
preview = true
|
||||||
|
|
||||||
|
extend-select = [
|
||||||
|
"ARG", # https://docs.astral.sh/ruff/rules/#flake8-unused-arguments-arg
|
||||||
|
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
||||||
|
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
|
||||||
|
"DOC102", # https://docs.astral.sh/ruff/rules/docstring-extraneous-parameter/
|
||||||
|
"DOC202", # https://docs.astral.sh/ruff/rules/docstring-extraneous-returns/
|
||||||
|
"DOC403", # https://docs.astral.sh/ruff/rules/docstring-extraneous-yields/
|
||||||
|
"DOC502", # https://docs.astral.sh/ruff/rules/docstring-extraneous-exception/
|
||||||
|
"E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
|
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
|
||||||
|
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
|
||||||
|
"PD", # https://docs.astral.sh/ruff/rules/#pandas-vet-pd
|
||||||
|
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||||
|
"PLR0202", # https://docs.astral.sh/ruff/rules/no-classmethod-decorator/
|
||||||
|
"PLR0203", # https://docs.astral.sh/ruff/rules/no-staticmethod-decorator/
|
||||||
|
"PLR0206", # https://docs.astral.sh/ruff/rules/property-with-parameters/
|
||||||
|
"PLR0915", # https://docs.astral.sh/ruff/rules/too-many-statements/
|
||||||
|
"PLR1702", # https://docs.astral.sh/ruff/rules/too-many-nested-blocks/
|
||||||
|
"TRY002",
|
||||||
|
]
|
||||||
|
extend-fixable = ["ALL"]
|
||||||
|
ignore = [
|
||||||
|
"UP046",
|
||||||
|
"UP047",
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint.extend-per-file-ignores]
|
||||||
|
"*.ipynb" = [
|
||||||
|
"F401", # unused imports
|
||||||
|
"F841", # unused local variable
|
||||||
|
"F821", # undefined name in exploratory notebook cells
|
||||||
|
]
|
||||||
|
|
||||||
|
[lint.isort]
|
||||||
|
force-single-line = true
|
||||||
|
|
||||||
|
[format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
skip-magic-trailing-comma = false
|
||||||
|
line-ending = "auto"
|
||||||
Executable
+80
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Create directory and symlink for Copilot skills."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def add_skill(markdown_path: str) -> None:
|
||||||
|
"""Add a skill by creating symlink in ~/.copilot/skills/.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
markdown_path: Path to the markdown file (relative or absolute)
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
FileNotFoundError: If markdown file doesn't exist
|
||||||
|
RuntimeError: If symlink creation fails
|
||||||
|
"""
|
||||||
|
md_file = Path(markdown_path)
|
||||||
|
|
||||||
|
if not md_file.exists():
|
||||||
|
raise FileNotFoundError(f"Markdown file not found: {markdown_path}")
|
||||||
|
|
||||||
|
# Get absolute path
|
||||||
|
abs_md_path = md_file.resolve()
|
||||||
|
|
||||||
|
# Extract skill name from filename (without .md extension)
|
||||||
|
skill_name = md_file.stem
|
||||||
|
|
||||||
|
# Create skill directory
|
||||||
|
skills_dir = Path.home() / ".copilot" / "skills" / skill_name
|
||||||
|
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create symlink
|
||||||
|
symlink_path = skills_dir / "SKILL.md"
|
||||||
|
|
||||||
|
# Remove existing symlink if it exists
|
||||||
|
if symlink_path.exists() or symlink_path.is_symlink():
|
||||||
|
symlink_path.unlink()
|
||||||
|
|
||||||
|
# Create the symlink using ln -s for compatibility
|
||||||
|
result = subprocess.run(
|
||||||
|
["ln", "-s", str(abs_md_path), str(symlink_path)],
|
||||||
|
capture_output=True,
|
||||||
|
check=False,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Failed to create symlink: {result.stderr}")
|
||||||
|
|
||||||
|
print(f"✓ Created skill link: {symlink_path} -> {abs_md_path}")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point."""
|
||||||
|
parser = argparse.ArgumentParser(description="Create directory and symlink for Copilot skills")
|
||||||
|
subparsers = parser.add_subparsers(dest="command", help="Command to run")
|
||||||
|
|
||||||
|
# Add subcommand
|
||||||
|
add_parser = subparsers.add_parser("add", help="Add a skill")
|
||||||
|
add_parser.add_argument("markdown", help="Path to markdown skill file")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.command == "add":
|
||||||
|
try:
|
||||||
|
add_skill(args.markdown)
|
||||||
|
except (FileNotFoundError, RuntimeError, OSError) as e:
|
||||||
|
print(f"✗ Error: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
from personal_mcp.catalog.server import (
|
from personal_mcp.catalog.server import build_prompt_detail_payload
|
||||||
build_skill_detail_payload,
|
from personal_mcp.catalog.server import build_prompts_index_payload
|
||||||
build_skills_index_payload,
|
from personal_mcp.catalog.server import build_skill_detail_payload
|
||||||
get_pattern_by_id_payload,
|
from personal_mcp.catalog.server import build_skills_index_payload
|
||||||
search_patterns_payload,
|
from personal_mcp.catalog.server import get_pattern_by_id_payload
|
||||||
)
|
from personal_mcp.catalog.server import get_prompt_by_id_payload
|
||||||
|
from personal_mcp.catalog.server import search_patterns_payload
|
||||||
|
from personal_mcp.catalog.server import search_prompts_payload
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"build_prompt_detail_payload",
|
||||||
|
"build_prompts_index_payload",
|
||||||
"build_skill_detail_payload",
|
"build_skill_detail_payload",
|
||||||
"build_skills_index_payload",
|
"build_skills_index_payload",
|
||||||
"get_pattern_by_id_payload",
|
"get_pattern_by_id_payload",
|
||||||
|
"get_prompt_by_id_payload",
|
||||||
"search_patterns_payload",
|
"search_patterns_payload",
|
||||||
|
"search_prompts_payload",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,45 +2,17 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from personal_mcp.skills.document_loader import DocsRegistry, SkillRecord
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
|
from personal_mcp.registry.models.registry import PromptRecord
|
||||||
|
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
||||||
|
from personal_mcp.registry.models.registry import SkillPatternPayload
|
||||||
|
from personal_mcp.registry.models.registry import SkillRecord
|
||||||
|
from personal_mcp.registry.models.registry import SkillSummaryPayload
|
||||||
|
|
||||||
DEFAULT_LIMIT = 20
|
DEFAULT_LIMIT = 20
|
||||||
MAX_LIMIT = 100
|
MAX_LIMIT = 100
|
||||||
|
|
||||||
|
|
||||||
def _pattern_payload(skill: SkillRecord) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": skill.skill_id,
|
|
||||||
"name": skill.name,
|
|
||||||
"version": skill.version,
|
|
||||||
"description": skill.description,
|
|
||||||
"tags": list(skill.tags),
|
|
||||||
"depends_on": list(skill.depends_on),
|
|
||||||
"capabilities": list(skill.capabilities),
|
|
||||||
"resources": list(skill.capabilities),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _summary_payload(skill: SkillRecord) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"id": skill.skill_id,
|
|
||||||
"name": skill.name,
|
|
||||||
"description": skill.description,
|
|
||||||
"tags": list(skill.tags),
|
|
||||||
"capabilities": list(skill.capabilities),
|
|
||||||
"version": skill.version,
|
|
||||||
"document_uri": skill.document_uri,
|
|
||||||
"detail_uri": f"resource://catalog/skills/{skill.skill_id}",
|
|
||||||
"resources": {
|
|
||||||
"document": skill.document_uri,
|
|
||||||
"references": [
|
|
||||||
f"resource://skills/{skill.skill_id}/references/{ref_id}"
|
|
||||||
for ref_id in sorted(skill.references)
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _skill_matches(
|
def _skill_matches(
|
||||||
skill: SkillRecord,
|
skill: SkillRecord,
|
||||||
*,
|
*,
|
||||||
@@ -66,10 +38,32 @@ def _skill_matches(
|
|||||||
if tag and tag not in skill.tags:
|
if tag and tag not in skill.tags:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if capability and capability not in skill.capabilities:
|
return not (capability and capability not in skill.capabilities)
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
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_skills_index_payload(
|
def build_skills_index_payload(
|
||||||
@@ -87,20 +81,14 @@ def build_skills_index_payload(
|
|||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise ValueError("cursor must be an integer string") from exc
|
raise ValueError("cursor must be an integer string") from exc
|
||||||
|
|
||||||
ordered = [
|
ordered = [registry.skills_by_id[skill_id] for skill_id in registry.skills_in_load_order]
|
||||||
registry.skills_by_id[skill_id] for skill_id in registry.skills_in_load_order
|
matches = [skill for skill in ordered if _skill_matches(skill, query=query, tag=tag, capability=capability)]
|
||||||
]
|
|
||||||
matches = [
|
|
||||||
skill
|
|
||||||
for skill in ordered
|
|
||||||
if _skill_matches(skill, query=query, tag=tag, capability=capability)
|
|
||||||
]
|
|
||||||
|
|
||||||
page = matches[start : start + normalized_limit]
|
page = matches[start : start + normalized_limit]
|
||||||
next_cursor = start + normalized_limit
|
next_cursor = start + normalized_limit
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"skills": [_summary_payload(skill) for skill in page],
|
"skills": [SkillSummaryPayload.from_record(skill).model_dump() for skill in page],
|
||||||
"total": len(matches),
|
"total": len(matches),
|
||||||
"cursor": str(start),
|
"cursor": str(start),
|
||||||
"limit": normalized_limit,
|
"limit": normalized_limit,
|
||||||
@@ -119,7 +107,6 @@ def build_skill_detail_payload(registry: DocsRegistry, skill_id: str) -> dict[st
|
|||||||
"description": skill.description,
|
"description": skill.description,
|
||||||
"version": skill.version,
|
"version": skill.version,
|
||||||
"tags": list(skill.tags),
|
"tags": list(skill.tags),
|
||||||
"depends_on": list(skill.depends_on),
|
|
||||||
"capabilities": list(skill.capabilities),
|
"capabilities": list(skill.capabilities),
|
||||||
"resources": {
|
"resources": {
|
||||||
"document": skill.document_uri,
|
"document": skill.document_uri,
|
||||||
@@ -136,6 +123,56 @@ def build_skill_detail_payload(registry: DocsRegistry, skill_id: str) -> dict[st
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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_patterns_payload(
|
def search_patterns_payload(
|
||||||
registry: DocsRegistry,
|
registry: DocsRegistry,
|
||||||
*,
|
*,
|
||||||
@@ -160,7 +197,7 @@ def search_patterns_payload(
|
|||||||
|
|
||||||
page = matches[normalized_skip : normalized_skip + normalized_limit]
|
page = matches[normalized_skip : normalized_skip + normalized_limit]
|
||||||
return {
|
return {
|
||||||
"patterns": [_pattern_payload(skill) for skill in page],
|
"patterns": [SkillPatternPayload.from_record(skill).model_dump() for skill in page],
|
||||||
"total": len(matches),
|
"total": len(matches),
|
||||||
"skip": normalized_skip,
|
"skip": normalized_skip,
|
||||||
"limit": normalized_limit,
|
"limit": normalized_limit,
|
||||||
@@ -170,4 +207,44 @@ def search_patterns_payload(
|
|||||||
def get_pattern_by_id_payload(registry: DocsRegistry, skill_id: str) -> dict[str, Any]:
|
def get_pattern_by_id_payload(registry: DocsRegistry, skill_id: str) -> dict[str, Any]:
|
||||||
if skill_id not in registry.skills_by_id:
|
if skill_id not in registry.skills_by_id:
|
||||||
return {"found": False, "id": skill_id}
|
return {"found": False, "id": skill_id}
|
||||||
return {"found": True, "pattern": _pattern_payload(registry.skills_by_id[skill_id])}
|
return {"found": True, "pattern": SkillPatternPayload.from_record(registry.skills_by_id[skill_id]).model_dump()}
|
||||||
|
|
||||||
|
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
|||||||
+144
-17
@@ -1,25 +1,31 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from inspect import Parameter
|
||||||
|
from inspect import Signature
|
||||||
from typing import Any
|
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 import ResourcesAsTools
|
||||||
from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform
|
from fastmcp.server.transforms.search import BM25SearchTransform
|
||||||
|
from fastmcp.server.transforms.search import RegexSearchTransform
|
||||||
|
|
||||||
from personal_mcp.catalog.server import (
|
from personal_mcp.catalog.server import build_prompt_detail_payload
|
||||||
build_skill_detail_payload,
|
from personal_mcp.catalog.server import build_prompts_index_payload
|
||||||
build_skills_index_payload,
|
from personal_mcp.catalog.server import build_skill_detail_payload
|
||||||
get_pattern_by_id_payload,
|
from personal_mcp.catalog.server import build_skills_index_payload
|
||||||
search_patterns_payload,
|
from personal_mcp.catalog.server import get_pattern_by_id_payload
|
||||||
)
|
from personal_mcp.catalog.server import get_prompt_by_id_payload
|
||||||
from personal_mcp.skills.document_loader import (
|
from personal_mcp.catalog.server import search_patterns_payload
|
||||||
DocsRegistry,
|
from personal_mcp.catalog.server import search_prompts_payload
|
||||||
load_docs_registry,
|
from personal_mcp.registry.load import load_docs_registry
|
||||||
read_docs_markdown_path,
|
from personal_mcp.registry.models.registry import DocsRegistry
|
||||||
read_skill_document,
|
from personal_mcp.registry.read import read_docs_markdown_path
|
||||||
read_skill_reference,
|
from personal_mcp.registry.read import read_prompt_document
|
||||||
)
|
from personal_mcp.registry.read import read_skill_document
|
||||||
|
from personal_mcp.registry.read import read_skill_reference
|
||||||
|
|
||||||
DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs")
|
DOCS_ROOT = os.getenv("PERSONAL_MCP_DOCS_ROOT", "../../docs")
|
||||||
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
|
TOOL_SEARCH_MODE = os.getenv("PERSONAL_MCP_TOOL_SEARCH", "none").strip().lower()
|
||||||
@@ -65,9 +71,7 @@ def _install_tool_fallback_transforms() -> None:
|
|||||||
mcp.add_transform(BM25SearchTransform(**kwargs))
|
mcp.add_transform(BM25SearchTransform(**kwargs))
|
||||||
return
|
return
|
||||||
|
|
||||||
raise ValueError(
|
raise ValueError("PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25")
|
||||||
"PERSONAL_MCP_TOOL_SEARCH must be one of: none, regex, bm25"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _ro_annotations() -> dict[str, bool]:
|
def _ro_annotations() -> dict[str, bool]:
|
||||||
@@ -77,6 +81,54 @@ 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() -> 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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mcp.resource(
|
@mcp.resource(
|
||||||
"resource://catalog/skills_index",
|
"resource://catalog/skills_index",
|
||||||
mime_type="application/json",
|
mime_type="application/json",
|
||||||
@@ -150,6 +202,57 @@ 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
|
@mcp.tool
|
||||||
def search_patterns(
|
def search_patterns(
|
||||||
query: str = "",
|
query: str = "",
|
||||||
@@ -185,4 +288,28 @@ def get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
|
||||||
_install_tool_fallback_transforms()
|
_install_tool_fallback_transforms()
|
||||||
|
_register_prompt_objects()
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from .models.registry import DocsRegistry
|
||||||
|
from .models.registry import PromptRecord
|
||||||
|
from .models.registry import PromptSummaryRecord
|
||||||
|
from .models.registry import ReferenceRecord
|
||||||
|
from .models.registry import SkillRecord
|
||||||
|
from .models.registry import SkillSummaryRecord
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DocsRegistry",
|
||||||
|
"PromptRecord",
|
||||||
|
"PromptSummaryRecord",
|
||||||
|
"ReferenceRecord",
|
||||||
|
"SkillRecord",
|
||||||
|
"SkillSummaryRecord",
|
||||||
|
]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Functions to produce immutable dataclasses representing the document registry."""
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from dataclasses import field
|
||||||
|
from importlib.resources.abc import Traversable
|
||||||
|
from itertools import starmap
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class MarkdownDocument:
|
||||||
|
"""Represents a loaded markdown document with its content and frontmatter."""
|
||||||
|
|
||||||
|
relpath: PurePosixPath
|
||||||
|
"""The relative path of the document within the package resources."""
|
||||||
|
content: str = field(repr=False)
|
||||||
|
"""The raw markdown content of the document."""
|
||||||
|
frontmatter: str | None = field(repr=False, default=None)
|
||||||
|
"""The raw YAML frontmatter of the document, if present."""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_root(cls, root: Traversable):
|
||||||
|
"""Recursively load all markdown documents from the root resource."""
|
||||||
|
mapped = starmap(cls.from_resource, walk_resources(root))
|
||||||
|
return {d.relpath: d for d in mapped}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_resource(cls, relpath: PurePosixPath, resource: Traversable) -> Self:
|
||||||
|
"""Load a markdown document from a package resource."""
|
||||||
|
raw = resource.read_text(encoding="utf-8")
|
||||||
|
frontmatter = get_raw_frontmatter(raw)
|
||||||
|
return cls(relpath=relpath, content=raw, frontmatter=frontmatter)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def skill_slug(self) -> str | None:
|
||||||
|
parts = self.relpath.parts
|
||||||
|
if parts[0] == "skills" and len(parts) >= 3:
|
||||||
|
return parts[1]
|
||||||
|
|
||||||
|
@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(
|
||||||
|
node: Traversable,
|
||||||
|
*,
|
||||||
|
suffix: str = ".md",
|
||||||
|
prefix: PurePosixPath | None = None,
|
||||||
|
) -> Generator[tuple[PurePosixPath, Traversable]]:
|
||||||
|
"""Recursively yield all resources in node, with their full path."""
|
||||||
|
prefix = prefix if prefix is not None else PurePosixPath()
|
||||||
|
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||||
|
relpath = prefix.joinpath(child.name)
|
||||||
|
if child.is_dir():
|
||||||
|
yield from walk_resources(child, suffix=suffix, prefix=relpath)
|
||||||
|
continue
|
||||||
|
if not child.is_file() or not child.name.lower().endswith(suffix):
|
||||||
|
continue
|
||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
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.as_posix()))
|
||||||
|
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.as_posix(),
|
||||||
|
):
|
||||||
|
if doc.prompt_slug:
|
||||||
|
grouped.setdefault(doc.prompt_slug, set()).add(doc)
|
||||||
|
return grouped
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
from collections.abc import Iterable
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from fnmatch import fnmatch
|
||||||
|
from importlib.resources.abc import Traversable
|
||||||
|
from itertools import groupby
|
||||||
|
from itertools import starmap
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from typing import Self
|
||||||
|
|
||||||
|
from personal_mcp.registry.models.common import ReferenceEntry
|
||||||
|
from personal_mcp.registry.models.skill import SkillFrontmatter
|
||||||
|
from personal_mcp.registry.models.skill import StoredSkill
|
||||||
|
from personal_mcp.registry.models.skill import StoredSkillReference
|
||||||
|
from personal_mcp.skills.document_loader import _reference_id_from_filename
|
||||||
|
from personal_mcp.skills.document_loader import _title_from_reference_filename
|
||||||
|
|
||||||
|
from .document import MarkdownDocument
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SkillFilesBundle:
|
||||||
|
"""Represents a skill and all of its associated markdown files."""
|
||||||
|
|
||||||
|
slug: str
|
||||||
|
skill: MarkdownDocument
|
||||||
|
references: tuple[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_skill_paths(docs).items()))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_paths(cls, slug: str, paths: set[MarkdownDocument]) -> Self:
|
||||||
|
skill = next(iter(p for p in paths if p.relpath.name == "SKILL.md"))
|
||||||
|
sorted_paths = tuple(sorted(paths, key=lambda p: p.relpath.as_posix()))
|
||||||
|
references = tuple(p for p in sorted_paths if fnmatch(p.relpath.as_posix(), f"skills/{slug}/references/*.md"))
|
||||||
|
other = tuple(p for p in sorted_paths if p not in references and p != skill)
|
||||||
|
return cls(
|
||||||
|
slug=slug,
|
||||||
|
skill=skill,
|
||||||
|
references=references,
|
||||||
|
other=other,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def group_skill_paths(docs: Iterable[MarkdownDocument]) -> dict[str, set[MarkdownDocument]]:
|
||||||
|
"""Group skills from a list of markdown documents by their skill slug."""
|
||||||
|
s = sorted(
|
||||||
|
filter(lambda d: d.skill_slug is not None, docs),
|
||||||
|
key=lambda d: (d.skill_slug or "", d.relpath.stem),
|
||||||
|
)
|
||||||
|
grouped = groupby(s, key=lambda doc: doc.skill_slug)
|
||||||
|
return {k: set(g) for k, g in grouped if k}
|
||||||
|
|
||||||
|
|
||||||
|
def _discover_reference_entries(bundle: SkillFilesBundle) -> dict[str, ReferenceEntry]:
|
||||||
|
discovered: dict[str, ReferenceEntry] = {}
|
||||||
|
for reference_doc in bundle.references:
|
||||||
|
ref_id = _reference_id_from_filename(reference_doc.relpath.name)
|
||||||
|
if ref_id is None:
|
||||||
|
continue
|
||||||
|
discovered[ref_id] = ReferenceEntry(
|
||||||
|
path=PurePosixPath("references").joinpath(reference_doc.relpath.name).as_posix(),
|
||||||
|
title=_title_from_reference_filename(reference_doc.relpath.name),
|
||||||
|
)
|
||||||
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
|
def build_stored_skill(
|
||||||
|
*,
|
||||||
|
bundle: SkillFilesBundle,
|
||||||
|
docs_by_relpath: Mapping[PurePosixPath, MarkdownDocument],
|
||||||
|
) -> StoredSkill:
|
||||||
|
frontmatter = SkillFrontmatter.from_raw_yaml(bundle.skill.frontmatter)
|
||||||
|
metadata = frontmatter.x_personal_mcp
|
||||||
|
merged_entries = _discover_reference_entries(bundle)
|
||||||
|
merged_entries.update(dict(metadata.references))
|
||||||
|
|
||||||
|
references: dict[str, StoredSkillReference] = {}
|
||||||
|
for ref_id, entry in sorted(merged_entries.items()):
|
||||||
|
ref_relpath = PurePosixPath("skills").joinpath(bundle.slug).joinpath(entry.path)
|
||||||
|
if ref_relpath not in docs_by_relpath:
|
||||||
|
raise KeyError(f"reference document not found for '{metadata.id}:{ref_id}' at {ref_relpath.as_posix()}")
|
||||||
|
ref_doc = docs_by_relpath[ref_relpath]
|
||||||
|
references[ref_id] = StoredSkillReference(
|
||||||
|
ref_id=ref_id,
|
||||||
|
relpath=ref_relpath,
|
||||||
|
content=ref_doc.content,
|
||||||
|
entry=entry,
|
||||||
|
)
|
||||||
|
|
||||||
|
return StoredSkill.model_validate(
|
||||||
|
{
|
||||||
|
"skill_id": metadata.id,
|
||||||
|
"relpath": bundle.skill.relpath,
|
||||||
|
"content": bundle.skill.content,
|
||||||
|
"frontmatter": frontmatter,
|
||||||
|
"references": references,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||||
|
from personal_mcp.registry.ingest.prompt import PromptFilesBundle
|
||||||
|
from personal_mcp.registry.ingest.skill import SkillFilesBundle
|
||||||
|
from personal_mcp.registry.ingest.skill import build_stored_skill
|
||||||
|
from personal_mcp.registry.models.common import _normalize_docs_path
|
||||||
|
from personal_mcp.registry.models.prompt import StoredPrompt
|
||||||
|
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.models.registry import ReferenceRecord
|
||||||
|
from personal_mcp.registry.models.registry import SkillRecord
|
||||||
|
from personal_mcp.registry.models.registry import SkillSummaryRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_frontmatter(raw_frontmatter: str | None, *, path: PurePosixPath) -> dict[str, object]:
|
||||||
|
"""Parse frontmatter YAML into a mapping for downstream validation.
|
||||||
|
|
||||||
|
This helper is retained for compatibility with model-validation tests that
|
||||||
|
exercise gate behavior directly at parse boundaries.
|
||||||
|
"""
|
||||||
|
if raw_frontmatter is None:
|
||||||
|
raise ValueError(f"missing YAML frontmatter: {path.as_posix()}")
|
||||||
|
|
||||||
|
parsed = yaml.safe_load(raw_frontmatter)
|
||||||
|
if not isinstance(parsed, dict):
|
||||||
|
raise TypeError(f"frontmatter must parse to an object: {path.as_posix()}")
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _build_skill_record(
|
||||||
|
*, bundle: SkillFilesBundle, docs_by_relpath: dict[PurePosixPath, MarkdownDocument]
|
||||||
|
) -> SkillRecord:
|
||||||
|
stored = build_stored_skill(bundle=bundle, docs_by_relpath=docs_by_relpath)
|
||||||
|
metadata = stored.frontmatter.x_personal_mcp
|
||||||
|
references: dict[str, ReferenceRecord] = {}
|
||||||
|
for ref_id, ref in sorted(stored.references.items()):
|
||||||
|
references[ref_id] = ReferenceRecord(
|
||||||
|
ref_id=ref_id,
|
||||||
|
uri=f"resource://skills/{metadata.id}/references/{ref_id}",
|
||||||
|
relpath=ref.relpath.as_posix(),
|
||||||
|
mime_type=ref.entry.mime_type,
|
||||||
|
title=ref.entry.title,
|
||||||
|
content=ref.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
return SkillRecord(
|
||||||
|
skill_id=metadata.id,
|
||||||
|
name=stored.frontmatter.name,
|
||||||
|
description=stored.frontmatter.description,
|
||||||
|
version=metadata.version,
|
||||||
|
tags=tuple(metadata.tags),
|
||||||
|
capabilities=tuple(metadata.capabilities),
|
||||||
|
document_uri=f"resource://skills/{metadata.id}/document",
|
||||||
|
document_relpath=stored.relpath.as_posix(),
|
||||||
|
document_content=stored.content,
|
||||||
|
references=references,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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.as_posix(),
|
||||||
|
document_content=stored.content,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tag_index_skills(
|
||||||
|
skills_in_order: tuple[str, ...], skills_by_id: dict[str, SkillRecord]
|
||||||
|
) -> dict[str, tuple[str, ...]]:
|
||||||
|
tag_index: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
for skill_id in skills_in_order:
|
||||||
|
for tag in skills_by_id[skill_id].tags:
|
||||||
|
tag_index[tag].append(skill_id)
|
||||||
|
return {tag: tuple(ids) for tag, ids in sorted(tag_index.items())}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_capability_index(
|
||||||
|
skills_in_order: tuple[str, ...], skills_by_id: dict[str, SkillRecord]
|
||||||
|
) -> dict[str, tuple[str, ...]]:
|
||||||
|
capability_index: defaultdict[str, list[str]] = defaultdict(list)
|
||||||
|
for skill_id in skills_in_order:
|
||||||
|
for capability in skills_by_id[skill_id].capabilities:
|
||||||
|
capability_index[capability].append(skill_id)
|
||||||
|
return {capability: tuple(ids) for capability, ids in sorted(capability_index.items())}
|
||||||
|
|
||||||
|
|
||||||
|
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())}
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_docs_root(*, package_anchor: str, docs_root: str) -> Path:
|
||||||
|
package = importlib.import_module(package_anchor)
|
||||||
|
package_file = getattr(package, "__file__", None)
|
||||||
|
if package_file is None:
|
||||||
|
raise ValueError(f"package anchor '{package_anchor}' has no file location")
|
||||||
|
|
||||||
|
resolved = Path(package_file).resolve().parent.joinpath(docs_root).resolve()
|
||||||
|
if not resolved.exists() or not resolved.is_dir():
|
||||||
|
raise FileNotFoundError(f"docs root does not exist or is not a directory: {resolved}")
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
|
def load_docs_registry(*, package_anchor: str, docs_root: str = "docs") -> DocsRegistry:
|
||||||
|
docs_path = _resolve_docs_root(package_anchor=package_anchor, docs_root=docs_root)
|
||||||
|
docs = MarkdownDocument.from_root(docs_path)
|
||||||
|
|
||||||
|
docs_markdown_by_path = {_normalize_docs_path(relpath.as_posix()): doc.content for relpath, doc in docs.items()}
|
||||||
|
|
||||||
|
skill_bundles = SkillFilesBundle.from_docs(docs.values())
|
||||||
|
prompt_bundles = PromptFilesBundle.from_docs(docs.values())
|
||||||
|
|
||||||
|
docs_by_relpath = {doc.relpath: doc for doc in docs.values()}
|
||||||
|
|
||||||
|
skills_by_id: dict[str, SkillRecord] = {}
|
||||||
|
skills_in_load_order: list[str] = []
|
||||||
|
for bundle in skill_bundles:
|
||||||
|
record = _build_skill_record(bundle=bundle, docs_by_relpath=docs_by_relpath)
|
||||||
|
if record.skill_id in skills_by_id:
|
||||||
|
raise ValueError(f"duplicate skill_id detected: {record.skill_id}")
|
||||||
|
skills_by_id[record.skill_id] = record
|
||||||
|
skills_in_load_order.append(record.skill_id)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
if record.prompt_id in skills_by_id:
|
||||||
|
raise ValueError(f"prompt_id collides with existing skill_id: {record.prompt_id}")
|
||||||
|
prompts_by_id[record.prompt_id] = record
|
||||||
|
prompts_in_load_order.append(record.prompt_id)
|
||||||
|
|
||||||
|
skills_in_order_tuple = tuple(skills_in_load_order)
|
||||||
|
prompts_in_order_tuple = tuple(prompts_in_load_order)
|
||||||
|
|
||||||
|
return DocsRegistry(
|
||||||
|
skills_by_id=skills_by_id,
|
||||||
|
skills_in_load_order=skills_in_order_tuple,
|
||||||
|
skills_summary_in_load_order=tuple(
|
||||||
|
SkillSummaryRecord.from_record(skills_by_id[skill_id]) for skill_id in skills_in_order_tuple
|
||||||
|
),
|
||||||
|
docs_markdown_by_path=docs_markdown_by_path,
|
||||||
|
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
||||||
|
tag_to_skill_ids=_build_tag_index_skills(skills_in_order_tuple, skills_by_id),
|
||||||
|
capability_to_skill_ids=_build_capability_index(skills_in_order_tuple, skills_by_id),
|
||||||
|
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),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Pydantic models for the document registry."""
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
from types import MappingProxyType
|
||||||
|
from typing import ClassVar
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic import ConfigDict
|
||||||
|
from pydantic import field_validator
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""Immutable base model with strict field validation rules."""
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(
|
||||||
|
extra="forbid",
|
||||||
|
frozen=True,
|
||||||
|
validate_by_alias=True,
|
||||||
|
validate_by_name=True,
|
||||||
|
str_strip_whitespace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def frozen_mapping[K, V](value: Mapping[K, V] | None = None) -> Mapping[K, V]:
|
||||||
|
return MappingProxyType(dict(value) if value is not None else {})
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceEntry(StrictFrozenModel):
|
||||||
|
"""Reference metadata for a markdown file within a skill."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
mime_type: str = "text/markdown"
|
||||||
|
title: str | None = None
|
||||||
|
|
||||||
|
@field_validator("path")
|
||||||
|
@classmethod
|
||||||
|
def validate_reference_path(cls, value: str) -> str:
|
||||||
|
path = PurePosixPath(value)
|
||||||
|
if path.is_absolute() or ".." in path.parts:
|
||||||
|
raise ValueError("reference path must be a relative in-skill path")
|
||||||
|
if not str(path).startswith("references/"):
|
||||||
|
raise ValueError("reference path must stay under references/")
|
||||||
|
if path.suffix.lower() != ".md":
|
||||||
|
raise ValueError("reference path must target a markdown file")
|
||||||
|
return path.as_posix()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_docs_path(path: str) -> str:
|
||||||
|
normalized = PurePosixPath(path)
|
||||||
|
if normalized.is_absolute() or ".." in normalized.parts:
|
||||||
|
raise ValueError("path must be a normalized docs-relative path")
|
||||||
|
if normalized.suffix.lower() != ".md":
|
||||||
|
raise ValueError("path must point to a markdown file")
|
||||||
|
return normalized.as_posix()
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import re
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
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 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: PurePosixPath
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic import field_validator
|
||||||
|
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
from .common import frozen_mapping
|
||||||
|
from .prompt import PromptArgumentEntry
|
||||||
|
|
||||||
|
|
||||||
|
class ReferenceRecord(StrictFrozenModel):
|
||||||
|
"""Registry record for a resolved skill reference document."""
|
||||||
|
|
||||||
|
ref_id: str
|
||||||
|
uri: str
|
||||||
|
relpath: str
|
||||||
|
mime_type: str
|
||||||
|
title: str | None
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class SkillRecord(StrictFrozenModel):
|
||||||
|
"""Registry record containing a fully resolved skill and references."""
|
||||||
|
|
||||||
|
skill_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
document_uri: str
|
||||||
|
document_relpath: str
|
||||||
|
document_content: str
|
||||||
|
references: Mapping[str, ReferenceRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
|
@field_validator("references", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def freeze_references(cls, value: Mapping[str, ReferenceRecord] | None) -> Mapping[str, ReferenceRecord]:
|
||||||
|
return frozen_mapping(value)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillSummaryRecord(StrictFrozenModel):
|
||||||
|
"""Compact skill summary exposed by catalog listing APIs."""
|
||||||
|
|
||||||
|
skill_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
document_uri: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, record: SkillRecord) -> "SkillSummaryRecord":
|
||||||
|
return cls(
|
||||||
|
skill_id=record.skill_id,
|
||||||
|
name=record.name,
|
||||||
|
description=record.description,
|
||||||
|
tags=record.tags,
|
||||||
|
capabilities=record.capabilities,
|
||||||
|
document_uri=record.document_uri,
|
||||||
|
version=record.version,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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: str
|
||||||
|
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 SkillPatternPayload(StrictFrozenModel):
|
||||||
|
"""Catalog payload model for skill pattern search results."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
version: str
|
||||||
|
description: str
|
||||||
|
tags: list[str]
|
||||||
|
capabilities: list[str]
|
||||||
|
resources: list[str]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, record: SkillRecord) -> "SkillPatternPayload":
|
||||||
|
return cls(
|
||||||
|
id=record.skill_id,
|
||||||
|
name=record.name,
|
||||||
|
version=record.version,
|
||||||
|
description=record.description,
|
||||||
|
tags=list(record.tags),
|
||||||
|
capabilities=list(record.capabilities),
|
||||||
|
resources=list(record.capabilities),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SkillSummaryPayload(StrictFrozenModel):
|
||||||
|
"""Catalog payload model for skill index summaries."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
tags: list[str]
|
||||||
|
capabilities: list[str]
|
||||||
|
version: str
|
||||||
|
document_uri: str
|
||||||
|
detail_uri: str
|
||||||
|
resources: dict[str, str | list[str]]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_record(cls, record: SkillRecord) -> "SkillSummaryPayload":
|
||||||
|
return cls(
|
||||||
|
id=record.skill_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/skills/{record.skill_id}",
|
||||||
|
resources={
|
||||||
|
"document": record.document_uri,
|
||||||
|
"references": [
|
||||||
|
f"resource://skills/{record.skill_id}/references/{ref_id}" for ref_id in sorted(record.references)
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
||||||
|
"""In-memory index of loaded skills, prompts, and docs content."""
|
||||||
|
|
||||||
|
skills_by_id: Mapping[str, SkillRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
skills_in_load_order: tuple[str, ...]
|
||||||
|
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
||||||
|
docs_markdown_by_path: Mapping[str, str] = Field(default_factory=frozen_mapping)
|
||||||
|
docs_markdown_path_index: tuple[str, ...]
|
||||||
|
tag_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
capability_to_skill_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
prompts_by_id: Mapping[str, PromptRecord] = Field(default_factory=frozen_mapping)
|
||||||
|
prompts_in_load_order: tuple[str, ...] = ()
|
||||||
|
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
||||||
|
tag_to_prompt_ids: Mapping[str, tuple[str, ...]] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
|
@field_validator(
|
||||||
|
"skills_by_id",
|
||||||
|
"docs_markdown_by_path",
|
||||||
|
"tag_to_skill_ids",
|
||||||
|
"capability_to_skill_ids",
|
||||||
|
"prompts_by_id",
|
||||||
|
"tag_to_prompt_ids",
|
||||||
|
mode="before",
|
||||||
|
)
|
||||||
|
@classmethod
|
||||||
|
def freeze_mappings(cls, value: Mapping[str, object] | None) -> Mapping[str, object]:
|
||||||
|
return frozen_mapping(value)
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
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 ReferenceEntry
|
||||||
|
from .common import StrictFrozenModel
|
||||||
|
from .common import frozen_mapping
|
||||||
|
|
||||||
|
|
||||||
|
class SkillMetadata(StrictFrozenModel):
|
||||||
|
"""Canonical metadata describing a skill."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
version: str
|
||||||
|
tags: tuple[str, ...] = ()
|
||||||
|
capabilities: tuple[str, ...] = Field(min_length=1)
|
||||||
|
references: Mapping[str, ReferenceEntry] = 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("references", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def freeze_references(cls, value: Mapping[str, ReferenceEntry] | None) -> Mapping[str, ReferenceEntry]:
|
||||||
|
return frozen_mapping(value)
|
||||||
|
|
||||||
|
@field_validator("references")
|
||||||
|
@classmethod
|
||||||
|
def validate_reference_ids(cls, value: Mapping[str, ReferenceEntry]) -> Mapping[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
|
||||||
|
|
||||||
|
|
||||||
|
class SkillFrontmatter(StrictFrozenModel):
|
||||||
|
"""Parsed SKILL frontmatter including standard and personal-mcp fields."""
|
||||||
|
|
||||||
|
name: str = Field(min_length=1, max_length=64)
|
||||||
|
description: str = Field(min_length=1, max_length=1024)
|
||||||
|
x_personal_mcp: SkillMetadata = 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) -> "SkillFrontmatter":
|
||||||
|
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 StoredSkillReference(StrictFrozenModel):
|
||||||
|
"""Structured representation of a skill reference markdown document."""
|
||||||
|
|
||||||
|
ref_id: str
|
||||||
|
relpath: PurePosixPath
|
||||||
|
content: str
|
||||||
|
entry: ReferenceEntry
|
||||||
|
|
||||||
|
|
||||||
|
class StoredSkill(StrictFrozenModel):
|
||||||
|
"""Structured representation of a skill markdown document."""
|
||||||
|
|
||||||
|
skill_id: str
|
||||||
|
relpath: PurePosixPath
|
||||||
|
content: str
|
||||||
|
frontmatter: SkillFrontmatter
|
||||||
|
references: Mapping[str, StoredSkillReference] = Field(default_factory=frozen_mapping)
|
||||||
|
|
||||||
|
@field_validator("frontmatter", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_frontmatter_yaml(cls, value: SkillFrontmatter | str | None) -> SkillFrontmatter:
|
||||||
|
if isinstance(value, SkillFrontmatter):
|
||||||
|
return value
|
||||||
|
return SkillFrontmatter.from_raw_yaml(value)
|
||||||
|
|
||||||
|
@field_validator("references", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def freeze_references(cls, value: Mapping[str, StoredSkillReference] | None) -> Mapping[str, StoredSkillReference]:
|
||||||
|
return frozen_mapping(value)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_contract(self) -> "StoredSkill":
|
||||||
|
parts = self.relpath.parts
|
||||||
|
if len(parts) < 3 or parts[0] != "skills":
|
||||||
|
raise ValueError("skill relpath must be under skills/<slug>/")
|
||||||
|
|
||||||
|
skill_dir_name = parts[1]
|
||||||
|
if self.frontmatter.name != skill_dir_name:
|
||||||
|
raise ValueError("frontmatter name must exactly match skill 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://skills/{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.skill_id != self.frontmatter.x_personal_mcp.id:
|
||||||
|
raise ValueError("skill_id must exactly match x-personal-mcp.id")
|
||||||
|
|
||||||
|
for ref_id, ref in self.references.items():
|
||||||
|
if ref.ref_id != ref_id:
|
||||||
|
raise ValueError(f"reference key must match ref_id: {ref_id}")
|
||||||
|
return self
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from .models.common import _normalize_docs_path
|
||||||
|
from .models.registry import DocsRegistry
|
||||||
|
|
||||||
|
|
||||||
|
def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]:
|
||||||
|
if skill_id not in registry.skills_by_id:
|
||||||
|
raise KeyError(f"unknown skill_id: {skill_id}")
|
||||||
|
skill = registry.skills_by_id[skill_id]
|
||||||
|
return {
|
||||||
|
"id": skill.skill_id,
|
||||||
|
"uri": skill.document_uri,
|
||||||
|
"format": "markdown",
|
||||||
|
"source_path": f"docs/{skill.document_relpath}",
|
||||||
|
"content": skill.document_content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_skill_reference(
|
||||||
|
registry: DocsRegistry,
|
||||||
|
*,
|
||||||
|
skill_id: str,
|
||||||
|
ref_id: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
if skill_id not in registry.skills_by_id:
|
||||||
|
raise KeyError(f"unknown skill_id: {skill_id}")
|
||||||
|
skill = registry.skills_by_id[skill_id]
|
||||||
|
if ref_id not in skill.references:
|
||||||
|
raise KeyError(f"unknown ref_id '{ref_id}' for skill '{skill_id}'")
|
||||||
|
reference = skill.references[ref_id]
|
||||||
|
return {
|
||||||
|
"id": ref_id,
|
||||||
|
"skill_id": skill_id,
|
||||||
|
"uri": reference.uri,
|
||||||
|
"format": "markdown",
|
||||||
|
"source_path": f"docs/{reference.relpath}",
|
||||||
|
"content": reference.content,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
||||||
|
normalized_path = _normalize_docs_path(path)
|
||||||
|
if normalized_path not in registry.docs_markdown_by_path:
|
||||||
|
raise KeyError(f"unknown docs path: {normalized_path}")
|
||||||
|
return {
|
||||||
|
"uri": f"resource://docs/{normalized_path}",
|
||||||
|
"format": "markdown",
|
||||||
|
"source_path": f"docs/{normalized_path}",
|
||||||
|
"content": registry.docs_markdown_by_path[normalized_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}",
|
||||||
|
"content": prompt.document_content,
|
||||||
|
}
|
||||||
@@ -1,196 +1,16 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
|
||||||
from importlib.resources import files
|
|
||||||
from importlib.resources.abc import Traversable
|
from importlib.resources.abc import Traversable
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
|
|
||||||
|
|
||||||
SKILL_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
|
from ..registry.models.common import SKILL_ID_RE
|
||||||
SEMVER_RE = re.compile(
|
from ..registry.models.common import ReferenceEntry
|
||||||
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:[-+][0-9A-Za-z.-]+)?$"
|
from ..registry.models.prompt import PromptFrontmatter
|
||||||
)
|
from ..registry.models.skill import SkillFrontmatter
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class RegistryIssue:
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
skill_id: str | None
|
|
||||||
path: str
|
|
||||||
hint: str
|
|
||||||
|
|
||||||
|
|
||||||
class DocsRegistryValidationError(Exception):
|
|
||||||
def __init__(self, errors: list[RegistryIssue]) -> None:
|
|
||||||
self.errors = errors
|
|
||||||
summary = "\n".join(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
f"{issue.code}: {issue.message} "
|
|
||||||
f"(skill={issue.skill_id or 'unknown'}, path={issue.path})"
|
|
||||||
)
|
|
||||||
for issue in errors
|
|
||||||
]
|
|
||||||
)
|
|
||||||
super().__init__(summary)
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
path = PurePosixPath(value)
|
|
||||||
if path.is_absolute() or ".." in path.parts:
|
|
||||||
raise ValueError("reference path must be a relative in-skill path")
|
|
||||||
if not str(path).startswith("references/"):
|
|
||||||
raise ValueError("reference path must stay under references/")
|
|
||||||
if path.suffix.lower() != ".md":
|
|
||||||
raise ValueError("reference path must target a markdown file")
|
|
||||||
return path.as_posix()
|
|
||||||
|
|
||||||
|
|
||||||
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("tags")
|
|
||||||
@classmethod
|
|
||||||
def validate_tags(cls, value: list[str]) -> list[str]:
|
|
||||||
for tag in value:
|
|
||||||
if not SKILL_ID_RE.fullmatch(tag):
|
|
||||||
raise ValueError(f"invalid tag: {tag}")
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class SkillFrontmatter(BaseModel):
|
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
|
||||||
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ReferenceRecord:
|
|
||||||
ref_id: str
|
|
||||||
uri: str
|
|
||||||
relpath: str
|
|
||||||
mime_type: str
|
|
||||||
title: str | None
|
|
||||||
content: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SkillRecord:
|
|
||||||
skill_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
version: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
depends_on: tuple[str, ...]
|
|
||||||
document_uri: str
|
|
||||||
document_relpath: str
|
|
||||||
document_content: str
|
|
||||||
references: dict[str, ReferenceRecord]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SkillSummaryRecord:
|
|
||||||
skill_id: str
|
|
||||||
name: str
|
|
||||||
description: str
|
|
||||||
tags: tuple[str, ...]
|
|
||||||
capabilities: tuple[str, ...]
|
|
||||||
document_uri: str
|
|
||||||
version: str
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class DocsRegistry:
|
|
||||||
skills_by_id: dict[str, SkillRecord]
|
|
||||||
skills_in_load_order: tuple[str, ...]
|
|
||||||
skills_summary_in_load_order: tuple[SkillSummaryRecord, ...]
|
|
||||||
docs_markdown_by_path: dict[str, str]
|
|
||||||
docs_markdown_path_index: tuple[str, ...]
|
|
||||||
tag_to_skill_ids: dict[str, tuple[str, ...]]
|
|
||||||
capability_to_skill_ids: dict[str, tuple[str, ...]]
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
||||||
@@ -214,15 +34,16 @@ def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str
|
|||||||
body = "\n".join(lines[end_index + 1 :])
|
body = "\n".join(lines[end_index + 1 :])
|
||||||
parsed = yaml.safe_load(raw_yaml)
|
parsed = yaml.safe_load(raw_yaml)
|
||||||
if not isinstance(parsed, dict):
|
if not isinstance(parsed, dict):
|
||||||
raise ValueError(f"frontmatter must parse to an object: {path}")
|
raise TypeError(f"frontmatter must parse to an object: {path}")
|
||||||
return parsed, body
|
return parsed, body
|
||||||
|
|
||||||
|
|
||||||
def _walk_markdown(
|
def _walk_markdown(
|
||||||
node: Traversable,
|
node: Traversable,
|
||||||
*,
|
*,
|
||||||
prefix: PurePosixPath = PurePosixPath(""),
|
prefix: PurePosixPath | None = None,
|
||||||
) -> list[tuple[str, Traversable]]:
|
) -> list[tuple[str, Traversable]]:
|
||||||
|
prefix = PurePosixPath() if prefix is None else prefix
|
||||||
results: list[tuple[str, Traversable]] = []
|
results: list[tuple[str, Traversable]] = []
|
||||||
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
for child in sorted(node.iterdir(), key=lambda item: item.name):
|
||||||
relpath = prefix.joinpath(child.name)
|
relpath = prefix.joinpath(child.name)
|
||||||
@@ -235,9 +56,7 @@ def _walk_markdown(
|
|||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def _validate_skill_frontmatter(
|
def _validate_skill_frontmatter(raw: dict[str, Any], *, skill_dir_name: str) -> SkillFrontmatter:
|
||||||
raw: dict[str, Any], *, skill_dir_name: str
|
|
||||||
) -> SkillFrontmatter:
|
|
||||||
model = SkillFrontmatter.model_validate(raw)
|
model = SkillFrontmatter.model_validate(raw)
|
||||||
if model.name != skill_dir_name:
|
if model.name != skill_dir_name:
|
||||||
raise ValueError("frontmatter name must exactly match skill directory name")
|
raise ValueError("frontmatter name must exactly match skill directory name")
|
||||||
@@ -249,13 +68,16 @@ def _validate_skill_frontmatter(
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _normalize_docs_path(path: str) -> str:
|
def _validate_prompt_frontmatter(raw: dict[str, Any], *, prompt_dir_name: str) -> PromptFrontmatter:
|
||||||
normalized = PurePosixPath(path)
|
model = PromptFrontmatter.model_validate(raw)
|
||||||
if normalized.is_absolute() or ".." in normalized.parts:
|
if model.name != prompt_dir_name:
|
||||||
raise ValueError("path must be a normalized docs-relative path")
|
raise ValueError("frontmatter name must exactly match prompt directory name")
|
||||||
if normalized.suffix.lower() != ".md":
|
if model.x_personal_mcp.id != model.name:
|
||||||
raise ValueError("path must point to a markdown file")
|
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||||
return normalized.as_posix()
|
expected_capability = f"resource://prompts/{model.name}/document"
|
||||||
|
if expected_capability not in model.x_personal_mcp.capabilities:
|
||||||
|
raise ValueError(f"capabilities must include {expected_capability}")
|
||||||
|
return model
|
||||||
|
|
||||||
|
|
||||||
def _title_from_reference_filename(filename: str) -> str:
|
def _title_from_reference_filename(filename: str) -> str:
|
||||||
@@ -301,310 +123,3 @@ def _discover_top_level_references(
|
|||||||
title=_title_from_reference_filename(child.name),
|
title=_title_from_reference_filename(child.name),
|
||||||
)
|
)
|
||||||
return discovered
|
return discovered
|
||||||
|
|
||||||
|
|
||||||
def _ensure_no_cycles(skills_by_id: dict[str, SkillRecord]) -> list[tuple[str, str]]:
|
|
||||||
visiting: set[str] = set()
|
|
||||||
visited: set[str] = set()
|
|
||||||
cycles: list[tuple[str, str]] = []
|
|
||||||
|
|
||||||
def walk(skill_id: str, stack: list[str]) -> None:
|
|
||||||
if skill_id in visited:
|
|
||||||
return
|
|
||||||
if skill_id in visiting:
|
|
||||||
cycle_from = stack[stack.index(skill_id) :]
|
|
||||||
cycles.append((skill_id, " -> ".join(cycle_from + [skill_id])))
|
|
||||||
return
|
|
||||||
|
|
||||||
visiting.add(skill_id)
|
|
||||||
stack.append(skill_id)
|
|
||||||
for dep in skills_by_id[skill_id].depends_on:
|
|
||||||
if dep in skills_by_id:
|
|
||||||
walk(dep, stack)
|
|
||||||
stack.pop()
|
|
||||||
visiting.remove(skill_id)
|
|
||||||
visited.add(skill_id)
|
|
||||||
|
|
||||||
for skill_id in sorted(skills_by_id):
|
|
||||||
walk(skill_id, [])
|
|
||||||
return cycles
|
|
||||||
|
|
||||||
|
|
||||||
def load_docs_registry(
|
|
||||||
*,
|
|
||||||
package_anchor: str,
|
|
||||||
docs_root: str = "docs",
|
|
||||||
) -> DocsRegistry:
|
|
||||||
docs_dir = files(package_anchor).joinpath(docs_root)
|
|
||||||
issues: list[RegistryIssue] = []
|
|
||||||
|
|
||||||
if not docs_dir.is_dir():
|
|
||||||
raise DocsRegistryValidationError(
|
|
||||||
[
|
|
||||||
RegistryIssue(
|
|
||||||
code="missing_docs_root",
|
|
||||||
message="docs root directory does not exist",
|
|
||||||
skill_id=None,
|
|
||||||
path=docs_root,
|
|
||||||
hint="configure docs_root to a valid packaged docs path",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
docs_markdown_by_path: dict[str, str] = {}
|
|
||||||
for relpath, doc_file in _walk_markdown(docs_dir):
|
|
||||||
docs_markdown_by_path[relpath] = doc_file.read_text(encoding="utf-8")
|
|
||||||
|
|
||||||
skills_root = docs_dir.joinpath("skills")
|
|
||||||
if not skills_root.is_dir():
|
|
||||||
raise DocsRegistryValidationError(
|
|
||||||
[
|
|
||||||
RegistryIssue(
|
|
||||||
code="missing_skills_root",
|
|
||||||
message="skills directory does not exist under docs root",
|
|
||||||
skill_id=None,
|
|
||||||
path=f"{docs_root}/skills",
|
|
||||||
hint="ensure docs/skills is included in packaged docs",
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
skills_by_id: dict[str, SkillRecord] = {}
|
|
||||||
summaries: list[SkillSummaryRecord] = []
|
|
||||||
|
|
||||||
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
|
||||||
if not skill_dir.is_dir():
|
|
||||||
continue
|
|
||||||
|
|
||||||
skill_dir_name = skill_dir.name
|
|
||||||
skill_rel_root = PurePosixPath("skills").joinpath(skill_dir_name)
|
|
||||||
skill_doc_relpath = skill_rel_root.joinpath("SKILL.md").as_posix()
|
|
||||||
skill_doc_file = skill_dir.joinpath("SKILL.md")
|
|
||||||
|
|
||||||
if not skill_doc_file.is_file():
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="missing_skill_document",
|
|
||||||
message="missing required SKILL.md",
|
|
||||||
skill_id=skill_dir_name,
|
|
||||||
path=skill_doc_relpath,
|
|
||||||
hint="add docs/skills/<skill-id>/SKILL.md",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
skill_markdown = skill_doc_file.read_text(encoding="utf-8")
|
|
||||||
try:
|
|
||||||
raw_frontmatter, _ = _parse_frontmatter(
|
|
||||||
skill_markdown,
|
|
||||||
path=skill_doc_relpath,
|
|
||||||
)
|
|
||||||
frontmatter = _validate_skill_frontmatter(
|
|
||||||
raw_frontmatter,
|
|
||||||
skill_dir_name=skill_dir_name,
|
|
||||||
)
|
|
||||||
except (ValueError, ValidationError) as exc:
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="invalid_frontmatter",
|
|
||||||
message=str(exc),
|
|
||||||
skill_id=skill_dir_name,
|
|
||||||
path=skill_doc_relpath,
|
|
||||||
hint="fix SKILL.md YAML frontmatter to match the contract",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
effective_reference_entries = _discover_top_level_references(skill_dir=skill_dir)
|
|
||||||
effective_reference_entries.update(frontmatter.x_personal_mcp.references)
|
|
||||||
|
|
||||||
references: dict[str, ReferenceRecord] = {}
|
|
||||||
for ref_id, ref_entry in effective_reference_entries.items():
|
|
||||||
ref_relpath = skill_rel_root.joinpath(ref_entry.path).as_posix()
|
|
||||||
if ref_relpath not in docs_markdown_by_path:
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="missing_reference",
|
|
||||||
message=f"reference target is missing for ref_id '{ref_id}'",
|
|
||||||
skill_id=frontmatter.name,
|
|
||||||
path=ref_relpath,
|
|
||||||
hint="fix x-personal-mcp.references path or add the referenced markdown file",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
references[ref_id] = ReferenceRecord(
|
|
||||||
ref_id=ref_id,
|
|
||||||
uri=f"resource://skills/{frontmatter.name}/references/{ref_id}",
|
|
||||||
relpath=ref_relpath,
|
|
||||||
mime_type=ref_entry.mime_type,
|
|
||||||
title=ref_entry.title,
|
|
||||||
content=docs_markdown_by_path[ref_relpath],
|
|
||||||
)
|
|
||||||
|
|
||||||
skill_id = frontmatter.name
|
|
||||||
if skill_id in skills_by_id:
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="duplicate_skill_id",
|
|
||||||
message="duplicate skill id discovered",
|
|
||||||
skill_id=skill_id,
|
|
||||||
path=skill_doc_relpath,
|
|
||||||
hint="ensure each skill directory has a unique id",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
|
|
||||||
record = SkillRecord(
|
|
||||||
skill_id=skill_id,
|
|
||||||
name=frontmatter.name,
|
|
||||||
description=frontmatter.description,
|
|
||||||
version=frontmatter.x_personal_mcp.version,
|
|
||||||
tags=tuple(frontmatter.x_personal_mcp.tags),
|
|
||||||
capabilities=tuple(frontmatter.x_personal_mcp.capabilities),
|
|
||||||
depends_on=tuple(frontmatter.x_personal_mcp.depends_on),
|
|
||||||
document_uri=f"resource://skills/{skill_id}/document",
|
|
||||||
document_relpath=skill_doc_relpath,
|
|
||||||
document_content=skill_markdown,
|
|
||||||
references=references,
|
|
||||||
)
|
|
||||||
skills_by_id[skill_id] = record
|
|
||||||
summaries.append(
|
|
||||||
SkillSummaryRecord(
|
|
||||||
skill_id=record.skill_id,
|
|
||||||
name=record.name,
|
|
||||||
description=record.description,
|
|
||||||
tags=record.tags,
|
|
||||||
capabilities=record.capabilities,
|
|
||||||
document_uri=record.document_uri,
|
|
||||||
version=record.version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for skill_id, record in sorted(skills_by_id.items()):
|
|
||||||
for dependency in record.depends_on:
|
|
||||||
if dependency == skill_id:
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="self_dependency",
|
|
||||||
message="skill must not depend on itself",
|
|
||||||
skill_id=skill_id,
|
|
||||||
path=record.document_relpath,
|
|
||||||
hint="remove the skill id from depends_on",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
elif dependency not in skills_by_id:
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="missing_dependency",
|
|
||||||
message=f"depends_on target '{dependency}' does not exist",
|
|
||||||
skill_id=skill_id,
|
|
||||||
path=record.document_relpath,
|
|
||||||
hint="add the missing skill or remove it from depends_on",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
for cycle_start, cycle in _ensure_no_cycles(skills_by_id):
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="dependency_cycle",
|
|
||||||
message=f"depends_on cycle detected: {cycle}",
|
|
||||||
skill_id=cycle_start,
|
|
||||||
path=skills_by_id[cycle_start].document_relpath,
|
|
||||||
hint="remove at least one dependency edge in the cycle",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
seen_uris: set[str] = set()
|
|
||||||
for skill_id, record in sorted(skills_by_id.items()):
|
|
||||||
uris = [record.document_uri] + [ref.uri for ref in record.references.values()]
|
|
||||||
for uri in uris:
|
|
||||||
if uri in seen_uris:
|
|
||||||
issues.append(
|
|
||||||
RegistryIssue(
|
|
||||||
code="duplicate_uri",
|
|
||||||
message=f"duplicate resource URI generated: {uri}",
|
|
||||||
skill_id=skill_id,
|
|
||||||
path=record.document_relpath,
|
|
||||||
hint="ensure unique skill ids and reference ids",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
seen_uris.add(uri)
|
|
||||||
|
|
||||||
if issues:
|
|
||||||
raise DocsRegistryValidationError(issues)
|
|
||||||
|
|
||||||
skill_ids = tuple(sorted(skills_by_id))
|
|
||||||
summary_by_id = {summary.skill_id: summary for summary in summaries}
|
|
||||||
ordered_summaries = tuple(summary_by_id[skill_id] for skill_id in skill_ids)
|
|
||||||
|
|
||||||
tag_index: dict[str, list[str]] = {}
|
|
||||||
capability_index: dict[str, list[str]] = {}
|
|
||||||
for skill_id in skill_ids:
|
|
||||||
record = skills_by_id[skill_id]
|
|
||||||
for tag in record.tags:
|
|
||||||
tag_index.setdefault(tag, []).append(skill_id)
|
|
||||||
for capability in record.capabilities:
|
|
||||||
capability_index.setdefault(capability, []).append(skill_id)
|
|
||||||
|
|
||||||
return DocsRegistry(
|
|
||||||
skills_by_id=skills_by_id,
|
|
||||||
skills_in_load_order=skill_ids,
|
|
||||||
skills_summary_in_load_order=ordered_summaries,
|
|
||||||
docs_markdown_by_path=docs_markdown_by_path,
|
|
||||||
docs_markdown_path_index=tuple(sorted(docs_markdown_by_path)),
|
|
||||||
tag_to_skill_ids={
|
|
||||||
key: tuple(sorted(values)) for key, values in sorted(tag_index.items())
|
|
||||||
},
|
|
||||||
capability_to_skill_ids={
|
|
||||||
key: tuple(sorted(values))
|
|
||||||
for key, values in sorted(capability_index.items())
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def read_skill_document(registry: DocsRegistry, skill_id: str) -> dict[str, str]:
|
|
||||||
if skill_id not in registry.skills_by_id:
|
|
||||||
raise KeyError(f"unknown skill_id: {skill_id}")
|
|
||||||
skill = registry.skills_by_id[skill_id]
|
|
||||||
return {
|
|
||||||
"id": skill.skill_id,
|
|
||||||
"uri": skill.document_uri,
|
|
||||||
"format": "markdown",
|
|
||||||
"source_path": f"docs/{skill.document_relpath}",
|
|
||||||
"content": skill.document_content,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def read_skill_reference(
|
|
||||||
registry: DocsRegistry,
|
|
||||||
*,
|
|
||||||
skill_id: str,
|
|
||||||
ref_id: str,
|
|
||||||
) -> dict[str, str]:
|
|
||||||
if skill_id not in registry.skills_by_id:
|
|
||||||
raise KeyError(f"unknown skill_id: {skill_id}")
|
|
||||||
skill = registry.skills_by_id[skill_id]
|
|
||||||
if ref_id not in skill.references:
|
|
||||||
raise KeyError(f"unknown ref_id '{ref_id}' for skill '{skill_id}'")
|
|
||||||
reference = skill.references[ref_id]
|
|
||||||
return {
|
|
||||||
"id": ref_id,
|
|
||||||
"skill_id": skill_id,
|
|
||||||
"uri": reference.uri,
|
|
||||||
"format": "markdown",
|
|
||||||
"source_path": f"docs/{reference.relpath}",
|
|
||||||
"content": reference.content,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]:
|
|
||||||
normalized_path = _normalize_docs_path(path)
|
|
||||||
if normalized_path not in registry.docs_markdown_by_path:
|
|
||||||
raise KeyError(f"unknown docs path: {normalized_path}")
|
|
||||||
return {
|
|
||||||
"uri": f"resource://docs/{normalized_path}",
|
|
||||||
"format": "markdown",
|
|
||||||
"source_path": f"docs/{normalized_path}",
|
|
||||||
"content": registry.docs_markdown_by_path[normalized_path],
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from personal_mcp.mcp import mcp
|
from personal_mcp.mcp import mcp
|
||||||
from personal_mcp.web.config import Settings, get_settings
|
from personal_mcp.web.config import Settings
|
||||||
|
from personal_mcp.web.config import get_settings
|
||||||
from personal_mcp.web.docs_mount import mount_docs_static
|
from personal_mcp.web.docs_mount import mount_docs_static
|
||||||
from personal_mcp.web.health import router as health_router
|
from personal_mcp.web.health import router as health_router
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
from contextvars import ContextVar
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings
|
||||||
|
from pydantic_settings import SettingsConfigDict
|
||||||
|
|
||||||
|
DEFAULT_ENV_FILE = Path(".env").resolve()
|
||||||
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
_REPO_ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
@@ -10,7 +13,7 @@ class Settings(BaseSettings):
|
|||||||
"""Runtime settings for the HTTP MCP and docs server."""
|
"""Runtime settings for the HTTP MCP and docs server."""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=DEFAULT_ENV_FILE,
|
||||||
env_prefix="PERSONAL_MCP_",
|
env_prefix="PERSONAL_MCP_",
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
@@ -24,5 +27,12 @@ class Settings(BaseSettings):
|
|||||||
site_dir: Path = Field(default=_REPO_ROOT / "site")
|
site_dir: Path = Field(default=_REPO_ROOT / "site")
|
||||||
|
|
||||||
|
|
||||||
|
_settings: ContextVar[Settings | None] = ContextVar("settings", default=None)
|
||||||
|
|
||||||
|
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
return Settings()
|
settings = _settings.get()
|
||||||
|
if settings is None:
|
||||||
|
settings = Settings()
|
||||||
|
_settings.set(settings)
|
||||||
|
return settings
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, Response, status
|
from fastapi import FastAPI
|
||||||
|
from fastapi import Response
|
||||||
|
from fastapi import status
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Test package marker for intra-suite imports."""
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
# Global lightweight fixtures can be added here as the suite grows.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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.skill import SkillFilesBundle
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).parents[3]
|
||||||
|
DOCS_ROOT = REPO_ROOT / "docs"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCurrentDocsIngestion:
|
||||||
|
"""Covers ingestion of the repository's current docs tree."""
|
||||||
|
|
||||||
|
def test_loads_markdown_documents(self) -> None:
|
||||||
|
"""Ensures all current markdown documents can be loaded."""
|
||||||
|
docs = MarkdownDocument.from_root(DOCS_ROOT)
|
||||||
|
|
||||||
|
assert docs
|
||||||
|
|
||||||
|
def test_bundles_current_skills(self) -> None:
|
||||||
|
"""Ensures all current canonical skill documents can be bundled."""
|
||||||
|
docs = MarkdownDocument.from_root(DOCS_ROOT)
|
||||||
|
expected_slugs = {path.parent.name for path in DOCS_ROOT.glob("skills/*/SKILL.md")}
|
||||||
|
|
||||||
|
bundles = SkillFilesBundle.from_docs(docs.values())
|
||||||
|
|
||||||
|
assert {bundle.slug for bundle in bundles} == expected_slugs
|
||||||
|
|
||||||
|
def test_bundles_current_prompts(self) -> None:
|
||||||
|
"""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
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from pathlib import PurePosixPath
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TestMarkdownDocument:
|
||||||
|
"""Covers MarkdownDocument construction and derived properties."""
|
||||||
|
|
||||||
|
class TestFromRoot:
|
||||||
|
"""Covers loading markdown documents from a resource root."""
|
||||||
|
|
||||||
|
def test_keys_by_relpath(self, tmp_path: Path) -> None:
|
||||||
|
"""Ensures from_root returns a mapping keyed by relative path."""
|
||||||
|
skills_dir = tmp_path / "skills" / "demo"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
(skills_dir / "SKILL.md").write_text("# demo\n", encoding="utf-8")
|
||||||
|
|
||||||
|
docs = MarkdownDocument.from_root(tmp_path)
|
||||||
|
|
||||||
|
assert set(docs) == {PurePosixPath("skills/demo/SKILL.md")}
|
||||||
|
|
||||||
|
def test_loads_markdown_only(self, tmp_path: Path) -> None:
|
||||||
|
"""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 TestSkillSlugProperty:
|
||||||
|
"""Covers skill_slug derivation from document relative paths."""
|
||||||
|
|
||||||
|
def test_returns_slug(self) -> None:
|
||||||
|
"""Ensures skill_slug returns the slug for valid skills paths."""
|
||||||
|
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo/SKILL.md"), content="#")
|
||||||
|
|
||||||
|
assert doc.skill_slug == "demo"
|
||||||
|
|
||||||
|
def test_none_for_non_skill(self) -> None:
|
||||||
|
"""Ensures skill_slug is None for non-skills paths."""
|
||||||
|
doc = MarkdownDocument(relpath=PurePosixPath("docs/index.md"), content="#")
|
||||||
|
|
||||||
|
assert doc.skill_slug is None
|
||||||
|
|
||||||
|
def test_none_for_incomplete_skill(self) -> None:
|
||||||
|
"""Ensures skill_slug is None for incomplete skills paths."""
|
||||||
|
doc = MarkdownDocument(relpath=PurePosixPath("skills/demo"), content="#")
|
||||||
|
|
||||||
|
assert doc.skill_slug 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"), 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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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]
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
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.skill import SkillFilesBundle
|
||||||
|
from personal_mcp.registry.ingest.skill import group_skill_paths
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
MakeDoc = Callable[[str], MarkdownDocument]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkillFilesBundle:
|
||||||
|
"""Covers SkillFilesBundle 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 / "skills" / "alpha"
|
||||||
|
beta = tmp_path / "skills" / "beta"
|
||||||
|
(alpha / "references").mkdir(parents=True)
|
||||||
|
beta.mkdir(parents=True)
|
||||||
|
(alpha / "SKILL.md").write_text("# alpha\n", encoding="utf-8")
|
||||||
|
(alpha / "references" / "one.md").write_text("ref\n", encoding="utf-8")
|
||||||
|
(beta / "SKILL.md").write_text("# beta\n", encoding="utf-8")
|
||||||
|
|
||||||
|
bundles = SkillFilesBundle.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 / "skills" / "alpha"
|
||||||
|
beta = tmp_path / "skills" / "beta"
|
||||||
|
(alpha / "references").mkdir(parents=True)
|
||||||
|
beta.mkdir(parents=True)
|
||||||
|
(alpha / "SKILL.md").write_text("# alpha\n", encoding="utf-8")
|
||||||
|
(alpha / "references" / "one.md").write_text("ref\n", encoding="utf-8")
|
||||||
|
(beta / "SKILL.md").write_text("# beta\n", encoding="utf-8")
|
||||||
|
|
||||||
|
from_root = SkillFilesBundle.from_root(tmp_path)
|
||||||
|
from_docs = SkillFilesBundle.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 skill slug."""
|
||||||
|
docs = [
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/references/a.md"),
|
||||||
|
make_doc("skills/beta/SKILL.md"),
|
||||||
|
]
|
||||||
|
|
||||||
|
bundles = SkillFilesBundle.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 SkillFilesBundle per slug."""
|
||||||
|
docs = [
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/references/r1.md"),
|
||||||
|
make_doc("skills/alpha/references/r2.md"),
|
||||||
|
]
|
||||||
|
|
||||||
|
bundles = SkillFilesBundle.from_docs(docs)
|
||||||
|
|
||||||
|
assert len(bundles) == 1
|
||||||
|
assert bundles[0].slug == "alpha"
|
||||||
|
|
||||||
|
class TestFromPaths:
|
||||||
|
"""Covers classification of skill, reference, and other documents."""
|
||||||
|
|
||||||
|
def test_selects_skill_md(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures from_paths selects SKILL.md as the primary document."""
|
||||||
|
docs = {
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/notes.md"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle = SkillFilesBundle.from_paths("alpha", docs)
|
||||||
|
|
||||||
|
assert bundle.skill.relpath.name == "SKILL.md"
|
||||||
|
|
||||||
|
def test_collects_references(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures from_paths captures reference docs under references/."""
|
||||||
|
docs = {
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/references/r1.md"),
|
||||||
|
make_doc("skills/alpha/references/r2.md"),
|
||||||
|
make_doc("skills/alpha/notes.md"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle = SkillFilesBundle.from_paths("alpha", docs)
|
||||||
|
|
||||||
|
assert {doc.relpath.as_posix() for doc in bundle.references} == {
|
||||||
|
"skills/alpha/references/r1.md",
|
||||||
|
"skills/alpha/references/r2.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_collects_other_docs(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures from_paths classifies non-reference docs as other docs."""
|
||||||
|
docs = {
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/references/r1.md"),
|
||||||
|
make_doc("skills/alpha/notes.md"),
|
||||||
|
make_doc("skills/alpha/changelog.md"),
|
||||||
|
}
|
||||||
|
|
||||||
|
bundle = SkillFilesBundle.from_paths("alpha", docs)
|
||||||
|
|
||||||
|
assert {doc.relpath.as_posix() for doc in bundle.other} == {
|
||||||
|
"skills/alpha/changelog.md",
|
||||||
|
"skills/alpha/notes.md",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestGroupSkillPaths:
|
||||||
|
"""Covers grouping markdown documents by derived skill slug."""
|
||||||
|
|
||||||
|
def test_groups_slugged_docs(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures group_skill_paths groups only documents with a slug."""
|
||||||
|
docs = [
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/references/r.md"),
|
||||||
|
make_doc("skills/beta/SKILL.md"),
|
||||||
|
]
|
||||||
|
|
||||||
|
grouped = group_skill_paths(docs)
|
||||||
|
|
||||||
|
assert set(grouped) == {"alpha", "beta"}
|
||||||
|
|
||||||
|
def test_excludes_unslugged_docs(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures group_skill_paths excludes documents without skill slugs."""
|
||||||
|
docs = [
|
||||||
|
make_doc("docs/index.md"),
|
||||||
|
make_doc("content/usage.md"),
|
||||||
|
]
|
||||||
|
|
||||||
|
assert group_skill_paths(docs) == {}
|
||||||
|
|
||||||
|
def test_returns_sets(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures group_skill_paths returns sets of docs per slug."""
|
||||||
|
grouped = group_skill_paths([make_doc("skills/alpha/SKILL.md")])
|
||||||
|
|
||||||
|
assert isinstance(grouped["alpha"], set)
|
||||||
|
|
||||||
|
def test_stable_grouping(self, make_doc: MakeDoc) -> None:
|
||||||
|
"""Ensures group_skill_paths behaves consistently after internal sorting."""
|
||||||
|
docs = [
|
||||||
|
make_doc("skills/beta/SKILL.md"),
|
||||||
|
make_doc("skills/alpha/references/a.md"),
|
||||||
|
make_doc("skills/alpha/SKILL.md"),
|
||||||
|
]
|
||||||
|
|
||||||
|
grouped_forward = group_skill_paths(docs)
|
||||||
|
grouped_reverse = group_skill_paths(list(reversed(docs)))
|
||||||
|
|
||||||
|
assert grouped_forward == grouped_reverse
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user