started prompt mechanics
This commit is contained in:
+19
-1
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -63,6 +76,8 @@ Typical catalog resources:
|
|||||||
|
|
||||||
1. resource://catalog/skills_index
|
1. resource://catalog/skills_index
|
||||||
2. resource://catalog/skills/{skill_id}
|
2. resource://catalog/skills/{skill_id}
|
||||||
|
3. resource://catalog/prompts_index
|
||||||
|
4. 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.
|
||||||
|
|
||||||
@@ -139,6 +154,9 @@ For the full URI semantics, parameter validation rules, and compatibility policy
|
|||||||
3. resource://catalog/skills_index
|
3. resource://catalog/skills_index
|
||||||
4. resource://catalog/skills/{skill_id}
|
4. resource://catalog/skills/{skill_id}
|
||||||
5. resource://docs/{path*}
|
5. resource://docs/{path*}
|
||||||
|
6. resource://catalog/prompts_index
|
||||||
|
7. resource://catalog/prompts/{prompt_id}
|
||||||
|
8. resource://prompts/{prompt_id}/document
|
||||||
|
|
||||||
Validation rules:
|
Validation rules:
|
||||||
|
|
||||||
@@ -200,7 +218,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
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ icon: lucide/braces
|
|||||||
|
|
||||||
This page defines the `SKILL.md` frontmatter and FastMCP metadata 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 optional typed argument metadata.
|
||||||
|
|
||||||
## Anthropic Frontmatter Support
|
## Anthropic Frontmatter Support
|
||||||
|
|
||||||
Across Anthropic API and Agent Skills surfaces:
|
Across Anthropic API and Agent Skills surfaces:
|
||||||
@@ -97,6 +99,35 @@ Rules for `x-personal-mcp`:
|
|||||||
5. `depends_on` is optional and lists other skill ids.
|
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.
|
6. `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 `type`, optional `description`, optional `required`, optional `default`, and optional `enum`.
|
||||||
|
3. `type` must be one of `string`, `number`, `integer`, `boolean`, `array`, or `object`.
|
||||||
|
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:
|
||||||
|
type: string
|
||||||
|
description: Target package or module under test.
|
||||||
|
required: true
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
Reference entry rules:
|
Reference entry rules:
|
||||||
|
|
||||||
1. `ref-id` is lowercase kebab-case.
|
1. `ref-id` is lowercase kebab-case.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Prompt For Creating Initial Test Structure
|
||||||
|
|
||||||
|
Create an initial test structure in `./tests` based around pytest best practices.
|
||||||
|
|
||||||
|
This is a placeholder until there's more content
|
||||||
@@ -27,6 +27,20 @@ Use this skill to bootstrap a new skill in the docs-first architecture. Try to u
|
|||||||
2. One-sentence capability statement (what it does and when to use it)
|
2. One-sentence capability statement (what it does and when to use it)
|
||||||
3. Optional list of references to include under `references/`
|
3. Optional list of references to include under `references/`
|
||||||
|
|
||||||
|
## Progressive Discovery Requirement
|
||||||
|
|
||||||
|
Every new skill created from this template should be optimized for progressive discovery, so agents load only the most relevant references in the right order.
|
||||||
|
|
||||||
|
Required sections for new skills:
|
||||||
|
|
||||||
|
1. `When to Use` with concrete trigger conditions.
|
||||||
|
2. `How To Use This Skill` with a short intent-first flow.
|
||||||
|
3. `Intent Router` mapping common task intents to specific reference files.
|
||||||
|
4. `Load Order` and `Load Budget` defaults (for example, start with one baseline reference, then one stack-specific reference).
|
||||||
|
5. `Output Contract` requiring the references consulted and the discovery path used.
|
||||||
|
|
||||||
|
If the domain includes naming or hierarchy conventions, include a dedicated naming trigger section that is consulted before structure recommendations.
|
||||||
|
|
||||||
## Source of Truth and Required References
|
## Source of Truth and Required References
|
||||||
|
|
||||||
1. Use this file as the baseline template for new skill authoring.
|
1. Use this file as the baseline template for new skill authoring.
|
||||||
@@ -47,6 +61,7 @@ docs/
|
|||||||
<skill-id>/
|
<skill-id>/
|
||||||
SKILL.md
|
SKILL.md
|
||||||
references/
|
references/
|
||||||
|
index.md (recommended reference router)
|
||||||
... (optional markdown files, nested folders allowed)
|
... (optional markdown files, nested folders allowed)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -56,6 +71,7 @@ Rules:
|
|||||||
2. All skill-specific supporting docs live under `references/`.
|
2. All skill-specific supporting docs live under `references/`.
|
||||||
3. Skill directories are ownership boundaries; no cross-skill writes.
|
3. Skill directories are ownership boundaries; no cross-skill writes.
|
||||||
4. `skill-id` is lowercase kebab-case and should remain stable.
|
4. `skill-id` is lowercase kebab-case and should remain stable.
|
||||||
|
5. Include a progressive discovery section in `SKILL.md` that makes selective reference loading explicit.
|
||||||
|
|
||||||
### Framing
|
### Framing
|
||||||
|
|
||||||
@@ -148,6 +164,9 @@ Compatibility rule:
|
|||||||
5. Include resource://skills/<skill-id>/document in capabilities.
|
5. Include resource://skills/<skill-id>/document in capabilities.
|
||||||
6. For each top-level `references/<name>.md`, expect `resource://skills/<skill-id>/references/<name>` (normalized to lowercase kebab-case).
|
6. For each top-level `references/<name>.md`, expect `resource://skills/<skill-id>/references/<name>` (normalized to lowercase kebab-case).
|
||||||
7. Add explicit `x-personal-mcp.references` entries only for nested paths or metadata overrides.
|
7. Add explicit `x-personal-mcp.references` entries only for nested paths or metadata overrides.
|
||||||
|
8. Add `When to Use`, `How To Use This Skill`, `Intent Router`, `Load Order`, and `Load Budget` sections in the skill body.
|
||||||
|
9. Ensure the `Output Contract` requires reporting consulted references and the decision path.
|
||||||
|
10. If naming conventions are part of the domain, include a naming trigger section and place naming guidance early in the flow.
|
||||||
|
|
||||||
## Required Outcomes
|
## Required Outcomes
|
||||||
|
|
||||||
@@ -156,6 +175,7 @@ Compatibility rule:
|
|||||||
3. Ensure frontmatter follows repository contract, including `x-personal-mcp` fields and canonical capabilities.
|
3. Ensure frontmatter follows repository contract, including `x-personal-mcp` fields and canonical capabilities.
|
||||||
4. Keep URI and reference mapping consistent with repository conventions.
|
4. Keep URI and reference mapping consistent with repository conventions.
|
||||||
5. Reconcile all updates with repository implementation and avoid introducing parallel metadata systems.
|
5. Reconcile all updates with repository implementation and avoid introducing parallel metadata systems.
|
||||||
|
6. Ensure the skill body is structured for progressive discovery and selective reference loading.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: pytest-scaffolding
|
name: pytest-scaffolding
|
||||||
description: "Reference hub for pytest suite structure, naming, markers, and stack-specific testing patterns. Use when you need best-practice guidance and source links for core pytest, FastAPI testing, and SQLAlchemy testing."
|
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."
|
||||||
argument-hint: "Target scope plus stack details (pure Python, FastAPI, SQLAlchemy sync, SQLAlchemy async, or mixed)"
|
argument-hint: "Target scope plus stack details (pure Python, FastAPI, SQLAlchemy sync, SQLAlchemy async, or mixed)"
|
||||||
x-personal-mcp:
|
x-personal-mcp:
|
||||||
id: pytest-scaffolding
|
id: pytest-scaffolding
|
||||||
@@ -29,24 +29,50 @@ Repository defaults:
|
|||||||
- pytest settings live in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
- pytest settings live in `pyproject.toml` under `[tool.pytest.ini_options]`.
|
||||||
- strict marker checking is expected (`--strict-markers`).
|
- strict marker checking is expected (`--strict-markers`).
|
||||||
|
|
||||||
## Reference Map
|
## Progressive Discovery Start
|
||||||
|
|
||||||
Use this map to open only the reference that matches your immediate need.
|
Use this load order by default so guidance stays targeted and naming conventions are pulled in early:
|
||||||
|
|
||||||
- Core pytest practices and command patterns: [pytest-docs.md](./references/pytest-docs.md)
|
1. Classify intent first: naming and organization, baseline pytest mechanics, FastAPI testing, SQLAlchemy testing, or mixed.
|
||||||
- Naming conventions and hierarchy organization: [naming-and-organization.md](./references/naming-and-organization.md)
|
2. For create/restructure/rename tasks, load [naming-and-organization.md](./references/naming-and-organization.md) first.
|
||||||
- FastAPI-specific testing patterns: [fastapi-testing.md](./references/fastapi-testing.md)
|
3. Load [pytest-docs.md](./references/pytest-docs.md) next for fixture and marker defaults.
|
||||||
- SQLAlchemy-specific testing patterns: [sqlalchemy-testing.md](./references/sqlalchemy-testing.md)
|
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)
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
## Baseline Best Practices
|
## Baseline Best Practices
|
||||||
|
|
||||||
These are stable defaults regardless of stack:
|
These are stable defaults regardless of stack:
|
||||||
|
|
||||||
1. Mirror `src/` into `tests/` so ownership and coverage are obvious.
|
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. Keep fixtures explicit and layered (`tests/conftest.py` globally, subtree `conftest.py` for domain-specific fixtures).
|
2. Mirror `src/` into `tests/` so ownership and coverage are obvious.
|
||||||
3. Register markers up front (`unit`, `integration`, `smoke`, `slow`, `external`) and keep strict marker checks enabled.
|
3. Keep fixtures explicit and layered (`tests/conftest.py` globally, subtree `conftest.py` for domain-specific fixtures).
|
||||||
4. Separate fast feedback (`-m unit`) from broader integration/external lanes.
|
4. Register markers up front (`unit`, `integration`, `smoke`, `slow`, `external`) and keep strict marker checks enabled.
|
||||||
5. Validate structure early with collection checks before expanding assertions.
|
5. Separate fast feedback (`-m unit`) from broader integration/external lanes.
|
||||||
|
6. Validate structure early with collection checks before expanding assertions.
|
||||||
|
|
||||||
## Stack-Specific Guidance
|
## Stack-Specific Guidance
|
||||||
|
|
||||||
@@ -76,7 +102,9 @@ Use these commands to check structure and execution lanes:
|
|||||||
## Output Contract
|
## Output Contract
|
||||||
When this skill is applied, return:
|
When this skill is applied, return:
|
||||||
1. Which references were consulted.
|
1. Which references were consulted.
|
||||||
2. Recommended structure, naming, fixture, and marker decisions.
|
2. The discovery path used (intent classification, load order, and why).
|
||||||
3. Exact validation commands.
|
3. Recommended structure, naming, fixture, and marker decisions.
|
||||||
4. Relevant source-doc links for any non-trivial recommendation.
|
4. Concrete naming outcomes: file/module naming pattern, class usage decision, and any parametrization `ids` conventions.
|
||||||
5. Risks, assumptions, or open questions.
|
5. Exact validation commands.
|
||||||
|
6. Relevant source-doc links for any non-trivial recommendation.
|
||||||
|
7. Risks, assumptions, or open questions.
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
# Testing
|
||||||
@@ -15,6 +15,9 @@ The public, preferred URIs are:
|
|||||||
3. `resource://skills/{skill_id}/document`
|
3. `resource://skills/{skill_id}/document`
|
||||||
4. `resource://skills/{skill_id}/references/{ref_id}`
|
4. `resource://skills/{skill_id}/references/{ref_id}`
|
||||||
5. `resource://docs/{path*}`
|
5. `resource://docs/{path*}`
|
||||||
|
6. `resource://catalog/prompts_index`
|
||||||
|
7. `resource://catalog/prompts/{prompt_id}`
|
||||||
|
8. `resource://prompts/{prompt_id}/document`
|
||||||
|
|
||||||
Contract intent:
|
Contract intent:
|
||||||
|
|
||||||
@@ -52,6 +55,23 @@ Contract intent:
|
|||||||
2. Supports nested paths via RFC6570 wildcard expansion.
|
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`.
|
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/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
|
## Template Parameter And Validation Rules
|
||||||
|
|
||||||
### `skill_id`
|
### `skill_id`
|
||||||
@@ -72,6 +92,11 @@ Contract intent:
|
|||||||
4. Resolves only inside `docs/`.
|
4. Resolves only inside `docs/`.
|
||||||
5. Markdown-only in the end state, meaning `.md` files.
|
5. Markdown-only in the end state, meaning `.md` files.
|
||||||
|
|
||||||
|
### `prompt_id`
|
||||||
|
|
||||||
|
1. Lowercase kebab-case.
|
||||||
|
2. Must be unique across prompt ids and must not collide with skill ids.
|
||||||
|
|
||||||
## URI Versioning Policy
|
## URI Versioning Policy
|
||||||
|
|
||||||
Default rule:
|
Default rule:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -30,12 +32,18 @@ In this repository, skill guidance is exposed as MCP resources, not as server-ow
|
|||||||
|
|
||||||
1. `resource://catalog/skills_index`
|
1. `resource://catalog/skills_index`
|
||||||
2. `resource://catalog/skills/{skill_id}`
|
2. `resource://catalog/skills/{skill_id}`
|
||||||
|
3. `resource://catalog/prompts_index`
|
||||||
|
4. `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,6 +82,10 @@ 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.
|
||||||
|
|
||||||
## Operating Pattern
|
## Operating Pattern
|
||||||
@@ -209,6 +221,8 @@ Compatibility aliases for clients that use `catalog_*` naming are also available
|
|||||||
1. `catalog_search_patterns`
|
1. `catalog_search_patterns`
|
||||||
2. `catalog_get_pattern_by_id`
|
2. `catalog_get_pattern_by_id`
|
||||||
3. `catalog_get_skill_document_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.
|
Use canonical names first; aliases exist only to preserve interoperability when a client emits non-canonical names.
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
from personal_mcp.catalog.server import (
|
from personal_mcp.catalog.server import (
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"build_skill_detail_payload",
|
"build_skill_detail_payload",
|
||||||
|
"build_prompt_detail_payload",
|
||||||
|
"build_prompts_index_payload",
|
||||||
"build_skills_index_payload",
|
"build_skills_index_payload",
|
||||||
|
"get_prompt_by_id_payload",
|
||||||
"get_pattern_by_id_payload",
|
"get_pattern_by_id_payload",
|
||||||
|
"search_prompts_payload",
|
||||||
"search_patterns_payload",
|
"search_patterns_payload",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from personal_mcp.skills.document_loader import DocsRegistry, SkillRecord
|
from personal_mcp.skills.document_loader import DocsRegistry, PromptRecord, SkillRecord
|
||||||
|
|
||||||
DEFAULT_LIMIT = 20
|
DEFAULT_LIMIT = 20
|
||||||
MAX_LIMIT = 100
|
MAX_LIMIT = 100
|
||||||
@@ -41,6 +41,19 @@ def _summary_payload(skill: SkillRecord) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt_summary_payload(prompt: PromptRecord) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": prompt.prompt_id,
|
||||||
|
"name": prompt.name,
|
||||||
|
"description": prompt.description,
|
||||||
|
"tags": list(prompt.tags),
|
||||||
|
"capabilities": list(prompt.capabilities),
|
||||||
|
"version": prompt.version,
|
||||||
|
"document_uri": prompt.document_uri,
|
||||||
|
"detail_uri": f"resource://catalog/prompts/{prompt.prompt_id}",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _skill_matches(
|
def _skill_matches(
|
||||||
skill: SkillRecord,
|
skill: SkillRecord,
|
||||||
*,
|
*,
|
||||||
@@ -72,6 +85,34 @@ def _skill_matches(
|
|||||||
return True
|
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
|
||||||
|
|
||||||
|
if tag and tag not in prompt.tags:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def build_skills_index_payload(
|
def build_skills_index_payload(
|
||||||
registry: DocsRegistry,
|
registry: DocsRegistry,
|
||||||
*,
|
*,
|
||||||
@@ -136,6 +177,64 @@ 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": [_prompt_summary_payload(prompt) 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,
|
||||||
*,
|
*,
|
||||||
@@ -171,3 +270,43 @@ def get_pattern_by_id_payload(registry: DocsRegistry, skill_id: str) -> dict[str
|
|||||||
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": _pattern_payload(registry.skills_by_id[skill_id])}
|
||||||
|
|
||||||
|
|
||||||
|
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": [_prompt_summary_payload(prompt) for prompt in page],
|
||||||
|
"total": len(matches),
|
||||||
|
"skip": normalized_skip,
|
||||||
|
"limit": normalized_limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_prompt_by_id_payload(registry: DocsRegistry, prompt_id: str) -> dict[str, Any]:
|
||||||
|
if prompt_id not in registry.prompts_by_id:
|
||||||
|
return {"found": False, "id": prompt_id}
|
||||||
|
return {
|
||||||
|
"found": True,
|
||||||
|
"prompt": build_prompt_detail_payload(registry, prompt_id),
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
from inspect import Parameter, Signature
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastmcp import FastMCP
|
from fastmcp import FastMCP
|
||||||
@@ -8,15 +10,20 @@ from fastmcp.server.transforms import ResourcesAsTools
|
|||||||
from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform
|
from fastmcp.server.transforms.search import BM25SearchTransform, RegexSearchTransform
|
||||||
|
|
||||||
from personal_mcp.catalog.server import (
|
from personal_mcp.catalog.server import (
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
from personal_mcp.skills.document_loader import (
|
from personal_mcp.skills.document_loader import (
|
||||||
DocsRegistry,
|
DocsRegistry,
|
||||||
load_docs_registry,
|
load_docs_registry,
|
||||||
read_docs_markdown_path,
|
read_docs_markdown_path,
|
||||||
|
read_prompt_document,
|
||||||
read_skill_document,
|
read_skill_document,
|
||||||
read_skill_reference,
|
read_skill_reference,
|
||||||
)
|
)
|
||||||
@@ -77,6 +84,70 @@ 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 _python_type(prompt_arg_type: str) -> type[Any]:
|
||||||
|
if prompt_arg_type == "string":
|
||||||
|
return str
|
||||||
|
if prompt_arg_type == "number":
|
||||||
|
return float
|
||||||
|
if prompt_arg_type == "integer":
|
||||||
|
return int
|
||||||
|
if prompt_arg_type == "boolean":
|
||||||
|
return bool
|
||||||
|
if prompt_arg_type == "array":
|
||||||
|
return list
|
||||||
|
if prompt_arg_type == "object":
|
||||||
|
return dict
|
||||||
|
return str
|
||||||
|
|
||||||
|
|
||||||
|
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()):
|
||||||
|
arg_type = _python_type(arg.type)
|
||||||
|
annotations[arg_name] = arg_type
|
||||||
|
if arg.required:
|
||||||
|
default = Parameter.empty
|
||||||
|
else:
|
||||||
|
default = arg.default
|
||||||
|
params.append(
|
||||||
|
Parameter(
|
||||||
|
arg_name,
|
||||||
|
kind=Parameter.KEYWORD_ONLY,
|
||||||
|
default=default,
|
||||||
|
annotation=arg_type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
signature = Signature(parameters=params, return_annotation=str)
|
||||||
|
|
||||||
|
prompt_content = prompt.document_content
|
||||||
|
|
||||||
|
def prompt_handler(**kwargs: Any) -> str:
|
||||||
|
return _render_prompt_markdown(prompt_content, kwargs)
|
||||||
|
|
||||||
|
prompt_handler.__name__ = re.sub(r"[^a-zA-Z0-9_]", "_", prompt_id)
|
||||||
|
prompt_handler.__doc__ = prompt.description
|
||||||
|
prompt_handler.__annotations__ = annotations
|
||||||
|
prompt_handler.__signature__ = signature # type: ignore[attr-defined]
|
||||||
|
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 +221,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,6 +307,29 @@ 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)
|
||||||
|
|
||||||
|
|
||||||
@mcp.tool
|
@mcp.tool
|
||||||
def catalog_search_patterns(
|
def catalog_search_patterns(
|
||||||
query: str = "",
|
query: str = "",
|
||||||
@@ -213,4 +358,27 @@ def catalog_get_skill_document_by_id(skill_id: str) -> dict[str, Any]:
|
|||||||
return get_skill_document_by_id(skill_id)
|
return get_skill_document_by_id(skill_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def catalog_search_prompts(
|
||||||
|
query: str = "",
|
||||||
|
tags: list[str] | None = None,
|
||||||
|
skip: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Compatibility alias for clients expecting catalog_* tool naming."""
|
||||||
|
return search_prompts(
|
||||||
|
query=query,
|
||||||
|
tags=tags,
|
||||||
|
skip=skip,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mcp.tool
|
||||||
|
def catalog_get_prompt_by_id(prompt_id: str) -> dict[str, Any]:
|
||||||
|
"""Compatibility alias for clients expecting catalog_* tool naming."""
|
||||||
|
return get_prompt_by_id(prompt_id)
|
||||||
|
|
||||||
|
|
||||||
_install_tool_fallback_transforms()
|
_install_tool_fallback_transforms()
|
||||||
|
_register_prompt_objects()
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
from importlib.resources import files
|
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
|
||||||
@@ -111,6 +111,80 @@ class PersonalMcpMetadata(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PromptArgumentEntry(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||||
|
|
||||||
|
type: str = Field(min_length=1)
|
||||||
|
description: str | None = None
|
||||||
|
required: bool = False
|
||||||
|
default: Any | None = None
|
||||||
|
enum: list[str] | None = None
|
||||||
|
|
||||||
|
@field_validator("type")
|
||||||
|
@classmethod
|
||||||
|
def validate_type(cls, value: str) -> str:
|
||||||
|
allowed_types = {
|
||||||
|
"string",
|
||||||
|
"number",
|
||||||
|
"integer",
|
||||||
|
"boolean",
|
||||||
|
"array",
|
||||||
|
"object",
|
||||||
|
}
|
||||||
|
if value not in allowed_types:
|
||||||
|
raise ValueError(f"unsupported prompt argument type: {value}")
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("enum")
|
||||||
|
@classmethod
|
||||||
|
def validate_enum(cls, value: list[str] | None) -> list[str] | None:
|
||||||
|
if value is not None and not value:
|
||||||
|
raise ValueError("enum must contain at least one value when provided")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PromptMetadata(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)
|
||||||
|
arguments: dict[str, PromptArgumentEntry] = 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("arguments")
|
||||||
|
@classmethod
|
||||||
|
def validate_argument_names(
|
||||||
|
cls, value: dict[str, PromptArgumentEntry]
|
||||||
|
) -> dict[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 SkillFrontmatter(BaseModel):
|
class SkillFrontmatter(BaseModel):
|
||||||
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
|
||||||
|
|
||||||
@@ -146,6 +220,25 @@ class SkillFrontmatter(BaseModel):
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class PromptFrontmatter(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)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ReferenceRecord:
|
class ReferenceRecord:
|
||||||
ref_id: str
|
ref_id: str
|
||||||
@@ -182,6 +275,31 @@ class SkillSummaryRecord:
|
|||||||
version: str
|
version: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PromptRecord:
|
||||||
|
prompt_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
version: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
arguments: dict[str, PromptArgumentEntry]
|
||||||
|
document_uri: str
|
||||||
|
document_relpath: str
|
||||||
|
document_content: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PromptSummaryRecord:
|
||||||
|
prompt_id: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
tags: tuple[str, ...]
|
||||||
|
capabilities: tuple[str, ...]
|
||||||
|
document_uri: str
|
||||||
|
version: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class DocsRegistry:
|
class DocsRegistry:
|
||||||
skills_by_id: dict[str, SkillRecord]
|
skills_by_id: dict[str, SkillRecord]
|
||||||
@@ -191,6 +309,10 @@ class DocsRegistry:
|
|||||||
docs_markdown_path_index: tuple[str, ...]
|
docs_markdown_path_index: tuple[str, ...]
|
||||||
tag_to_skill_ids: dict[str, tuple[str, ...]]
|
tag_to_skill_ids: dict[str, tuple[str, ...]]
|
||||||
capability_to_skill_ids: dict[str, tuple[str, ...]]
|
capability_to_skill_ids: dict[str, tuple[str, ...]]
|
||||||
|
prompts_by_id: dict[str, PromptRecord] = field(default_factory=dict)
|
||||||
|
prompts_in_load_order: tuple[str, ...] = ()
|
||||||
|
prompts_summary_in_load_order: tuple[PromptSummaryRecord, ...] = ()
|
||||||
|
tag_to_prompt_ids: dict[str, tuple[str, ...]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
def _parse_frontmatter(markdown: str, *, path: str) -> tuple[dict[str, Any], str]:
|
||||||
@@ -249,6 +371,20 @@ def _validate_skill_frontmatter(
|
|||||||
return model
|
return model
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_prompt_frontmatter(
|
||||||
|
raw: dict[str, Any], *, prompt_dir_name: str
|
||||||
|
) -> PromptFrontmatter:
|
||||||
|
model = PromptFrontmatter.model_validate(raw)
|
||||||
|
if model.name != prompt_dir_name:
|
||||||
|
raise ValueError("frontmatter name must exactly match prompt directory name")
|
||||||
|
if model.x_personal_mcp.id != model.name:
|
||||||
|
raise ValueError("x-personal-mcp.id must exactly match name")
|
||||||
|
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 _normalize_docs_path(path: str) -> str:
|
def _normalize_docs_path(path: str) -> str:
|
||||||
normalized = PurePosixPath(path)
|
normalized = PurePosixPath(path)
|
||||||
if normalized.is_absolute() or ".." in normalized.parts:
|
if normalized.is_absolute() or ".." in normalized.parts:
|
||||||
@@ -371,6 +507,8 @@ def load_docs_registry(
|
|||||||
|
|
||||||
skills_by_id: dict[str, SkillRecord] = {}
|
skills_by_id: dict[str, SkillRecord] = {}
|
||||||
summaries: list[SkillSummaryRecord] = []
|
summaries: list[SkillSummaryRecord] = []
|
||||||
|
prompts_by_id: dict[str, PromptRecord] = {}
|
||||||
|
prompt_summaries: list[PromptSummaryRecord] = []
|
||||||
|
|
||||||
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
for skill_dir in sorted(skills_root.iterdir(), key=lambda item: item.name):
|
||||||
if not skill_dir.is_dir():
|
if not skill_dir.is_dir():
|
||||||
@@ -481,6 +619,154 @@ def load_docs_registry(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
prompts_root = docs_dir.joinpath("prompts")
|
||||||
|
discovered_prompt_docs: set[str] = set()
|
||||||
|
if prompts_root.is_dir():
|
||||||
|
for prompt_dir in sorted(prompts_root.iterdir(), key=lambda item: item.name):
|
||||||
|
if not prompt_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt_dir_name = prompt_dir.name
|
||||||
|
prompt_rel_root = PurePosixPath("prompts").joinpath(prompt_dir_name)
|
||||||
|
prompt_doc_relpath = prompt_rel_root.joinpath("PROMPT.md").as_posix()
|
||||||
|
prompt_doc_file = prompt_dir.joinpath("PROMPT.md")
|
||||||
|
|
||||||
|
if not prompt_doc_file.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
discovered_prompt_docs.add(prompt_doc_relpath)
|
||||||
|
|
||||||
|
prompt_markdown = prompt_doc_file.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
|
raw_frontmatter, _ = _parse_frontmatter(
|
||||||
|
prompt_markdown,
|
||||||
|
path=prompt_doc_relpath,
|
||||||
|
)
|
||||||
|
frontmatter = _validate_prompt_frontmatter(
|
||||||
|
raw_frontmatter,
|
||||||
|
prompt_dir_name=prompt_dir_name,
|
||||||
|
)
|
||||||
|
except (ValueError, ValidationError) as exc:
|
||||||
|
issues.append(
|
||||||
|
RegistryIssue(
|
||||||
|
code="invalid_prompt_frontmatter",
|
||||||
|
message=str(exc),
|
||||||
|
skill_id=prompt_dir_name,
|
||||||
|
path=prompt_doc_relpath,
|
||||||
|
hint="fix PROMPT.md YAML frontmatter to match the contract",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt_id = frontmatter.name
|
||||||
|
if prompt_id in prompts_by_id:
|
||||||
|
issues.append(
|
||||||
|
RegistryIssue(
|
||||||
|
code="duplicate_prompt_id",
|
||||||
|
message="duplicate prompt id discovered",
|
||||||
|
skill_id=prompt_id,
|
||||||
|
path=prompt_doc_relpath,
|
||||||
|
hint="ensure each prompt directory has a unique id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if prompt_id in skills_by_id:
|
||||||
|
issues.append(
|
||||||
|
RegistryIssue(
|
||||||
|
code="prompt_skill_id_collision",
|
||||||
|
message="prompt id collides with an existing skill id",
|
||||||
|
skill_id=prompt_id,
|
||||||
|
path=prompt_doc_relpath,
|
||||||
|
hint="use a unique prompt id that does not match a skill id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt_record = PromptRecord(
|
||||||
|
prompt_id=prompt_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),
|
||||||
|
arguments=frontmatter.x_personal_mcp.arguments,
|
||||||
|
document_uri=f"resource://prompts/{prompt_id}/document",
|
||||||
|
document_relpath=prompt_doc_relpath,
|
||||||
|
document_content=prompt_markdown,
|
||||||
|
)
|
||||||
|
prompts_by_id[prompt_id] = prompt_record
|
||||||
|
prompt_summaries.append(
|
||||||
|
PromptSummaryRecord(
|
||||||
|
prompt_id=prompt_record.prompt_id,
|
||||||
|
name=prompt_record.name,
|
||||||
|
description=prompt_record.description,
|
||||||
|
tags=prompt_record.tags,
|
||||||
|
capabilities=prompt_record.capabilities,
|
||||||
|
document_uri=prompt_record.document_uri,
|
||||||
|
version=prompt_record.version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for relpath, prompt_file in _walk_markdown(
|
||||||
|
prompts_root,
|
||||||
|
prefix=PurePosixPath("prompts"),
|
||||||
|
):
|
||||||
|
if relpath in discovered_prompt_docs or relpath.endswith("/PROMPT.md"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt_markdown = prompt_file.read_text(encoding="utf-8")
|
||||||
|
prompt_id = _reference_id_from_filename(PurePosixPath(relpath).name)
|
||||||
|
if prompt_id is None:
|
||||||
|
continue
|
||||||
|
if prompt_id in prompts_by_id:
|
||||||
|
issues.append(
|
||||||
|
RegistryIssue(
|
||||||
|
code="duplicate_prompt_id",
|
||||||
|
message="duplicate prompt id discovered",
|
||||||
|
skill_id=prompt_id,
|
||||||
|
path=relpath,
|
||||||
|
hint="ensure each prompt id is unique",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if prompt_id in skills_by_id:
|
||||||
|
issues.append(
|
||||||
|
RegistryIssue(
|
||||||
|
code="prompt_skill_id_collision",
|
||||||
|
message="prompt id collides with an existing skill id",
|
||||||
|
skill_id=prompt_id,
|
||||||
|
path=relpath,
|
||||||
|
hint="use a unique prompt id that does not match a skill id",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
prompt_record = PromptRecord(
|
||||||
|
prompt_id=prompt_id,
|
||||||
|
name=prompt_id,
|
||||||
|
description=f"Legacy prompt loaded from docs/{relpath}",
|
||||||
|
version="1.0.0",
|
||||||
|
tags=("prompt", "legacy"),
|
||||||
|
capabilities=(f"resource://prompts/{prompt_id}/document",),
|
||||||
|
arguments={},
|
||||||
|
document_uri=f"resource://prompts/{prompt_id}/document",
|
||||||
|
document_relpath=relpath,
|
||||||
|
document_content=prompt_markdown,
|
||||||
|
)
|
||||||
|
prompts_by_id[prompt_id] = prompt_record
|
||||||
|
prompt_summaries.append(
|
||||||
|
PromptSummaryRecord(
|
||||||
|
prompt_id=prompt_record.prompt_id,
|
||||||
|
name=prompt_record.name,
|
||||||
|
description=prompt_record.description,
|
||||||
|
tags=prompt_record.tags,
|
||||||
|
capabilities=prompt_record.capabilities,
|
||||||
|
document_uri=prompt_record.document_uri,
|
||||||
|
version=prompt_record.version,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
for skill_id, record in sorted(skills_by_id.items()):
|
for skill_id, record in sorted(skills_by_id.items()):
|
||||||
for dependency in record.depends_on:
|
for dependency in record.depends_on:
|
||||||
if dependency == skill_id:
|
if dependency == skill_id:
|
||||||
@@ -531,15 +817,37 @@ def load_docs_registry(
|
|||||||
)
|
)
|
||||||
seen_uris.add(uri)
|
seen_uris.add(uri)
|
||||||
|
|
||||||
|
for prompt_id, record in sorted(prompts_by_id.items()):
|
||||||
|
for uri in [record.document_uri]:
|
||||||
|
if uri in seen_uris:
|
||||||
|
issues.append(
|
||||||
|
RegistryIssue(
|
||||||
|
code="duplicate_uri",
|
||||||
|
message=f"duplicate resource URI generated: {uri}",
|
||||||
|
skill_id=prompt_id,
|
||||||
|
path=record.document_relpath,
|
||||||
|
hint="ensure unique prompt ids",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
seen_uris.add(uri)
|
||||||
|
|
||||||
if issues:
|
if issues:
|
||||||
raise DocsRegistryValidationError(issues)
|
raise DocsRegistryValidationError(issues)
|
||||||
|
|
||||||
skill_ids = tuple(sorted(skills_by_id))
|
skill_ids = tuple(sorted(skills_by_id))
|
||||||
summary_by_id = {summary.skill_id: summary for summary in summaries}
|
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)
|
ordered_summaries = tuple(summary_by_id[skill_id] for skill_id in skill_ids)
|
||||||
|
prompt_ids = tuple(sorted(prompts_by_id))
|
||||||
|
prompt_summary_by_id = {
|
||||||
|
summary.prompt_id: summary for summary in prompt_summaries
|
||||||
|
}
|
||||||
|
ordered_prompt_summaries = tuple(
|
||||||
|
prompt_summary_by_id[prompt_id] for prompt_id in prompt_ids
|
||||||
|
)
|
||||||
|
|
||||||
tag_index: dict[str, list[str]] = {}
|
tag_index: dict[str, list[str]] = {}
|
||||||
capability_index: dict[str, list[str]] = {}
|
capability_index: dict[str, list[str]] = {}
|
||||||
|
prompt_tag_index: dict[str, list[str]] = {}
|
||||||
for skill_id in skill_ids:
|
for skill_id in skill_ids:
|
||||||
record = skills_by_id[skill_id]
|
record = skills_by_id[skill_id]
|
||||||
for tag in record.tags:
|
for tag in record.tags:
|
||||||
@@ -547,6 +855,11 @@ def load_docs_registry(
|
|||||||
for capability in record.capabilities:
|
for capability in record.capabilities:
|
||||||
capability_index.setdefault(capability, []).append(skill_id)
|
capability_index.setdefault(capability, []).append(skill_id)
|
||||||
|
|
||||||
|
for prompt_id in prompt_ids:
|
||||||
|
record = prompts_by_id[prompt_id]
|
||||||
|
for tag in record.tags:
|
||||||
|
prompt_tag_index.setdefault(tag, []).append(prompt_id)
|
||||||
|
|
||||||
return DocsRegistry(
|
return DocsRegistry(
|
||||||
skills_by_id=skills_by_id,
|
skills_by_id=skills_by_id,
|
||||||
skills_in_load_order=skill_ids,
|
skills_in_load_order=skill_ids,
|
||||||
@@ -560,6 +873,13 @@ def load_docs_registry(
|
|||||||
key: tuple(sorted(values))
|
key: tuple(sorted(values))
|
||||||
for key, values in sorted(capability_index.items())
|
for key, values in sorted(capability_index.items())
|
||||||
},
|
},
|
||||||
|
prompts_by_id=prompts_by_id,
|
||||||
|
prompts_in_load_order=prompt_ids,
|
||||||
|
prompts_summary_in_load_order=ordered_prompt_summaries,
|
||||||
|
tag_to_prompt_ids={
|
||||||
|
key: tuple(sorted(values))
|
||||||
|
for key, values in sorted(prompt_tag_index.items())
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -608,3 +928,16 @@ def read_docs_markdown_path(registry: DocsRegistry, path: str) -> dict[str, str]
|
|||||||
"source_path": f"docs/{normalized_path}",
|
"source_path": f"docs/{normalized_path}",
|
||||||
"content": registry.docs_markdown_by_path[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,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user