Skip to content

Commit 9f66f37

Browse files
authored
Add cache fields to directory read and _meta to Skill (#3719)
1 parent 754254c commit 9f66f37

5 files changed

Lines changed: 74 additions & 6 deletions

File tree

‎docs/advanced/skills.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ There are three moves here:
3535
* `Skill(uri=..., frontmatter=..., resources=[...])` is one entry. It has the same shape whether it
3636
comes back from `skills/list` or `skills/get`. `resources` is the skill's complete file
3737
manifest — every file, `SKILL.md` included, each with a `sha256:...` digest and byte size — or
38-
the string `"dynamic"` for content generated on demand.
38+
the string `"dynamic"` for content generated on demand. An optional `_meta` dict rides along
39+
unchanged on both methods.
3940
* `list_skills` and `get_skill` are plain async callables, invoked once per request. `get_skill`
4041
**must** answer for a skill even if your `list_skills` left it out — SEP-2640 requires a server
4142
to answer by URI for every skill it serves, listed or not.
@@ -117,7 +118,8 @@ setting.
117118

118119
## Protocol version and caching
119120

120-
In protocol version `2026-07-28` and later, `skills/list` and `skills/get` results carry the base
121+
In protocol version `2026-07-28` and later, `skills/list`, `skills/get`, and
122+
`resources/directory/read` results carry the base
121123
protocol's caching fields, [`ttlMs` and `cacheScope`](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2549) —
122124
the same freshness hint `tools/list`, `resources/list`, and `resources/read` carry. `Skills` fills
123125
`cacheScope` with `"private"` when your handler leaves it unset, and omits both fields entirely on an

‎src/mcp/server/skills.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ async def _handle_read_directory(
139139
validate_directory_result(params.uri, result)
140140
except ValueError:
141141
raise MCPError(code=INTERNAL_ERROR, message="Handler returned an invalid result") from None
142-
return result
142+
return _finalize_cacheable(result, ctx.protocol_version)
143143

144144

145145
def _require_skill_md_uri(uri: str) -> None:
@@ -159,7 +159,7 @@ def _require_directory_uri(uri: str) -> None:
159159
def _finalize_cacheable(result: CacheableResult, protocol_version: str) -> HandlerResult:
160160
"""Gate SEP-2549's `ttlMs`/`cacheScope` to protocol version 2026-07-28+.
161161
162-
`skills/list` and `skills/get` are extension methods, so — unlike a core spec
162+
`skills/list`, `skills/get` and `resources/directory/read` are extension methods, so — unlike a core spec
163163
method — the runner's per-version surface sieve never runs on their results;
164164
nothing else strips these fields for a legacy connection. `CacheableResult`
165165
defaults to `cache_scope="private"`, keeping user-specific entries out of shared caches.

‎src/mcp/shared/skills.py‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ class Skill(_SkillModel):
104104
"""Resource URI of the skill's `SKILL.md`."""
105105
frontmatter: Frontmatter
106106
resources: SkillResources
107+
meta: dict[str, Any] | None = Field(alias="_meta", default=None)
108+
"""Opaque `_meta` the server attaches to this entry."""
107109

108110
@model_validator(mode="after")
109111
def _check_conformance(self) -> Skill:
@@ -191,8 +193,12 @@ class ReadDirectoryParams(PaginatedRequestParams):
191193
"""URI of the directory resource whose direct children are listed."""
192194

193195

194-
class ReadDirectoryResult(PaginatedResult):
195-
"""Result of `resources/directory/read`."""
196+
class ReadDirectoryResult(PaginatedResult, CacheableResult):
197+
"""Result of `resources/directory/read`.
198+
199+
Like `ListSkillsResult`, this carries SEP-2549's `ttl_ms`/`cache_scope`, which
200+
`mcp.server.skills` strips for a pre-2026-07-28 connection.
201+
"""
196202

197203
resources: list[Resource]
198204

‎tests/client/extensions/test_skills.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,30 @@ async def test_get_skill_returns_the_matching_entry() -> None:
188188
assert skill.uri == _SKILL_URI
189189

190190

191+
async def test_list_and_get_skill_preserve_skill_meta() -> None:
192+
"""ext-skills#157: a skill's optional `_meta` survives server -> wire -> client on both methods."""
193+
meta = {"example.com/owner": "platform"}
194+
skill_with_meta = Skill(
195+
uri=_SKILL_URI,
196+
frontmatter={"name": "git-workflow", "description": "d"},
197+
resources=[SkillResource(uri=_SKILL_URI, digest=_SKILL_DIGEST, size=len(_SKILL_CONTENT))],
198+
_meta=meta,
199+
)
200+
201+
async def list_skills(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
202+
return ListSkillsResult(skills=[skill_with_meta])
203+
204+
async def get_skill(ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> GetSkillResult:
205+
return GetSkillResult(skill=skill_with_meta)
206+
207+
server = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
208+
async with _skills(server) as skills:
209+
listed = await skills.list_skills()
210+
fetched = await skills.get_skill(_SKILL_URI)
211+
assert [s.meta for s in listed] == [meta]
212+
assert fetched.meta == meta
213+
214+
191215
async def test_get_skill_propagates_the_servers_unknown_skill_error() -> None:
192216
async with _skills(_server()) as skills:
193217
with pytest.raises(MCPError) as exc_info:

‎tests/server/test_skills.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,20 @@ async def mutating_list(ctx: ServerRequestContext[Any, Any], params: ListSkillsP
179179
assert exc_info.value.code == INTERNAL_ERROR
180180

181181

182+
async def test_skills_list_sends_skill_meta_as_underscore_meta() -> None:
183+
"""ext-skills#157: a skill's optional metadata crosses the wire as `_meta`, not `meta`."""
184+
185+
async def with_meta(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
186+
skill = _git_workflow_skill().model_copy(update={"meta": {"example.com/owner": "platform"}})
187+
return ListSkillsResult(skills=[skill])
188+
189+
server = MCPServer("catalog", extensions=[Skills(list_skills=with_meta, get_skill=_get_skill)])
190+
async with Client(server, mode="2026-07-28") as client:
191+
raw = await client.session.send_request(ListSkillsRequest(), _RawResult)
192+
extra = raw.model_extra or {}
193+
assert extra["skills"][0]["_meta"] == {"example.com/owner": "platform"}
194+
195+
182196
async def test_skills_get_returns_the_matching_skill() -> None:
183197
async with Client(_server()) as client:
184198
result = await client.session.send_request(
@@ -321,6 +335,28 @@ async def test_directory_read_returns_the_handlers_children() -> None:
321335
assert [r.uri for r in result.resources] == ["skill://git-workflow/references"]
322336

323337

338+
async def test_directory_read_defaults_to_private_cache_scope_on_the_2026_07_28_wire() -> None:
339+
"""ext-skills#157: `resources/directory/read` results carry SEP-2549's cache fields too."""
340+
async with Client(_server(with_directory_read=True), mode="2026-07-28") as client:
341+
raw = await client.session.send_request(
342+
ReadDirectoryRequest(params=ReadDirectoryParams(uri="skill://git-workflow")), _RawResult
343+
)
344+
extra = raw.model_extra or {}
345+
assert (extra["ttlMs"], extra["cacheScope"]) == (0, "private")
346+
347+
348+
async def test_directory_read_omits_cache_fields_on_a_legacy_wire() -> None:
349+
"""SEP-2549 fields are 2026-07-28+ only, and the runner's sieve skips extension methods,
350+
so `Skills` strips them itself for an older connection."""
351+
async with Client(_server(with_directory_read=True), mode="legacy") as client:
352+
raw = await client.session.send_request(
353+
ReadDirectoryRequest(params=ReadDirectoryParams(uri="skill://git-workflow")), _RawResult
354+
)
355+
extra = raw.model_extra or {}
356+
assert "cacheScope" not in extra
357+
assert "ttlMs" not in extra
358+
359+
324360
async def test_directory_read_is_not_registered_without_a_handler() -> None:
325361
"""SDK-defined: omitting `read_directory` doesn't just skip the capability ad - the
326362
method itself isn't registered, so calling it anyway is Method not found."""

0 commit comments

Comments
 (0)