Skip to content

Commit 10e13e2

Browse files
Build skill manifests with a bisect range instead of scanning every path
1 parent be15d59 commit 10e13e2

2 files changed

Lines changed: 38 additions & 4 deletions

File tree

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

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import logging
99
import mimetypes
1010
import re
11+
from bisect import bisect_left
1112
from collections.abc import Callable, Mapping, Sequence
1213
from pathlib import PurePosixPath
1314
from typing import Any, Literal, Self, TypeVar
@@ -179,8 +180,8 @@ async def load(
179180
if ttl_ms < 0:
180181
raise ValueError("ttl_ms must not be negative")
181182
paths = _check_paths(list(await provider.list_files()))
182-
visible = [path for path in paths if not any(segment.startswith(".") for segment in path.split("/"))]
183-
skill_paths = sorted(path for path in visible if path.split("/")[-1] == _SKILL_FILE)
183+
visible = sorted(path for path in paths if not any(segment.startswith(".") for segment in path.split("/")))
184+
skill_paths = [path for path in visible if path.split("/")[-1] == _SKILL_FILE]
184185

185186
skills: dict[str, Skill] = {}
186187
served: set[str] = set()
@@ -312,6 +313,15 @@ def _check_paths(paths: list[str]) -> list[str]:
312313
return paths
313314

314315

316+
def _paths_under(sorted_paths: Sequence[str], prefix: str) -> list[str]:
317+
"""Return the paths that start with `prefix`; they are contiguous in sorted order, so no full scan is needed."""
318+
start = bisect_left(sorted_paths, prefix)
319+
end = start
320+
while end < len(sorted_paths) and sorted_paths[end].startswith(prefix):
321+
end += 1
322+
return list(sorted_paths[start:end])
323+
324+
315325
def _fingerprint(data: bytes) -> _Fingerprint:
316326
return _Fingerprint(digest=hashlib.sha256(data).hexdigest(), size=len(data))
317327

@@ -321,7 +331,7 @@ async def _load_skill(
321331
parse_frontmatter: Callable[[str], Mapping[str, Any]],
322332
skill_path: str,
323333
skill_md: bytes,
324-
visible: Sequence[str],
334+
sorted_visible: Sequence[str],
325335
fingerprints: dict[str, _Fingerprint],
326336
) -> tuple[Skill, list[str]]:
327337
"""Build the `Skill` whose `SKILL.md` is `skill_path`, along with the paths of its files.
@@ -331,7 +341,7 @@ async def _load_skill(
331341
"""
332342
frontmatter = _parse_frontmatter(skill_md, parse_frontmatter)
333343
root = skill_path.removesuffix(_SKILL_FILE)
334-
skill_files = [path for path in visible if path.startswith(root)]
344+
skill_files = _paths_under(sorted_visible, root)
335345
for path in skill_files:
336346
if path not in fingerprints:
337347
fingerprints[path] = _fingerprint(await provider.read_file(path))

‎tests/server/skills/test_catalog.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,30 @@ async def test_a_nested_skill_is_listed_and_its_files_also_belong_to_the_parent_
181181
]
182182

183183

184+
async def test_a_skills_files_exclude_siblings_that_only_share_a_name_prefix() -> None:
185+
"""SDK-defined: a manifest holds only what is under `acme/`, not `acme-tools/`, `acme0/` or `acme.md`.
186+
187+
The provider lists in reverse order, so the result can't depend on the listing order.
188+
"""
189+
files = {
190+
"acme/SKILL.md": _skill_md("acme"),
191+
"acme/guide.md": b"guide",
192+
"acme-tools/SKILL.md": _skill_md("acme-tools"),
193+
"acme0/SKILL.md": _skill_md("acme0"),
194+
"acme.md": b"loose",
195+
}
196+
provider = _MemoryProvider(files, listing=sorted(files, reverse=True))
197+
catalog = await SkillCatalog.load(provider, parse_frontmatter=_parse)
198+
async with Client(MCPServer("skills", extensions=[catalog]), extensions=[ClientSkills()]) as client:
199+
skills = {skill.uri: skill for skill in await client.extension(ClientSkills).list_skills()}
200+
201+
assert isinstance(skills["skill://acme/SKILL.md"].resources, list)
202+
assert [r.uri for r in skills["skill://acme/SKILL.md"].resources] == [
203+
"skill://acme/SKILL.md",
204+
"skill://acme/guide.md",
205+
]
206+
207+
184208
async def test_hidden_files_and_directories_are_never_listed_or_served() -> None:
185209
"""SDK-defined (matches typescript-sdk #3047): a path segment starting with `.` is skipped entirely."""
186210
server = await _server(

0 commit comments

Comments
 (0)