Dynamic skills¶
Async extension handlers can read current instructions from a database or
generate them for each request. Skills.from_directory() provides a startup
snapshot; it does not watch files or reload their contents.
Choose a manifest strategy¶
Changing content does not by itself require a dynamic manifest. If you can keep resource bytes consistent with their published hashes, return the full manifest with sizes and digests. An immutable, versioned snapshot is one way to maintain that consistency.
When stable digests cannot be published, return resources: "dynamic" in each
skill entry. Clients may decline to load these skills. The entry’s frontmatter
must still match the YAML frontmatter in the returned SKILL.md. These are
requirements of the
Skills specification.
Use ttlMs: 0 for results that must be fetched again, and keep
cacheScope: "private" for caller-specific content. CacheableResult supplies
these defaults on 2026-07-28.
Generate content at a fixed URI¶
This example registers one skill whose instructions change while the server runs. The source is an injected object; replace it with an async database provider when needed. No filesystem or YAML parser is required here.
from pydantic import BaseModel
from aiohttp_tiny_mcp import Extension, Registry
from aiohttp_tiny_mcp.protocol.core import Failure, FailureKind, Rejected
from aiohttp_tiny_mcp.protocol.models import CacheableResult, ListParams, ReadResourceParams
from aiohttp_tiny_mcp.testing import connect
URI = "skill://deploy/SKILL.md"
DESCRIPTION = "Deploy using current release instructions."
class Nothing(BaseModel):
pass
class Instructions:
def __init__(self, text: str):
self.text = text
class Listing(CacheableResult):
skills: list[dict]
class Detail(CacheableResult):
skill: dict
def entry() -> dict:
return {
"uri": URI,
"frontmatter": {"name": "deploy", "description": DESCRIPTION},
"resources": "dynamic",
}
extension = Extension("io.modelcontextprotocol/skills")
@extension.method("skills/list")
async def list_skills(args: ListParams) -> Listing:
if args.cursor is not None:
raise Rejected(Failure(FailureKind.INVALID_PARAMS, "Unknown cursor"))
return Listing(skills=[entry()])
@extension.method("skills/get")
async def get_skill(args: ReadResourceParams) -> Detail:
if args.uri != URI:
raise Rejected(Failure(FailureKind.INVALID_PARAMS, "Unknown skill"))
return Detail(skill=entry())
@extension.resource(URI, mime_type="text/markdown", cache_ttl_ms=0, cache_scope="private")
async def read_skill(args: Nothing, source: Instructions) -> str:
return f"---\nname: deploy\ndescription: {DESCRIPTION}\n---\n{source.text}\n"
source = Instructions("Deploy the blue release using the deployment tool.")
registry = Registry("live-skills", "1.0")
registry.provide_instance(source)
registry.extension(extension)
async with connect(registry, adapter="2026-07-28") as client:
discovery = await client.initialize()
assert "io.modelcontextprotocol/skills" in discovery["capabilities"]["extensions"]
listing = await client.request_method("skills/list")
assert listing["skills"][0]["resources"] == "dynamic"
assert listing["ttlMs"] == 0
assert listing["cacheScope"] == "private"
detail = await client.request_method("skills/get", {"uri": URI})
assert detail["skill"] == listing["skills"][0]
first = await client.read_resource(URI)
assert "blue release" in first.contents[0].text
source.text = "Deploy the green release using the deployment tool."
second = await client.read_resource(URI)
assert "green release" in second.contents[0].text
The registration is a snapshot of declarations, not of callback results.
read_skill runs on each read and sees the current injected source. Supporting
files can use additional extension.resource() callbacks. Handlers may also
receive Exchange or an authenticated Principal through dependency injection.
Apply the same access rules to listings, skill details, and file reads.
See handler permissions in the authentication guide.
Install this extension instead of Skills.from_directory() in the same
registry. Both use io.modelcontextprotocol/skills, and duplicate extension
identifiers are rejected. To combine static and dynamic skills, serve both
through one set of listing, detail, and resource handlers.
The models in aiohttp_tiny_mcp.extensions.skills currently describe static manifests.
For resources: "dynamic", use your own CacheableResult subclasses as above,
or dictionaries that include ttlMs and cacheScope.
Older clients¶
The same callback is available at
mcp-extensions://io.modelcontextprotocol/skills/deploy/SKILL.md on older
revisions. Find it through resources/list or the extension’s manifest.json.
Continue the example to verify that the legacy resource also reads current data:
legacy_uri = "mcp-extensions://io.modelcontextprotocol/skills/deploy/SKILL.md"
async with connect(registry, adapter="2025-11-25") as client:
assert legacy_uri in {resource.uri for resource in await client.list_resources()}
source.text = "Check the green release with the health tool."
document = await client.read_resource(legacy_uri)
assert "health tool" in document.contents[0].text
See List skills on older MCP revisions
for pagination and manifest examples. Older clients receive ordinary resources;
read mcp-extensions://io.modelcontextprotocol/skills/skills/list to receive the
current entries, including resources: "dynamic". This read runs the same handler
as native skills/list. In the viewer, select that URI under Resources
and click Read. Older revisions do not have native extension views.
Changing the catalog¶
The example changes content at an existing URI. Changing which skills exist requires additional routing and discovery work:
Custom
skills/listandskills/gethandlers can query a live catalog.Extension.resource()accepts fixed URIs and templates such asskill://{name}/SKILL.md. Register templates to route files from a live catalog.Registry.resource()supports templates, but resources registered there do not acquire an extension’s legacy prefix automatically. Their templates are listed throughresources/templates/list; concrete instances are not enumerated.The legacy extension manifest captures route declarations at installation. Its
methodResourcespoints to live handlers; it does not snapshot their catalogue results.
There is no built-in async Skills provider with list, get, and read yet.
For an unbounded live catalog, register list/get handlers with
extension.method() and file templates with extension.resource(). The method
resources are generated automatically. For a finite catalog, register file URIs at
startup and let their handlers return current content.