Agent Skills (SEP-2640) & SkillController¶
litestar-mcp supports two complementary skill abstractions that bridge MCP instruction bundles, executable tools, and multi-agent runtimes:
- Agent Skills over MCP (
io.modelcontextprotocol/skills, SEP-2640): Versioned, SHA-256 digest-lockedSKILL.mdinstruction bundles and supporting reference files served throughskills/list,skills/get,resources/directory/read, andresources/read(skill://<name>/<relative_path>). Configured viaMCPConfig(skills=MCPSkillsConfig(...)). - Class-Based
SkillController: A LitestarControllersubclass (extendingPromptController) that unifies@toolmethods,@promptmethods, groundinginstructions, Litestarguards, andProvide/FromDishka[T]dependency injection into a single reusable unit forLitestarMCP,Agent(skills=[...]), and A2AAgentSkillcard projection.
| Concept | Primary Class / Config | Wire Exposure | Typical Use Case |
|---|---|---|---|
MCP SKILL.md Bundles |
MCPSkillsConfig, Skill, SkillFile, FilesystemSkillSource, SkillSource |
skills/list, skills/get, resources/directory/read, resources/read (skill://...) |
Publishing static or tenant-scoped runbooks, coding standards, and auxiliary scripts that MCP clients read on demand. |
Class-Based SkillController |
SkillController (@tool, @prompt, instructions) |
tools/list, tools/call, prompts/list, prompts/get, plus Agent(skills=[...]) |
Packaging executable domain tools, prompt templates, and system instructions together with shared controller guards and DI. |
A2A AgentSkill Metadata |
SkillController.to_agent_skill() / A2AConfig |
GET /.well-known/agent-card.json (skills array) |
Advertising high-level agent capabilities to remote A2A 1.0 peers. |
Enabling Static Filesystem Skills (MCPSkillsConfig)¶
Pass an MCPSkillsConfig instance with paths=[...] (Sequence[Path]) to MCPConfig:
"""Snippet: serving Agent Skills over MCP from a directory."""
from pathlib import Path
from litestar import Litestar
from litestar_mcp import LitestarMCP, MCPConfig, MCPSkillsConfig
SKILLS_ROOT = Path(__file__).resolve().parents[1] / "skills"
def build() -> "Litestar":
# start-example
config = MCPConfig(skills=MCPSkillsConfig(paths=[SKILLS_ROOT]))
app = Litestar(plugins=[LitestarMCP(config)])
# end-example
return app
MCPSkillsConfig Options¶
| Field | Type | Default | Description |
|---|---|---|---|
paths |
Sequence[Path] |
() |
Directories whose immediate child folders containing a SKILL.md file are loaded at startup via FilesystemSkillSource. Pass pathlib.Path instances in a sequence (never a bare str). |
sources |
Sequence[SkillSource] |
() |
Async, request-aware SkillSource instances evaluated after paths. At least one of paths or sources must be non-empty. |
directory_read |
bool |
True |
Whether to advertise "directoryRead": true under capabilities.extensions["io.modelcontextprotocol/skills"] and serve resources/directory/read. |
max_files_per_skill |
int |
512 |
Maximum files (including SKILL.md) allowed inside a single filesystem skill folder before startup fails. |
max_bytes_per_skill |
int |
16_777_216 (16 MiB) |
Maximum total byte size across all files in a single filesystem skill folder before startup fails. |
Filesystem Directory Layout & Startup Validation¶
Each directory in MCPSkillsConfig.paths is scanned one level deep for candidate skill directories:
- Candidate Selection: Only immediate child directories containing a regular
SKILL.mdfile are treated as skills. Child folders withoutSKILL.md, dot-prefixed child folders, and symlinked child folders are skipped. - Recursive File Collection: Once a skill folder is selected, all regular files inside it are collected recursively (
SKILL.md,reference.md,scripts/verify.py), hashed with SHA-256 (sha256:<64-lowercase-hex>), and assigned canonicalskill://<name>/<relative_path>URIs. Dotfiles and symlinked files or subdirectories inside a skill folder are excluded from the manifest. - Startup Validation (
ImproperlyConfiguredException):FilesystemSkillSourcevalidates every skill eagerly at startup and raisesImproperlyConfiguredExceptionif: - A configured entry in
pathsdoes not exist or is not a directory. SKILL.mdis a symlink rather than a regular file.SKILL.mdlacks a valid YAML frontmatter block (--- ... ---) or does not parse to a mapping.- Frontmatter
nameordescriptionis missing, empty, or not astr. - Frontmatter
namedoes not match the skill directory's folder name. - Two skill folders across
pathsdeclare the samename. - A skill exceeds
max_files_per_skillormax_bytes_per_skill.
Wire Methods & SHA-256 Digest Verification¶
When MCPConfig.skills is configured, server/discover and initialize advertise "io.modelcontextprotocol/skills": {"directoryRead": true} under capabilities.extensions, and the router exposes:
skills/list: Returns paginated skill manifest entries (uri,frontmatter,resources). Each skill entry is atomic—a skill'sresourcesarray is never split across pages.skills/get: Accepts{"uri": "skill://<name>/SKILL.md"}(or"skill://<name>") and returns{"skill": {...}}.resources/directory/read: Accepts{"uri": "skill://<name>"}or{"uri": "skill://<name>/<subdir>"}(without a trailing slash) and lists direct child files and immediate subdirectories (mimeType: "inode/directory"). Only registered whendirectory_read=True.resources/read: Reads individual skill files by theirskill://<name>/<relative_path>URI (and also lists visible skill files inresources/list). Everyresources/readcall verifies the returned bytes againstfile.sizeandfile.digest(verify_skill_file); if a file on disk was modified after startup,SkillIntegrityErroris raised and mapped to JSON-RPCINTERNAL_ERROR(-32603).
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"skills": [
{
"uri": "skill://hello-skill/SKILL.md",
"frontmatter": {
"name": "hello-skill",
"description": "Greets the user and explains how this Litestar server exposes skills."
},
"resources": [
{
"uri": "skill://hello-skill/SKILL.md",
"name": "SKILL.md",
"mimeType": "text/markdown",
"size": 169,
"digest": "sha256:733c1d9526318ff7dbd07b507eb711ffd4397ea64534cee5c7f6f9a59fcbda12"
},
{
"uri": "skill://hello-skill/reference.md",
"name": "reference.md",
"mimeType": "text/markdown",
"size": 111,
"digest": "sha256:50d4b6470fcf7748340eae1e76628a3da46c96080ec78bec164b379794558f2d"
}
]
}
]
}
}
Dynamic, Request-Aware SkillSource Protocol¶
Applications that load skills from a database, object store, or tenant-scoped registry can implement the @runtime_checkable SkillSource protocol and pass instances to MCPSkillsConfig(sources=[...]):
async def list_skills(self, request: Request[Any, Any, Any], /) -> Sequence[Skill]: Return the skills visible torequest.async def get_skill(self, request: Request[Any, Any, Any], name: str, /) -> Skill | None: Return the namedSkillif visible torequest, orNone.async def read_file(self, request: Request[Any, Any, Any], skill: Skill, file: SkillFile, /) -> bytes: Return the raw bytes forfilewhenfile.contentisNone.
Constructing In-Memory Skills (Skill.build, Skill.from_content, SkillFile.from_content)¶
Skill.build(name, description, body="", *, metadata=None, files=None): Renders YAML frontmatter aroundbodyand computes SHA-256 digests and byte sizes forSKILL.mdand any auxiliaryfiles.Skill.from_content(skill_md, files=None): Parses rawSKILL.mdmarkdown containing frontmatter and attaches optional auxiliary files.SkillFile.from_content(skill_name, relative_path, content): Computessize,sha256:<hex>digest,mime_type, and canonicalskill://urifromcontent. To serve a large auxiliary file lazily on demand viasource.read_file(...)while keeping its SHA-256 digest locked in the manifest, usedataclasses.replace(SkillFile.from_content(...), content=None).
"""Dynamic, request-aware MCP skills example.
Demonstrates composing a static ``FilesystemSkillSource`` with a dynamic
``WorkspaceSkillSource`` that constructs skills in memory with ``Skill.build``,
filters visibility by the incoming ``X-Workspace-Group`` request header, and
lazily serves a secondary ``runbook.md`` file via ``read_file``.
"""
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "litestar[standard]>=2.0",
# "litestar-mcp",
# "uvicorn",
# ]
# ///
from dataclasses import replace
from pathlib import Path
from typing import Any
from litestar import Litestar, Request
from litestar_mcp import (
LitestarMCP,
MCPConfig,
MCPSkillsConfig,
Skill,
SkillFile,
)
SKILLS_ROOT = Path(__file__).resolve().parents[1] / "skills"
class WorkspaceSkillSource:
"""In-memory skill source scoped by the ``X-Workspace-Group`` header."""
def __init__(self) -> "None":
self._runbook_bytes = (
b"# Incident Runbook\n\n"
b"1. Verify service health indicators.\n"
b"2. Check deployment rollouts for the active workspace.\n"
)
lazy_runbook = replace(
SkillFile.from_content("platform-runbook", "runbook.md", self._runbook_bytes),
content=None,
)
self._skills: dict[str, tuple[str, Skill]] = {
"platform-runbook": (
"platform",
Skill.build(
name="platform-runbook",
description="Operational runbook for the platform engineering group.",
body="Follow the attached runbook when triaging workspace incidents.",
files={"runbook.md": lazy_runbook},
),
),
"analytics-playbook": (
"analytics",
Skill.build(
name="analytics-playbook",
description="Query optimization playbook for the analytics group.",
body="Inspect partition pruning before escalating slow warehouse jobs.",
),
),
}
async def list_skills(self, request: "Request[Any, Any, Any]") -> "list[Skill]":
"""Return only the skills matching the caller's workspace group."""
group = self._caller_group(request)
return [skill for required_group, skill in self._skills.values() if required_group == group]
async def get_skill(
self,
request: "Request[Any, Any, Any]",
name: "str",
) -> "Skill | None":
"""Resolve a single skill by name when permitted for the caller's group."""
entry = self._skills.get(name)
if entry is None:
return None
required_group, skill = entry
if required_group != self._caller_group(request):
return None
return skill
async def read_file(
self,
request: "Request[Any, Any, Any]",
skill: "Skill",
file: "SkillFile",
) -> "bytes":
"""Serve ``SKILL.md`` from memory and ``runbook.md`` on demand."""
del request
if file.content is not None:
return file.content
if skill.name == "platform-runbook" and file.relative_path == "runbook.md":
return self._runbook_bytes
raise FileNotFoundError(file.relative_path)
def _caller_group(self, request: "Request[Any, Any, Any] | None") -> "str":
if request is None:
return "public"
return request.headers.get("x-workspace-group", "public").strip().lower()
def build_app() -> "Litestar":
"""Construct a Litestar app combining filesystem and dynamic skill sources."""
mcp_config = MCPConfig(
name="Dynamic Skills API",
skills=MCPSkillsConfig(paths=[SKILLS_ROOT], sources=[WorkspaceSkillSource()]),
)
return Litestar(plugins=[LitestarMCP(mcp_config)])
app = build_app()
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="127.0.0.1", port=8000)
When both paths and sources are configured, SkillCatalog places FilesystemSkillSource(paths) first, followed by sources in sequence. On a duplicate skill name across sources, the earlier source in the sequence wins.
Cache Scope for Caller-Filtered Skills
Keep MCPConfig.cache_scope="private" (the default) whenever a SkillSource filters skills or file contents by request.headers, request.user, or tenant context so shared HTTP caches never mix responses across callers.
Class-Based SkillController¶
While MCPSkillsConfig serves SKILL.md document bundles over resources/*, SkillController lets you package executable @tool methods, @prompt methods, system instructions, Litestar guards, and dependencies into a single Python class:
class BaristaSkillController(SkillController):
"""Barista skill providing beverage menu lookup and order preparation tools."""
name = "barista"
description = "Specialized skills for querying menu items and preparing coffee orders"
instructions = (
"Barista Operational Grounding:\n"
"- Suggest seasonal single-origin roasts when asked for recommendations.\n"
"- Confirm milk alternatives (oat, almond, soy) before finalizing espresso drinks.\n"
"- Never accept orders for items outside the published menu.\n"
)
def __init__(self, owner: Any = None) -> None:
"""Initialize the barista skill with catalog menu and empty order list."""
super().__init__(owner)
self.menu: dict[str, dict[str, Any]] = {
"espresso": {"price": 3.50, "roast": "Ethiopian Yirgacheffe", "decaf_available": True},
"flat_white": {"price": 4.75, "roast": "Guatemalan Antigua", "decaf_available": True},
"cold_brew": {"price": 5.00, "roast": "Colombian Huila", "decaf_available": False},
}
self.orders: list[dict[str, Any]] = []
@tool
def get_menu(self) -> dict[str, dict[str, Any]]:
"""Retrieve available coffee drinks and pricing."""
return self.menu
@tool
def place_order(self, beverage: str, milk: str = "whole", shots: int = 2) -> dict[str, Any]:
"""Place a customized beverage order."""
bev = beverage.lower().replace(" ", "_")
if bev not in self.menu:
return {"status": "error", "message": f"'{beverage}' is not on the menu."}
order_record = {
"order_id": len(self.orders) + 1,
"beverage": bev,
"milk": milk,
"shots": shots,
"price": self.menu[bev]["price"],
"status": "in_queue",
}
self.orders.append(order_record)
return {"status": "success", "order": order_record}
A SkillController can be mounted in three places simultaneously:
- On
Litestar(route_handlers=[BaristaSkillController])orMCPConfig(skill_controllers=[BaristaSkillController]): Registers every@toolmethod intools/list/tools/calland every@promptmethod inprompts/list/prompts/get(namespaced underprefixorname), protected by the controller'sguardsanddependencies. - On
Agent(skills=[BaristaSkillController]): Injects the controller'sinstructionsinto the agent's system prompt and binds all@toolmethods to the agent's toolset with full DI support. - On
LitestarA2A(to_agent_skill()): Projectsid,name,description,tags, andexamplesinto the A2AAgentCardserved at/.well-known/agent-card.json.