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
@@ -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
from importlib.resources import files
from importlib.resources.abc import Traversable
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-]*$")
_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_]*)\}\}")
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):
raise ValueError("prompt_id must be lowercase kebab-case")
resource = files("personal_mcp").joinpath("docs", "prompts", prompt_id, "PROMPT.md")
raise ValueError(f"prompt id must be lowercase kebab-case: {prompt_id!r}")
if not resource.is_file():
raise FileNotFoundError(f"prompt document does not exist: {prompt_id}")
content = _FRONTMATTER_RE.sub("", resource.read_text(encoding="utf-8"), count=1)
placeholders = set(_PLACEHOLDER_RE.findall(content))
argument_names = set(arguments)
raw = resource.read_text(encoding="utf-8")
match = _FRONTMATTER_RE.match(raw)
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:
missing = sorted(argument_names - placeholders)
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}")
rendered = content
rendered = definition.body
for name, value in arguments.items():
replacement = "Not provided" if value is None else str(value)
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:
components_root = Path(__file__).parent / "components"
if not components_root.is_dir():
raise FileNotFoundError(f"prompt components root does not exist or is not a directory: {components_root}")
return FileSystemProvider(root=components_root, reload=False)
class MarkdownPromptsProvider(Provider):
def __init__(self, root: Traversable) -> None:
super().__init__()
if not root.is_dir():
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"]