Skip to content

Commit 42eff6c

Browse files
committed
Add cache fields to directory read and _meta to Skill
1 parent 754254c commit 42eff6c

5 files changed

Lines changed: 69 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: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,29 @@ 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+
meta = {"example.com/owner": "platform"}
193+
skill_with_meta = Skill(
194+
uri=_SKILL_URI,
195+
frontmatter={"name": "git-workflow", "description": "d"},
196+
resources=[SkillResource(uri=_SKILL_URI, digest=_SKILL_DIGEST, size=len(_SKILL_CONTENT))],
197+
_meta=meta,
198+
)
199+
200+
async def list_skills(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
201+
return ListSkillsResult(skills=[skill_with_meta])
202+
203+
async def get_skill(ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> GetSkillResult:
204+
return GetSkillResult(skill=skill_with_meta)
205+
206+
server = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
207+
async with _skills(server) as skills:
208+
listed = await skills.list_skills()
209+
fetched = await skills.get_skill(_SKILL_URI)
210+
assert [s.meta for s in listed] == [meta]
211+
assert fetched.meta == meta
212+
213+
191214
async def test_get_skill_propagates_the_servers_unknown_skill_error() -> None:
192215
async with _skills(_server()) as skills:
193216
with pytest.raises(MCPError) as exc_info:

‎tests/server/test_skills.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,18 @@ 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+
async def with_meta(ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
184+
skill = _git_workflow_skill().model_copy(update={"meta": {"example.com/owner": "platform"}})
185+
return ListSkillsResult(skills=[skill])
186+
187+
server = MCPServer("catalog", extensions=[Skills(list_skills=with_meta, get_skill=_get_skill)])
188+
async with Client(server, mode="2026-07-28") as client:
189+
raw = await client.session.send_request(ListSkillsRequest(), _RawResult)
190+
extra = raw.model_extra or {}
191+
assert extra["skills"][0]["_meta"] == {"example.com/owner": "platform"}
192+
193+
182194
async def test_skills_get_returns_the_matching_skill() -> None:
183195
async with Client(_server()) as client:
184196
result = await client.session.send_request(
@@ -321,6 +333,26 @@ async def test_directory_read_returns_the_handlers_children() -> None:
321333
assert [r.uri for r in result.resources] == ["skill://git-workflow/references"]
322334

323335

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

0 commit comments

Comments
 (0)