started prompt mechanics

This commit is contained in:
John Lancaster
2026-06-20 20:27:32 -05:00
parent 098a2418ee
commit f8e0c14d46
12 changed files with 809 additions and 19 deletions
+19 -1
View File
@@ -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
+31
View File
@@ -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
+20
View File
@@ -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
+44 -16
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
# Testing
+25
View File
@@ -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:
+14
View File
@@ -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.
+8
View File
@@ -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",
] ]
+140 -1
View File
@@ -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),
}
+168
View File
@@ -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()
+334 -1
View File
@@ -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,
}