Skip to content

Commit baabbd1

Browse files
Add optional pagination and harden SkillCatalog and FilesystemSkillProvider
1 parent 8b8049f commit baabbd1

6 files changed

Lines changed: 450 additions & 64 deletions

File tree

‎src/mcp/server/skills/__init__.py‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
however their catalog is stored, and serves the underlying `skill://` file
1414
content through the server's ordinary resource-registration APIs.
1515
16+
To serve skills from a directory (or any other source of files) without writing
17+
handlers, use `SkillCatalog` with a `SkillProvider` such as `FilesystemSkillProvider`.
18+
1619
async def list_skills(ctx, params):
1720
return ListSkillsResult(skills=[...])
1821
@@ -24,6 +27,10 @@ async def get_skill(ctx, params):
2427
mcp = MCPServer("catalog", extensions=[Skills(list_skills=list_skills, get_skill=get_skill)])
2528
"""
2629

30+
# The handler aliases were importable from `mcp.server.skills` when it was a single module.
31+
from mcp.server.skills._extension import GetSkillHandler as GetSkillHandler
32+
from mcp.server.skills._extension import ListSkillsHandler as ListSkillsHandler
33+
from mcp.server.skills._extension import ReadDirectoryHandler as ReadDirectoryHandler
2734
from mcp.server.skills._extension import Skills
2835
from mcp.server.skills.catalog import SkillCatalog
2936
from mcp.server.skills.filesystem import FilesystemSkillProvider

‎src/mcp/server/skills/_extension.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
1010
This module provides the protocol-level plumbing only: request/response
1111
handling, SEP-2640 conformance validation, and capability advertisement. It
12-
does not discover, read, or hash skills from a filesystem — a server author
13-
supplies handlers that answer `skills/list`/`skills/get`/`resources/directory/read`
12+
does not discover, read, or hash skills itself — a server author supplies
13+
handlers that answer `skills/list`/`skills/get`/`resources/directory/read`
1414
however their catalog is stored, and serves the underlying `skill://` file
1515
content through the server's ordinary resource-registration APIs
16-
(`MCPServer.add_resource`, or an `@mcp.resource(...)` template).
16+
(`MCPServer.add_resource`, or an `@mcp.resource(...)` template). `SkillCatalog`
17+
builds on `Skills` to do the discovery for a `SkillProvider`.
1718
1819
async def list_skills(ctx, params):
1920
return ListSkillsResult(skills=[...])

‎src/mcp/server/skills/catalog.py‎

Lines changed: 102 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,21 @@
22

33
from __future__ import annotations
44

5+
import base64
56
import hashlib
7+
import json
68
import logging
79
import mimetypes
810
import re
911
from collections.abc import Callable, Mapping, Sequence
10-
from dataclasses import dataclass
11-
from typing import Any, Literal, cast
12+
from pathlib import PurePosixPath
13+
from typing import Any, Literal, TypeVar
1214
from urllib.parse import quote
1315

1416
import anyio.to_thread
15-
from mcp_types import INVALID_PARAMS
17+
from mcp_types import INVALID_PARAMS, CacheableResult
1618
from mcp_types import Resource as ResourceEntry
17-
from pydantic import PrivateAttr, ValidationError
19+
from pydantic import BaseModel, PrivateAttr, TypeAdapter, ValidationError
1820
from pydantic_core import to_jsonable_python
1921

2022
from mcp.server.context import ServerRequestContext
@@ -27,6 +29,7 @@
2729
from mcp.shared.skills import (
2830
MAX_RESOURCES_PER_SKILL,
2931
MAX_TOTAL_SIZE,
32+
Frontmatter,
3033
GetSkillParams,
3134
GetSkillResult,
3235
ListSkillsParams,
@@ -44,14 +47,22 @@
4447
_SKILL_FILE = "SKILL.md"
4548
_DIRECTORY_MIME_TYPE = "inode/directory"
4649
_FRONTMATTER_DELIMITER = "---"
50+
_MIME_TYPE_OVERRIDES = {
51+
".md": "text/markdown",
52+
".markdown": "text/markdown",
53+
".yaml": "application/yaml",
54+
".yml": "application/yaml",
55+
}
4756

4857

49-
@dataclass(frozen=True)
50-
class _Fingerprint:
58+
class _Fingerprint(BaseModel, frozen=True):
5159
digest: str
5260
size: int
5361

5462

63+
_FRONTMATTER_ADAPTER: TypeAdapter[Frontmatter] = TypeAdapter(Frontmatter)
64+
65+
5566
class _SkillFileResource(Resource):
5667
"""One file of a skill, read from the provider on every `resources/read`.
5768
@@ -102,7 +113,13 @@ def __init__(
102113
directories: Mapping[str, Sequence[ResourceEntry]] | None,
103114
ttl_ms: int,
104115
cache_scope: Literal["public", "private"],
116+
page_size: int | None,
105117
) -> None:
118+
"""Assemble a catalog from already-validated parts; use `SkillCatalog.load()` instead.
119+
120+
Raises:
121+
ValidationError: If `ttl_ms` is negative.
122+
"""
106123
super().__init__(
107124
list_skills=self._list_handler,
108125
get_skill=self._get_handler,
@@ -112,8 +129,8 @@ def __init__(
112129
self._by_uri = {skill.uri: skill for skill in skills}
113130
self._files = dict(files)
114131
self._directories = directories
115-
self._ttl_ms = ttl_ms
116-
self._cache_scope: Literal["public", "private"] = cache_scope
132+
self._cache = CacheableResult(ttl_ms=ttl_ms, cache_scope=cache_scope)
133+
self._page_size = page_size
117134

118135
@classmethod
119136
async def load(
@@ -125,6 +142,7 @@ async def load(
125142
directory_read: bool = False,
126143
ttl_ms: int = 0,
127144
cache_scope: Literal["public", "private"] = "private",
145+
page_size: int | None = None,
128146
) -> SkillCatalog:
129147
"""Scan `provider` and build a catalog of the skills it holds.
130148
@@ -145,14 +163,20 @@ async def load(
145163
ttl_ms: `ttlMs` cache hint (SEP-2549) set on `skills/list`, `skills/get` and
146164
directory reads.
147165
cache_scope: `cacheScope` cache hint set on the same results.
166+
page_size: The maximum number of entries in one `skills/list` or directory read
167+
page. When `None`, every entry is returned in a single response. A skill entry
168+
is never split across pages.
148169
149170
Raises:
150-
ValueError: If `strict` is set and any skill is invalid, or if the provider
151-
lists a path that is empty, absolute, contains a `.` or `..` segment, or
152-
appears twice.
171+
ValueError: If `strict` is set and any skill is invalid, if the provider
172+
lists a path that is empty, absolute, contains a `.` or `..` segment, appears
173+
twice, or lies beneath another listed file, or if `ttl_ms` is negative or
174+
`page_size` is less than 1.
153175
OSError: If the provider fails to list or read a file.
154176
"""
155-
paths = _check_paths(await provider.list_files())
177+
if page_size is not None and page_size < 1:
178+
raise ValueError("page_size must be at least 1")
179+
paths = _check_paths(list(await provider.list_files()))
156180
visible = [path for path in paths if not any(segment.startswith(".") for segment in path.split("/"))]
157181
skill_paths = sorted(path for path in visible if path.split("/")[-1] == _SKILL_FILE)
158182

@@ -203,6 +227,7 @@ async def load(
203227
directories=_directory_index(files) if directory_read else None,
204228
ttl_ms=ttl_ms,
205229
cache_scope=cache_scope,
230+
page_size=page_size,
206231
)
207232

208233
@property
@@ -215,41 +240,79 @@ def resources(self) -> Sequence[ResourceBinding]:
215240
return [ResourceBinding(resource=file) for file in sorted(self._files.values(), key=lambda f: f.uri)]
216241

217242
async def _list_handler(self, ctx: ServerRequestContext[Any, Any], params: ListSkillsParams) -> ListSkillsResult:
218-
_reject_cursor(params.cursor)
219-
return ListSkillsResult(skills=list(self._catalog_skills), ttl_ms=self._ttl_ms, cache_scope=self._cache_scope)
243+
page, next_cursor = _paginate(self._catalog_skills, params.cursor, self._page_size)
244+
return ListSkillsResult(
245+
skills=page,
246+
next_cursor=next_cursor,
247+
ttl_ms=self._cache.ttl_ms,
248+
cache_scope=self._cache.cache_scope,
249+
)
220250

221251
async def _get_handler(self, ctx: ServerRequestContext[Any, Any], params: GetSkillParams) -> GetSkillResult:
222252
skill = self._by_uri.get(params.uri)
223253
if skill is None:
224254
raise MCPError(code=INVALID_PARAMS, message=f"unknown skill: {params.uri}")
225-
return GetSkillResult(skill=skill, ttl_ms=self._ttl_ms, cache_scope=self._cache_scope)
255+
return GetSkillResult(skill=skill, ttl_ms=self._cache.ttl_ms, cache_scope=self._cache.cache_scope)
226256

227257
async def _read_directory_handler(
228258
self, ctx: ServerRequestContext[Any, Any], params: ReadDirectoryParams
229259
) -> ReadDirectoryResult:
230260
assert self._directories is not None
231-
_reject_cursor(params.cursor)
232261
children = self._directories.get(params.uri)
233262
if children is None:
234263
raise MCPError(code=INVALID_PARAMS, message=f"unknown directory: {params.uri}")
235-
return ReadDirectoryResult(resources=list(children), ttl_ms=self._ttl_ms, cache_scope=self._cache_scope)
264+
page, next_cursor = _paginate(children, params.cursor, self._page_size)
265+
return ReadDirectoryResult(
266+
resources=page,
267+
next_cursor=next_cursor,
268+
ttl_ms=self._cache.ttl_ms,
269+
cache_scope=self._cache.cache_scope,
270+
)
271+
272+
273+
_T = TypeVar("_T")
274+
275+
276+
def _encode_cursor(offset: int) -> str:
277+
return base64.urlsafe_b64encode(str(offset).encode("ascii")).decode("ascii")
236278

237279

238-
def _reject_cursor(cursor: str | None) -> None:
239-
# The catalog returns everything on one page, so it never issues a cursor.
280+
def _paginate(items: Sequence[_T], cursor: str | None, page_size: int | None) -> tuple[list[_T], str | None]:
281+
"""Return one page of `items` and the cursor for the next page, if any.
282+
283+
The catalog is immutable, so a cursor is just an offset into `items`. Only the offsets this
284+
function issues are valid; anything else is Invalid params.
285+
"""
286+
if page_size is None:
287+
if cursor is not None:
288+
raise MCPError(code=INVALID_PARAMS, message="this server does not paginate; unexpected cursor")
289+
return list(items), None
290+
start = 0
240291
if cursor is not None:
241-
raise MCPError(code=INVALID_PARAMS, message="this server does not paginate; unexpected cursor")
292+
try:
293+
start = int(base64.urlsafe_b64decode(cursor.encode("ascii")))
294+
except ValueError:
295+
start = -1
296+
if not (0 < start < len(items) and start % page_size == 0 and _encode_cursor(start) == cursor):
297+
raise MCPError(code=INVALID_PARAMS, message="invalid cursor")
298+
end = start + page_size
299+
return list(items[start:end]), _encode_cursor(end) if end < len(items) else None
242300

243301

244-
def _check_paths(paths: Sequence[str]) -> list[str]:
302+
def _check_paths(paths: list[str]) -> list[str]:
245303
seen: set[str] = set()
246304
for path in paths:
247305
if any(segment in ("", ".", "..") for segment in path.split("/")):
248306
raise ValueError(f"provider listed an invalid path {path!r}")
249307
if path in seen:
250308
raise ValueError(f"provider listed {path!r} more than once")
251309
seen.add(path)
252-
return list(paths)
310+
for path in paths:
311+
segments = path.split("/")
312+
for depth in range(1, len(segments)):
313+
if "/".join(segments[:depth]) in seen:
314+
raise ValueError(f"provider listed {path!r} beneath {'/'.join(segments[:depth])!r}, which is a file")
315+
return paths
253316

254317

255318
def _fingerprint(data: bytes) -> _Fingerprint:
@@ -306,16 +369,14 @@ def _parse_frontmatter(data: bytes, parse: Callable[[str], Mapping[str, Any]]) -
306369
parsed = parse("\n".join(lines[1:end]))
307370
except Exception as exc: # the parser is caller-supplied, so any failure marks the skill invalid
308371
raise ValueError(f"frontmatter could not be parsed: {exc}") from exc
309-
if not _is_str_mapping(parsed):
310-
raise ValueError("frontmatter must be a mapping with string keys")
311372
try:
312-
return to_jsonable_python(parsed)
313-
except ValueError as exc:
314-
raise ValueError(f"frontmatter is not JSON-serializable: {exc}") from exc
315-
316-
317-
def _is_str_mapping(value: object) -> bool:
318-
return isinstance(value, Mapping) and all(isinstance(key, str) for key in cast(Mapping[object, object], value))
373+
frontmatter = to_jsonable_python(_FRONTMATTER_ADAPTER.validate_python(parsed))
374+
json.dumps(
375+
frontmatter, allow_nan=False
376+
) # NaN and infinity have no JSON form, so they can't pass through verbatim
377+
return frontmatter
378+
except (ValidationError, ValueError) as exc:
379+
raise ValueError("frontmatter must be a mapping with string keys and JSON-compatible values") from exc
319380

320381

321382
def _warn_over_limits(skill_uri: str, resources: Sequence[SkillResource]) -> None:
@@ -332,13 +393,17 @@ def _uri(path: str) -> str:
332393
return "skill://" + "/".join(quote(segment, safe="") for segment in path.split("/"))
333394

334395

396+
def _guess_mime_type(path: str) -> str:
397+
# `mimetypes` consults the host's tables, which differ by platform and don't always know Markdown.
398+
return (
399+
_MIME_TYPE_OVERRIDES.get(PurePosixPath(path).suffix.lower())
400+
or mimetypes.guess_type(path)[0]
401+
or "application/octet-stream"
402+
)
403+
404+
335405
def _guess_mime_types(paths: Sequence[str]) -> dict[str, str]:
336-
return {
337-
path: "text/markdown"
338-
if path.split("/")[-1] == _SKILL_FILE
339-
else mimetypes.guess_type(path)[0] or "application/octet-stream"
340-
for path in paths
341-
}
406+
return {path: _guess_mime_type(path) for path in paths}
342407

343408

344409
def _file_resource(

‎src/mcp/server/skills/filesystem.py‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,9 @@
1818
_READ_FLAGS = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0)
1919

2020

21-
# Windows reports junctions as reparse points rather than as symlinks; `stat` only types this constant on Windows.
22-
_WINDOWS_REPARSE_POINT = 0x400
21+
# Windows junctions and symlinks are reparse points tagged IO_REPARSE_TAG_MOUNT_POINT / IO_REPARSE_TAG_SYMLINK,
22+
# constants that `stat` only types on Windows. Other reparse points (cloud placeholders, dedup) are ordinary files.
23+
_WINDOWS_LINK_REPARSE_TAGS = frozenset({0xA0000003, 0xA000000C})
2324

2425

2526
class FilesystemSkillProvider:
@@ -77,7 +78,7 @@ def _walk(self, directory: Path, prefix: list[str], files: list[str]) -> None:
7778
self._walk(Path(entry.path), segments, files)
7879
elif stat.S_ISREG(result.st_mode):
7980
files.append("/".join(segments))
80-
else:
81+
else: # pragma: lax no cover
8182
logger.warning("Skipping %s: not a regular file", entry.path)
8283

8384
def _read_file(self, segments: list[str]) -> bytes:
@@ -93,9 +94,9 @@ def _read_file(self, segments: list[str]) -> bytes:
9394
fd = os.open(current, _READ_FLAGS)
9495
with os.fdopen(fd, "rb") as file:
9596
if not stat.S_ISREG(os.fstat(file.fileno()).st_mode):
96-
raise OSError(f"{current} is not a regular file")
97+
raise OSError(f"{current} is not a regular file") # pragma: lax no cover
9798
return file.read()
9899

99100

100101
def _is_link(result: os.stat_result) -> bool:
101-
return stat.S_ISLNK(result.st_mode) or bool(getattr(result, "st_file_attributes", 0) & _WINDOWS_REPARSE_POINT)
102+
return stat.S_ISLNK(result.st_mode) or getattr(result, "st_reparse_tag", 0) in _WINDOWS_LINK_REPARSE_TAGS

0 commit comments

Comments
 (0)