prompt markdown

This commit is contained in:
John Lancaster
2026-08-07 20:30:54 -05:00
parent 5b6d5aaec4
commit 88ff4c2c71
30 changed files with 475 additions and 373 deletions
+6 -10
View File
@@ -11,11 +11,11 @@ The application combines a FastMCP server with a pre-built Zensical documentatio
The runtime has four content paths: The runtime has four content paths:
1. `SkillsDirectoryProvider` publishes native `skill://` resources from packaged skill directories. 1. `SkillsDirectoryProvider` publishes native `skill://` resources from packaged skill directories.
2. `FileSystemProvider` discovers typed `@prompt` functions from packaged Python modules. 2. A custom prompt provider loads declarative prompt definitions from packaged Markdown.
3. The general docs registry publishes non-skill Markdown through `resource://docs/{path*}`. 3. The general docs registry publishes non-skill Markdown through `resource://docs/{path*}`.
4. FastAPI serves the pre-built `site/` directory. 4. FastAPI serves the pre-built `site/` directory.
There is no custom skill catalog, prompt catalog, or prompt registry model. There is no custom skill catalog, prompt catalog, or per-prompt Python module.
## Source Ownership ## Source Ownership
@@ -36,12 +36,9 @@ The provider parses standard skill frontmatter and generates the manifest. The g
### Prompts ### Prompts
Each prompt has two coordinated sources: Each prompt has one source: `docs/prompts/<prompt-id>/PROMPT.md`. Its nested `prompt` frontmatter owns runtime metadata and argument declarations, while its body owns canonical prose.
1. `src/personal_mcp/prompts/components/<module>.py` owns the typed signature and runtime metadata. The custom provider reads packaged Markdown with `importlib.resources`, validates metadata and exact placeholder-to-argument equality, and creates native FastMCP prompt objects. It rescans on each list and get request, so an editable deployment observes file additions, edits, and deletions without a restart.
2. `docs/prompts/<prompt-id>/PROMPT.md` owns the canonical prompt prose.
The component loads Markdown with `importlib.resources`. The renderer strips documentation frontmatter, requires exact placeholder-to-argument equality, and substitutes typed values. `FileSystemProvider(reload=False)` discovers the components during server construction.
FastMCP exposes prompts through native `prompts/list` and `prompts/get` operations. FastMCP exposes prompts through native `prompts/list` and `prompts/get` operations.
@@ -54,8 +51,7 @@ The docs registry indexes packaged Markdown for `resource://docs/{path*}`. It re
```mermaid ```mermaid
flowchart TD flowchart TD
A[Packaged Skill Directories] --> B[SkillsDirectoryProvider] A[Packaged Skill Directories] --> B[SkillsDirectoryProvider]
C[Typed Prompt Components] --> D[FileSystemProvider] C[Packaged Prompt Markdown] --> D[Markdown Prompt Provider]
E[Packaged Prompt Markdown] --> C
F[General Markdown] --> G[Docs Registry] F[General Markdown] --> G[Docs Registry]
B --> H[FastMCP Server] B --> H[FastMCP Server]
D --> H D --> H
@@ -65,7 +61,7 @@ flowchart TD
K --> M K --> M
``` ```
Server construction is lazy with respect to package import. Each application process creates its providers and docs snapshot when the server factory runs. Production providers use `reload=False`; content changes require a process restart. Server construction is lazy with respect to package import. Each application process creates its providers and docs snapshot when the server factory runs. Skills use startup discovery, while prompts are reloaded when a client lists or gets prompts.
## Packaging ## Packaging
+8 -8
View File
@@ -72,16 +72,16 @@ Recommended sequence:
## Prompt Authoring ## Prompt Authoring
Prompts pair typed Python metadata with canonical Markdown prose: A prompt is one self-describing `docs/prompts/<prompt-id>/PROMPT.md` file:
1. Add one `@prompt` function under `src/personal_mcp/prompts/components/`. 1. Create a lowercase kebab-case directory beneath `docs/prompts/`.
2. Use the function signature for arguments, requiredness, and literal constraints. 2. Add a nested `prompt` frontmatter mapping with version, description, tags, and ordered arguments.
3. Set name, description, tags, and version on the decorator. 3. Give every argument a description and explicit required flag.
4. Keep the canonical body in `docs/prompts/<prompt-id>/PROMPT.md`. 4. Add `choices` only when a string argument accepts a fixed set of values.
5. Keep Markdown placeholders exactly equal to the Python argument names. 5. Use each argument exactly once or more as a `{{argument_name}}` placeholder in the body.
6. Use only documentation-site fields in Markdown frontmatter. 6. Do not add a Python component, name field, metadata sidecar, or central catalog entry.
The production `FileSystemProvider` discovers component modules. Do not add prompt registry models, catalog resources, or dynamic signature generation. The custom provider rescans prompt documents during every native list and get request. Changes in an editable checkout are therefore visible on the next request without a process restart. Invalid metadata or placeholder drift fails that request with a configuration error.
## Frontmatter Safety ## Frontmatter Safety
+15 -7
View File
@@ -29,20 +29,28 @@ The provider uses the directory name as the URI identity and the frontmatter `de
## Prompt Documentation Frontmatter ## Prompt Documentation Frontmatter
Prompt runtime metadata is defined by typed Python components, not Markdown frontmatter. A prompt document may retain only fields consumed by the static documentation site: Each prompt stores runtime metadata in a nested `prompt` mapping beside fields consumed by the static documentation site. The runtime mapping uses this shape:
```yaml ```yaml
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Describe when to use the prompt.
tags: [example, prompts]
arguments: {topic: {description: "Topic to process.", required: true, choices: [first, second]}, notes: {description: "Optional constraints.", required: false}}
--- ---
``` ```
Prompt rules: Prompt rules:
1. Do not duplicate prompt names, descriptions, versions, tags, or arguments in Markdown YAML. 1. `version`, `description`, `tags`, and `arguments` are required; unknown fields inside `prompt` or an argument are rejected.
2. Argument names must be valid Python identifiers in the component signature. 2. The directory name supplies the prompt id. Do not add a duplicate `name` field.
3. Literal value constraints belong in Python type annotations. 3. Argument names must be valid identifiers and preserve their authored mapping order.
4. Markdown placeholders must exactly match the component argument names. 4. Every argument requires a non-empty `description` and explicit `required` boolean.
5. Optional `choices` must be a non-empty list of unique, non-empty strings.
6. Markdown placeholders must exactly match the declared argument names.
7. Top-level fields such as `icon` remain owned by the documentation site and are not runtime prompt metadata.
See the MCP [prompts concept documentation](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) and [schema reference](https://modelcontextprotocol.io/specification/latest/schema) for the protocol-level prompt shape. See the MCP [prompts concept documentation](https://modelcontextprotocol.io/docs/learn/server-concepts#prompts) and [schema reference](https://modelcontextprotocol.io/specification/latest/schema) for the protocol-level prompt shape.
@@ -54,11 +62,11 @@ Skill validation is file- and provider-oriented:
2. FastMCP parses the description and scans all files when the provider is created. 2. FastMCP parses the description and scans all files when the provider is created.
3. Repository tests enforce the stricter standard-only frontmatter and directory/name rules. 3. Repository tests enforce the stricter standard-only frontmatter and directory/name rules.
Prompt validation is provider- and renderer-oriented. Provider discovery validates decorated functions, while focused tests render every prompt and reject placeholder drift. Prompt validation is provider- and renderer-oriented. Every list or get request reloads and validates the authored files. A malformed definition fails the request instead of publishing a partial prompt set.
## Invariants ## Invariants
1. Skills remain directly portable to tools that understand standard Agent Skills directories. 1. Skills remain directly portable to tools that understand standard Agent Skills directories.
2. Native skill discovery has no parallel catalog metadata source. 2. Native skill discovery has no parallel catalog metadata source.
3. Prompts use FastMCP's native component metadata and protocol surface without a parallel catalog. 3. Prompts use FastMCP's native component metadata and protocol surface without a parallel catalog or Python component file.
4. All authored content remains under `docs/`. 4. All authored content remains under `docs/`.
+25 -23
View File
@@ -4,11 +4,11 @@ icon: lucide/messages-square
# Prompt Contract # Prompt Contract
This page defines the canonical contract for typed prompts discovered by FastMCP's `FileSystemProvider`. This page defines the canonical contract for declarative prompts published through a custom [FastMCP provider](https://gofastmcp.com/servers/providers/custom).
## Canonical Prompt Shape ## Canonical Prompt Shape
Each prompt has a Python component and one canonical Markdown document: Each prompt is one self-describing Markdown document:
```mermaid ```mermaid
--- ---
@@ -22,31 +22,32 @@ config:
lineColor: '#FFFFFF' lineColor: '#FFFFFF'
--- ---
treeView-beta treeView-beta
"src/personal_mcp/prompts/"
"components/"
"<prompt_module>.py"
"content.py"
"provider.py"
"docs/prompts/" "docs/prompts/"
"<prompt-id>/" "<prompt-id>/"
"PROMPT.md" "PROMPT.md"
"src/personal_mcp/prompts/"
"content.py"
"models.py"
"provider.py"
``` ```
Rules: Rules:
1. Each component exports one typed function decorated with `@prompt`. 1. The parent directory name defines the public prompt id.
2. Function parameters define the MCP argument names, requiredness, and accepted values. 2. The nested `prompt` frontmatter block defines version, description, tags, and arguments.
3. Decorator fields define runtime name, description, tags, and version. 3. Argument declarations define names, descriptions, requiredness, and optional string choices.
4. The function loads its matching `docs/prompts/<prompt-id>/PROMPT.md` through `importlib.resources`. 4. The Markdown body owns the rendered prompt prose and uses `{{argument_name}}` placeholders.
5. `PROMPT.md` owns the rendered prompt prose and uses `{argument_name}` placeholders. 5. Declared arguments and body placeholders must match exactly.
6. The renderer requires exact equality between the function's arguments and the Markdown placeholders. 6. No Python file is added when authoring a prompt.
## Ownership Boundary ## Ownership Boundary
1. Python owns runtime metadata and the callable schema. 1. Each `PROMPT.md` owns both its runtime metadata and prose.
2. Markdown owns prompt prose and may contain only documentation-site frontmatter. 2. Python owns only generic parsing, validation, rendering, and provider behavior.
3. There is no custom prompt catalog, prompt registry model, or metadata sidecar. 3. There is no central prompt catalog, generated signature, or metadata sidecar.
4. `FileSystemProvider(reload=False)` discovers components when the server is created. 4. The provider scans direct children of packaged `docs/prompts/` on each list or get request.
5. Additions, edits, and deletions become visible on the next request without restarting the server.
6. Reload is pull-based; the provider does not watch files or emit proactive change notifications.
## Prompt Id Contract ## Prompt Id Contract
@@ -57,7 +58,7 @@ Rules:
3. Must start with a letter. 3. Must start with a letter.
4. No underscores, spaces, dots, or uppercase characters. 4. No underscores, spaces, dots, or uppercase characters.
5. Directory name should equal `prompt-id` in each committed revision. 5. Directory name should equal `prompt-id` in each committed revision.
6. The `@prompt` name and Markdown directory name must equal `prompt-id`. 6. The provider derives the prompt name from the directory; frontmatter must not duplicate it.
7. Treat `prompt-id` as immutable after release; a rename is a breaking replacement. 7. Treat `prompt-id` as immutable after release; a rename is a breaking replacement.
Valid examples: Valid examples:
@@ -74,9 +75,10 @@ Invalid examples:
## Rendering Contract ## Rendering Contract
1. The renderer strips one leading YAML frontmatter block before returning prompt content. 1. The loader requires one leading YAML frontmatter block and validates its nested `prompt` mapping strictly.
2. Required values are supplied by the typed function signature. 2. All MCP arguments are strings; `choices` optionally restricts accepted values.
3. An omitted optional value renders as `Not provided`. 3. Missing required arguments, unknown arguments, and invalid choices fail before rendering.
4. Unknown prompt ids and mismatched placeholders fail immediately. 4. An omitted optional value renders as `Not provided`.
5. Prompt content is read from packaged resources and does not depend on the working directory. 5. Unknown prompt ids, malformed metadata, and mismatched placeholders fail immediately.
6. Prompt content is read through [importlib resources](https://docs.python.org/3/library/importlib.resources.html) and does not depend on the working directory.
+1 -1
View File
@@ -82,7 +82,7 @@ Instructions steer behavior but do not force VS Code to attach resources automat
## Prompt Objects ## Prompt Objects
Prompt modules remain separate from skills. When the client supports MCP prompt APIs, use prompt listing and `get_prompt` for parameterized workflows. Authored `PROMPT.md` remains the source of truth for each prompt. Prompts remain separate from skills. When the client supports MCP prompt APIs, use prompt listing and `get_prompt` for parameterized workflows. Each authored `PROMPT.md` is the complete source of truth for its metadata, arguments, and prose; changes are loaded on the next prompt request.
## Troubleshooting ## Troubleshooting
+6 -6
View File
@@ -28,8 +28,8 @@ treeView-beta
"static build output" "static build output"
"src/personal_mcp" "src/personal_mcp"
"mcp.py" "mcp.py"
"prompts/components/*.py"
"prompts/content.py" "prompts/content.py"
"prompts/models.py"
"prompts/provider.py" "prompts/provider.py"
"registry/" "registry/"
"skills/provider.py" "skills/provider.py"
@@ -39,7 +39,7 @@ treeView-beta
Ownership rules: Ownership rules:
1. `docs/skills/` is owned exclusively by `SkillsDirectoryProvider` at runtime. 1. `docs/skills/` is owned exclusively by `SkillsDirectoryProvider` at runtime.
2. `docs/prompts/` owns prompt prose; Python components own prompt metadata and argument schemas. 2. Each file under `docs/prompts/` owns its prompt metadata, argument schema, and prose.
3. The docs registry owns only general Markdown resources and explicitly excludes skills. 3. The docs registry owns only general Markdown resources and explicitly excludes skills.
4. `site/` is generated output. 4. `site/` is generated output.
5. The deleted custom `catalog/` package is not part of the runtime. 5. The deleted custom `catalog/` package is not part of the runtime.
@@ -49,7 +49,7 @@ Ownership rules:
```mermaid ```mermaid
flowchart TD flowchart TD
A[Packaged Skills] --> B[SkillsDirectoryProvider] A[Packaged Skills] --> B[SkillsDirectoryProvider]
C[Prompt Components] --> D[FileSystemProvider] C[Packaged Prompt Markdown] --> D[Markdown Prompt Provider]
E[Packaged Markdown] --> F[Docs Registry] E[Packaged Markdown] --> F[Docs Registry]
B --> G[FastMCP] B --> G[FastMCP]
D --> G D --> G
@@ -63,14 +63,14 @@ flowchart TD
Runtime guarantees: Runtime guarantees:
1. Providers are installed before serving requests. 1. Providers are installed before serving requests.
2. Production provider discovery uses `reload=False`. 2. Prompt discovery rescans authored files on each list and get request.
3. Duplicate components fail according to FastMCP's configured duplicate policy. 3. Duplicate components fail according to FastMCP's configured duplicate policy.
4. Skills and prompts use native FastMCP component surfaces. 4. Skills and prompts use native FastMCP component surfaces.
5. General docs path parsing rejects traversal, backslashes, non-Markdown paths, and the skill namespace. 5. General docs path parsing rejects traversal, backslashes, non-Markdown paths, and the skill namespace.
## Build And Publish Flow ## Build And Publish Flow
1. Author Markdown under `docs/` and typed prompts under `src/personal_mcp/prompts/components/`. 1. Author prompt definitions and prose under `docs/prompts/`.
2. Run `uv run zensical build` to produce `site/`. 2. Run `uv run zensical build` to produce `site/`.
3. Build the wheel, which packages the authored docs under `personal_mcp/docs/`. 3. Build the wheel, which packages the authored docs under `personal_mcp/docs/`.
4. Start the app and serve MCP plus the static site. 4. Start the app and serve MCP plus the static site.
@@ -81,7 +81,7 @@ No runtime Markdown-to-HTML conversion occurs.
1. `docs/skills/<skill-id>/SKILL.md` maps to `skill://<skill-id>/SKILL.md`. 1. `docs/skills/<skill-id>/SKILL.md` maps to `skill://<skill-id>/SKILL.md`.
2. Skill supporting files map to `skill://<skill-id>/<path>`. 2. Skill supporting files map to `skill://<skill-id>/<path>`.
3. Typed prompt components map to native MCP prompt names. 3. Declarative prompt documents map to native MCP prompt names.
4. General `docs/<path>.md` maps to `resource://docs/{path*}`. 4. General `docs/<path>.md` maps to `resource://docs/{path*}`.
The server publishes no tool projections of resources or prompts. The server publishes no tool projections of resources or prompts.
+18
View File
@@ -1,5 +1,23 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Provide a practical checklist and baseline template for authoring docs-first MCP modules and repository-specific Copilot instruction shims.
tags: [authoring, mcp, fastmcp, copilot, prompts, scaffolding]
arguments:
artifact_type:
description: Artifact type to create.
required: true
choices: [skill, prompt, shim]
artifact_id:
description: Lowercase kebab-case id for the module or shim.
required: true
goal:
description: One-sentence capability statement.
required: true
scope_glob:
description: Optional applyTo glob for shim outputs.
required: false
--- ---
# Authoring Bootstrap # Authoring Bootstrap
@@ -1,5 +1,23 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Research established patterns and design a high-level architecture for a new app or library with explicit tradeoffs and test strategy.
tags: [architecture, planning, greenfield, design, testing, prompts]
arguments:
scope_type:
description: Scope type to design.
required: true
choices: [app, library]
intent_document:
description: Optional full document describing goals and context.
required: false
problem_domain:
description: Problem domain and business goal.
required: false
constraints:
description: Runtime, deployment, and non-functional constraints.
required: false
--- ---
# Greenfield Architecture Planner # Greenfield Architecture Planner
@@ -1,5 +1,16 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.1.0"
description: Create a responsive sample page layout for a user-supplied domain and return paste-ready HTML and CSS for JSFiddle.
tags: [frontend, html, css, jsfiddle, layout, prototyping, prompts]
arguments:
domain:
description: Product, service, organization, or subject represented by the page.
required: true
layout_brief:
description: Optional page type, sections, priorities, or visual constraints.
required: false
--- ---
# JSFiddle Page Layout # JSFiddle Page Layout
@@ -1,5 +1,22 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Create one repository-specific thin shim instruction file that binds a file scope to a user-selected Personal MCP skill resource.
tags: [copilot, mcp, instructions, shims, prompts]
arguments:
apply_to_glob:
description: File glob scope for the shim applyTo field.
required: true
primary_skill_resource:
description: Primary native skill:// resource URI.
required: true
shim_title:
description: Optional human-readable instruction shim name.
required: false
companion_docs_page:
description: Optional relative companion documentation link.
required: false
--- ---
# MCP Consumer Repository Shim # MCP Consumer Repository Shim
@@ -1,5 +1,22 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Extract a user-selected component from a JSFiddle page layout and implement it as a reusable NiceGUI render function.
tags: [nicegui, components, frontend, refactoring, jsfiddle, prompts]
arguments:
component:
description: Visible label, semantic role, or selector identifying the component.
required: true
source_layout:
description: Optional source HTML and CSS.
required: false
target_location:
description: Optional target NiceGUI page, module, or package.
required: false
behavior_requirements:
description: Optional interactions, state, callbacks, or variations.
required: false
--- ---
# NiceGUI Component Extraction # NiceGUI Component Extraction
@@ -1,5 +1,23 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Fill scaffolded pytest methods with assertions, fixtures, and minimal test data while preserving reviewed structure.
tags: [pytest, testing, scaffolding, prompts]
arguments:
target_files:
description: Target test file paths under tests/.
required: true
stack:
description: Runtime stack type for fixture and marker choices.
required: true
choices: [pure-python, fastapi, sqlalchemy-sync, sqlalchemy-async, mixed]
strategy:
description: Optional minimal or comprehensive implementation preference.
required: false
marker_lane:
description: Optional pytest marker lane.
required: false
--- ---
# Pytest Fill Scaffold # Pytest Fill Scaffold
+18
View File
@@ -1,5 +1,23 @@
--- ---
icon: lucide/messages-square icon: lucide/messages-square
prompt:
version: "1.0.0"
description: Plan and optionally scaffold pytest file and class structure for selected Python modules.
tags: [pytest, testing, scaffolding, prompts]
arguments:
target_modules:
description: Target module paths under src/.
required: true
mode:
description: Whether to plan only or create scaffold files.
required: true
choices: [plan-only, scaffold]
path_strategy:
description: Optional src-to-tests path mapping preference.
required: false
naming_style:
description: Optional concise test naming preference.
required: false
--- ---
# Pytest Scaffold # Pytest Scaffold
+1
View File
@@ -6,6 +6,7 @@ dependencies = [
"fastapi>=0.133.0", "fastapi>=0.133.0",
"fastmcp==4.0.0b1", "fastmcp==4.0.0b1",
"pydantic-settings>=2", "pydantic-settings>=2",
"pyyaml>=6.0.2",
"python-json-logger>=4", "python-json-logger>=4",
"uvicorn[standard]>=0.34.0", "uvicorn[standard]>=0.34.0",
"zensical>=0.0.45", "zensical>=0.0.45",
@@ -1,45 +0,0 @@
from typing import Annotated
from typing import Literal
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="authoring",
version="1.0.0",
description=(
"Provide a practical checklist and baseline template for authoring docs-first MCP modules and "
"repository-specific Copilot instruction shims."
),
tags={"authoring", "mcp", "fastmcp", "copilot", "prompts", "scaffolding"},
)
def authoring(
artifact_type: Annotated[
Literal["skill", "prompt", "shim"],
Field(description="Artifact type to create."),
],
artifact_id: Annotated[
str,
Field(description="Lowercase kebab-case id for the module or shim."),
],
goal: Annotated[
str,
Field(description="One-sentence capability statement."),
],
scope_glob: Annotated[
str | None,
Field(description="Optional applyTo glob for shim outputs."),
] = None,
) -> str:
return render_prompt(
"authoring",
{
"artifact_type": artifact_type,
"artifact_id": artifact_id,
"goal": goal,
"scope_glob": scope_glob,
},
)
@@ -1,45 +0,0 @@
from typing import Annotated
from typing import Literal
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="greenfield-architecture",
version="1.0.0",
description=(
"Research established patterns and design a high-level architecture for a "
"new app or library with explicit tradeoffs and test strategy."
),
tags={"architecture", "planning", "greenfield", "design", "testing", "prompts"},
)
def greenfield_architecture(
scope_type: Annotated[
Literal["app", "library"],
Field(description="Scope type to design."),
],
intent_document: Annotated[
str | None,
Field(description="Optional full document describing goals and context."),
] = None,
problem_domain: Annotated[
str | None,
Field(description="Problem domain and business goal."),
] = None,
constraints: Annotated[
str | None,
Field(description="Runtime, deployment, and non-functional constraints."),
] = None,
) -> str:
return render_prompt(
"greenfield-architecture",
{
"scope_type": scope_type,
"intent_document": intent_document,
"problem_domain": problem_domain,
"constraints": constraints,
},
)
@@ -1,31 +0,0 @@
from typing import Annotated
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="jsfiddle-page-layout",
version="1.1.0",
description=(
"Create a responsive sample page layout for a user-supplied domain and return paste-ready HTML and CSS "
"for JSFiddle."
),
tags={"frontend", "html", "css", "jsfiddle", "layout", "prototyping", "prompts"},
)
def jsfiddle_page_layout(
domain: Annotated[
str,
Field(description="Product, service, organization, or subject represented by the page."),
],
layout_brief: Annotated[
str | None,
Field(description="Optional page type, sections, priorities, or visual constraints."),
] = None,
) -> str:
return render_prompt(
"jsfiddle-page-layout",
{"domain": domain, "layout_brief": layout_brief},
)
@@ -1,44 +0,0 @@
from typing import Annotated
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="mcp-consumer-repo-shim",
version="1.0.0",
description=(
"Create one repository-specific thin shim instruction file that binds a file scope to a user-selected "
"Personal MCP skill resource."
),
tags={"copilot", "mcp", "instructions", "shims", "prompts"},
)
def mcp_consumer_repo_shim(
apply_to_glob: Annotated[
str,
Field(description="File glob scope for the shim applyTo field."),
],
primary_skill_resource: Annotated[
str,
Field(description="Primary native skill:// resource URI."),
],
shim_title: Annotated[
str | None,
Field(description="Optional human-readable instruction shim name."),
] = None,
companion_docs_page: Annotated[
str | None,
Field(description="Optional relative companion documentation link."),
] = None,
) -> str:
return render_prompt(
"mcp-consumer-repo-shim",
{
"apply_to_glob": apply_to_glob,
"primary_skill_resource": primary_skill_resource,
"shim_title": shim_title,
"companion_docs_page": companion_docs_page,
},
)
@@ -1,44 +0,0 @@
from typing import Annotated
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="nicegui-component-extraction",
version="1.0.0",
description=(
"Extract a user-selected component from a JSFiddle page layout and implement it as a reusable NiceGUI "
"render function."
),
tags={"nicegui", "components", "frontend", "refactoring", "jsfiddle", "prompts"},
)
def nicegui_component_extraction(
component: Annotated[
str,
Field(description="Visible label, semantic role, or selector identifying the component."),
],
source_layout: Annotated[
str | None,
Field(description="Optional source HTML and CSS."),
] = None,
target_location: Annotated[
str | None,
Field(description="Optional target NiceGUI page, module, or package."),
] = None,
behavior_requirements: Annotated[
str | None,
Field(description="Optional interactions, state, callbacks, or variations."),
] = None,
) -> str:
return render_prompt(
"nicegui-component-extraction",
{
"component": component,
"source_layout": source_layout,
"target_location": target_location,
"behavior_requirements": behavior_requirements,
},
)
@@ -1,45 +0,0 @@
from typing import Annotated
from typing import Literal
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="pytest-fill-scaffold",
version="1.0.0",
description=(
"Fill scaffolded pytest methods with assertions, fixtures, and minimal test data while preserving reviewed "
"structure."
),
tags={"pytest", "testing", "scaffolding", "prompts"},
)
def pytest_fill_scaffold(
target_files: Annotated[
str,
Field(description="Target test file paths under tests/."),
],
stack: Annotated[
Literal["pure-python", "fastapi", "sqlalchemy-sync", "sqlalchemy-async", "mixed"],
Field(description="Runtime stack type for fixture and marker choices."),
],
strategy: Annotated[
str | None,
Field(description="Optional minimal or comprehensive implementation preference."),
] = None,
marker_lane: Annotated[
str | None,
Field(description="Optional pytest marker lane."),
] = None,
) -> str:
return render_prompt(
"pytest-fill-scaffold",
{
"target_files": target_files,
"stack": stack,
"strategy": strategy,
"marker_lane": marker_lane,
},
)
@@ -1,42 +0,0 @@
from typing import Annotated
from typing import Literal
from fastmcp.prompts import prompt
from pydantic import Field
from personal_mcp.prompts.content import render_prompt
@prompt(
name="pytest-scaffold",
version="1.0.0",
description="Plan and optionally scaffold pytest file and class structure for selected Python modules.",
tags={"pytest", "testing", "scaffolding", "prompts"},
)
def pytest_scaffold(
target_modules: Annotated[
str,
Field(description="Target module paths under src/."),
],
mode: Annotated[
Literal["plan-only", "scaffold"],
Field(description="Whether to plan only or create scaffold files."),
],
path_strategy: Annotated[
str | None,
Field(description="Optional src-to-tests path mapping preference."),
] = None,
naming_style: Annotated[
str | None,
Field(description="Optional concise test naming preference."),
] = None,
) -> str:
return render_prompt(
"pytest-scaffold",
{
"target_modules": target_modules,
"mode": mode,
"path_strategy": path_strategy,
"naming_style": naming_style,
},
)
+46 -9
View File
@@ -1,29 +1,66 @@
import re import re
from importlib.resources import files from importlib.resources import files
from importlib.resources.abc import Traversable
from typing import Any from typing import Any
import yaml
from pydantic import ValidationError
from personal_mcp.prompts.models import PromptDefinition
from personal_mcp.prompts.models import PromptMetadata
_PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$") _PROMPT_ID_RE = re.compile(r"^[a-z][a-z0-9-]*$")
_FRONTMATTER_RE = re.compile(r"\A---\r?\n.*?\r?\n---\r?\n?", re.DOTALL) _FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<yaml>.*?)\r?\n---(?:\r?\n|$)", re.DOTALL)
_PLACEHOLDER_RE = re.compile(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}") _PLACEHOLDER_RE = re.compile(r"\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}")
def render_prompt(prompt_id: str, arguments: dict[str, Any]) -> str: def load_prompt_definition(prompt_id: str, resource: Traversable) -> PromptDefinition:
if not _PROMPT_ID_RE.fullmatch(prompt_id): if not _PROMPT_ID_RE.fullmatch(prompt_id):
raise ValueError("prompt_id must be lowercase kebab-case") raise ValueError(f"prompt id must be lowercase kebab-case: {prompt_id!r}")
resource = files("personal_mcp").joinpath("docs", "prompts", prompt_id, "PROMPT.md")
if not resource.is_file(): if not resource.is_file():
raise FileNotFoundError(f"prompt document does not exist: {prompt_id}") raise FileNotFoundError(f"prompt document does not exist: {prompt_id}")
content = _FRONTMATTER_RE.sub("", resource.read_text(encoding="utf-8"), count=1) raw = resource.read_text(encoding="utf-8")
placeholders = set(_PLACEHOLDER_RE.findall(content)) match = _FRONTMATTER_RE.match(raw)
argument_names = set(arguments) if match is None:
raise ValueError(f"prompt document must start with YAML frontmatter: {prompt_id}")
try:
frontmatter = yaml.safe_load(match.group("yaml"))
except yaml.YAMLError as error:
raise ValueError(f"invalid YAML frontmatter for prompt {prompt_id!r}: {error}") from error
if not isinstance(frontmatter, dict):
raise TypeError(f"prompt frontmatter must be a mapping: {prompt_id}")
if "prompt" not in frontmatter:
raise ValueError(f"prompt frontmatter is missing the 'prompt' block: {prompt_id}")
try:
metadata = PromptMetadata.model_validate(frontmatter["prompt"])
except ValidationError as error:
raise ValueError(f"invalid metadata for prompt {prompt_id!r}: {error}") from error
body = raw[match.end() :]
placeholders = set(_PLACEHOLDER_RE.findall(body))
argument_names = set(metadata.arguments)
if placeholders != argument_names: if placeholders != argument_names:
missing = sorted(argument_names - placeholders) missing = sorted(argument_names - placeholders)
unknown = sorted(placeholders - argument_names) unknown = sorted(placeholders - argument_names)
raise ValueError(
f"prompt placeholders do not match arguments for {prompt_id!r}; missing={missing}, unknown={unknown}"
)
return PromptDefinition(prompt_id=prompt_id, metadata=metadata, body=body)
def render_prompt(prompt_id: str, arguments: dict[str, Any]) -> str:
resource = files("personal_mcp").joinpath("docs", "prompts", prompt_id, "PROMPT.md")
definition = load_prompt_definition(prompt_id, resource)
argument_names = set(definition.metadata.arguments)
if set(arguments) != argument_names:
missing = sorted(argument_names - set(arguments))
unknown = sorted(set(arguments) - argument_names)
raise ValueError(f"prompt placeholders do not match arguments; missing={missing}, unknown={unknown}") raise ValueError(f"prompt placeholders do not match arguments; missing={missing}, unknown={unknown}")
rendered = content rendered = definition.body
for name, value in arguments.items(): for name, value in arguments.items():
replacement = "Not provided" if value is None else str(value) replacement = "Not provided" if value is None else str(value)
rendered = rendered.replace(f"{{{{{name}}}}}", replacement) rendered = rendered.replace(f"{{{{{name}}}}}", replacement)
+115
View File
@@ -0,0 +1,115 @@
import re
from typing import Self
from fastmcp.exceptions import PromptError
from fastmcp.prompts import Prompt
from fastmcp.prompts import PromptArgument
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from pydantic import model_validator
_ARGUMENT_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class PromptArgumentDefinition(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
description: str = Field(min_length=1)
required: bool
choices: tuple[str, ...] | None = None
@model_validator(mode="after")
def validate_choices(self) -> Self:
if self.choices is None:
return self
if not self.choices:
raise ValueError("choices must contain at least one value")
if any(not choice for choice in self.choices):
raise ValueError("choices must not contain empty values")
if len(set(self.choices)) != len(self.choices):
raise ValueError("choices must not contain duplicate values")
return self
class PromptMetadata(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
version: str = Field(min_length=1)
description: str = Field(min_length=1)
tags: frozenset[str] = Field(min_length=1)
arguments: dict[str, PromptArgumentDefinition]
@model_validator(mode="after")
def validate_arguments(self) -> Self:
invalid_names = [name for name in self.arguments if not _ARGUMENT_NAME_RE.fullmatch(name)]
if invalid_names:
raise ValueError(f"argument names must be valid identifiers: {invalid_names}")
return self
class PromptDefinition(BaseModel):
model_config = ConfigDict(frozen=True)
prompt_id: str
metadata: PromptMetadata
body: str
class MarkdownPrompt(Prompt):
body: str = Field(exclude=True)
definitions: dict[str, PromptArgumentDefinition] = Field(exclude=True)
@classmethod
def from_definition(cls, definition: PromptDefinition) -> Self:
metadata = definition.metadata
return cls(
name=definition.prompt_id,
version=metadata.version,
description=metadata.description,
tags=set(metadata.tags),
arguments=[
PromptArgument(
name=name,
description=(
argument.description
if argument.choices is None
else f"{argument.description}\n\nAccepted values: {', '.join(argument.choices)}."
),
required=argument.required,
)
for name, argument in metadata.arguments.items()
],
body=definition.body,
definitions=metadata.arguments,
)
async def render(self, arguments: dict[str, object] | None = None) -> str:
provided = arguments or {}
declared_names = set(self.definitions)
unknown = sorted(set(provided) - declared_names)
if unknown:
raise PromptError(f"Unknown arguments for prompt {self.name!r}: {unknown}")
missing = sorted(
name for name, definition in self.definitions.items() if definition.required and name not in provided
)
if missing:
raise PromptError(f"Missing required arguments for prompt {self.name!r}: {missing}")
normalized: dict[str, object] = {}
for name, definition in self.definitions.items():
value = provided.get(name)
if value is not None and not isinstance(value, str):
raise PromptError(f"Argument {name!r} for prompt {self.name!r} must be a string")
if value is not None and definition.choices is not None and value not in definition.choices:
raise PromptError(
f"Argument {name!r} for prompt {self.name!r} must be one of {list(definition.choices)}"
)
normalized[name] = value
rendered = self.body
for name, value in normalized.items():
replacement = "Not provided" if value is None else str(value)
rendered = rendered.replace(f"{{{{{name}}}}}", replacement)
return rendered
+31 -7
View File
@@ -1,10 +1,34 @@
from pathlib import Path from collections.abc import Sequence
from importlib.resources import files
from importlib.resources.abc import Traversable
from fastmcp.server.providers import FileSystemProvider from fastmcp.prompts import Prompt
from fastmcp.server.providers import Provider
from personal_mcp.prompts.content import load_prompt_definition
from personal_mcp.prompts.models import MarkdownPrompt
def create_prompts_provider() -> FileSystemProvider: class MarkdownPromptsProvider(Provider):
components_root = Path(__file__).parent / "components" def __init__(self, root: Traversable) -> None:
if not components_root.is_dir(): super().__init__()
raise FileNotFoundError(f"prompt components root does not exist or is not a directory: {components_root}") if not root.is_dir():
return FileSystemProvider(root=components_root, reload=False) raise FileNotFoundError(f"prompts root does not exist or is not a directory: {root}")
self._root = root
async def _list_prompts(self) -> Sequence[Prompt]:
prompts: list[Prompt] = []
for directory in sorted(self._root.iterdir(), key=lambda child: child.name):
if not directory.is_dir():
continue
document = directory.joinpath("PROMPT.md")
if not document.is_file():
continue
definition = load_prompt_definition(directory.name, document)
prompts.append(MarkdownPrompt.from_definition(definition))
return prompts
def create_prompts_provider(root: Traversable | None = None) -> MarkdownPromptsProvider:
prompts_root = root or files("personal_mcp").joinpath("docs", "prompts")
return MarkdownPromptsProvider(prompts_root)
-5
View File
@@ -1,5 +0,0 @@
"""FastMCP provider composition for personal MCP skills."""
from .provider import create_skills_provider
__all__ = ["create_skills_provider"]
+32
View File
@@ -1,5 +1,8 @@
from pathlib import Path
import pytest import pytest
from personal_mcp.prompts.content import load_prompt_definition
from personal_mcp.prompts.content import render_prompt from personal_mcp.prompts.content import render_prompt
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@@ -23,3 +26,32 @@ class TestPromptContentRenderer:
def test_rejects_invalid_prompt_id(self) -> None: def test_rejects_invalid_prompt_id(self) -> None:
with pytest.raises(ValueError, match="lowercase kebab-case"): with pytest.raises(ValueError, match="lowercase kebab-case"):
render_prompt("../outside", {}) render_prompt("../outside", {})
def test_rejects_missing_prompt_metadata(self, tmp_path: Path) -> None:
document = tmp_path / "PROMPT.md"
document.write_text("---\nicon: lucide/messages-square\n---\n\n# Body\n", encoding="utf-8")
with pytest.raises(ValueError, match="missing the 'prompt' block"):
load_prompt_definition("demo", document)
def test_rejects_unknown_prompt_metadata(self, tmp_path: Path) -> None:
document = tmp_path / "PROMPT.md"
document.write_text(
"---\nprompt: {version: '1', description: Demo, tags: [demo], arguments: {}, unknown: true}\n"
"---\n\n# Body\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="Extra inputs are not permitted"):
load_prompt_definition("demo", document)
def test_rejects_declared_placeholder_drift(self, tmp_path: Path) -> None:
document = tmp_path / "PROMPT.md"
document.write_text(
"---\nprompt: {version: '1', description: Demo, tags: [demo], arguments: "
"{topic: {description: Topic, required: true}}}\n---\n\n# Body\n",
encoding="utf-8",
)
with pytest.raises(ValueError, match="placeholders do not match arguments"):
load_prompt_definition("demo", document)
+70 -1
View File
@@ -1,6 +1,9 @@
from pathlib import Path
import pytest import pytest
from fastmcp import Client from fastmcp import Client
from fastmcp import FastMCP from fastmcp import FastMCP
from fastmcp.exceptions import PromptError
from personal_mcp.prompts import create_prompts_provider from personal_mcp.prompts import create_prompts_provider
@@ -17,7 +20,20 @@ EXPECTED_PROMPTS = {
} }
class TestPromptFileSystemProvider: def write_prompt(document: Path, *, description: str, heading: str = "Demo") -> None:
document.parent.mkdir(parents=True, exist_ok=True)
document.write_text(
"---\n"
f"prompt: {{version: '1.0.0', description: {description!r}, tags: [demo], arguments: "
"{kind: {description: 'Kind to render.', required: true, choices: [first, second]}, "
"note: {description: 'Optional note.', required: false}}}\n"
"---\n\n"
f"# {heading}\n\nKind: {{{{kind}}}}\n\nNote: {{{{note}}}}\n",
encoding="utf-8",
)
class TestMarkdownPromptsProvider:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_discovers_exact_authored_set(self) -> None: async def test_discovers_exact_authored_set(self) -> None:
mcp = FastMCP("prompts-test") mcp = FastMCP("prompts-test")
@@ -47,6 +63,59 @@ class TestPromptFileSystemProvider:
) )
required = {argument.name for argument in authoring.arguments or [] if argument.required} required = {argument.name for argument in authoring.arguments or [] if argument.required}
artifact_type = next(argument for argument in authoring.arguments or [] if argument.name == "artifact_type")
assert required == {"artifact_type", "artifact_id", "goal"} assert required == {"artifact_type", "artifact_id", "goal"}
assert artifact_type.description == "Artifact type to create.\n\nAccepted values: skill, prompt, shim."
assert result.messages assert result.messages
assert "`artifact_id`: demo-skill" in result.messages[0].content.text assert "`artifact_id`: demo-skill" in result.messages[0].content.text
@pytest.mark.asyncio
async def test_enforces_required_arguments_and_choices(self) -> None:
provider = create_prompts_provider()
prompt = await provider.get_prompt("authoring")
assert prompt is not None
with pytest.raises(PromptError, match="Missing required arguments"):
await prompt.render({"artifact_type": "skill"})
with pytest.raises(PromptError, match="must be one of"):
await prompt.render(
{
"artifact_type": "unsupported",
"artifact_id": "demo-skill",
"goal": "Demonstrate validation.",
}
)
@pytest.mark.asyncio
async def test_live_loads_edits_without_python_components(self, tmp_path: Path) -> None:
prompts_root = tmp_path / "prompts"
prompts_root.mkdir()
provider = create_prompts_provider(prompts_root)
mcp = FastMCP("prompts-test")
mcp.add_provider(provider)
async with Client(mcp) as client:
assert await client.list_prompts() == []
document = prompts_root / "dynamic-demo" / "PROMPT.md"
write_prompt(document, description="Initial description")
prompts = await client.list_prompts()
assert [prompt.name for prompt in prompts] == ["dynamic-demo"]
assert prompts[0].description == "Initial description"
result = await client.get_prompt("dynamic-demo", {"kind": "first"})
assert "# Demo" in result.messages[0].content.text
assert "Note: Not provided" in result.messages[0].content.text
write_prompt(document, description="Updated description", heading="Updated")
prompts = await client.list_prompts()
assert prompts[0].description == "Updated description"
result = await client.get_prompt("dynamic-demo", {"kind": "second", "note": "ready"})
assert "# Updated" in result.messages[0].content.text
assert "Note: ready" in result.messages[0].content.text
document.unlink()
document.parent.rmdir()
assert await client.list_prompts() == []
Generated
+2
View File
@@ -1171,6 +1171,7 @@ dependencies = [
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-json-logger" }, { name = "python-json-logger" },
{ name = "pyyaml" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
{ name = "zensical" }, { name = "zensical" },
] ]
@@ -1196,6 +1197,7 @@ requires-dist = [
{ name = "fastmcp", specifier = "==4.0.0b1" }, { name = "fastmcp", specifier = "==4.0.0b1" },
{ name = "pydantic-settings", specifier = ">=2" }, { name = "pydantic-settings", specifier = ">=2" },
{ name = "python-json-logger", specifier = ">=4" }, { name = "python-json-logger", specifier = ">=4" },
{ name = "pyyaml", specifier = ">=6.0.2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.34.0" },
{ name = "zensical", specifier = ">=0.0.45" }, { name = "zensical", specifier = ">=0.0.45" },
] ]