22
33from __future__ import annotations
44
5+ import base64
56import hashlib
7+ import json
68import logging
79import mimetypes
810import re
911from 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
1214from urllib .parse import quote
1315
1416import anyio .to_thread
15- from mcp_types import INVALID_PARAMS
17+ from mcp_types import INVALID_PARAMS , CacheableResult
1618from mcp_types import Resource as ResourceEntry
17- from pydantic import PrivateAttr , ValidationError
19+ from pydantic import BaseModel , PrivateAttr , TypeAdapter , ValidationError
1820from pydantic_core import to_jsonable_python
1921
2022from mcp .server .context import ServerRequestContext
2729from mcp .shared .skills import (
2830 MAX_RESOURCES_PER_SKILL ,
2931 MAX_TOTAL_SIZE ,
32+ Frontmatter ,
3033 GetSkillParams ,
3134 GetSkillResult ,
3235 ListSkillsParams ,
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+
5566class _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
255318def _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
321382def _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+
335405def _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
344409def _file_resource (
0 commit comments