11"""Skillz MCP server exposing local Anthropic-style skills via FastMCP.
22
3- Usage examples::
4-
5- uv run python -m skillz /path/to/skills --verbose
6- uv run python -m skillz tmp/examples --list-skills
7-
8- Manual smoke tests rely on the sample fixture in ``tmp/examples`` created
9- by the project checklist. The ``--list-skills`` flag validates discovery
10- without starting the transport, while additional sanity checks can be run
11- with a short script that invokes the generated tool functions directly.
12-
133Skills provide instructions and resources via MCP. Clients are responsible
144for reading resources (including any scripts) and executing them if needed.
155"""
188
199import argparse
2010import logging
11+ import mimetypes
2112import re
2213import sys
2314import textwrap
@@ -65,8 +56,6 @@ def __init__(self, message: str) -> None:
6556 super ().__init__ (message , code = "validation_error" )
6657
6758
68-
69-
7059@dataclass (slots = True )
7160class SkillMetadata :
7261 """Structured metadata extracted from a skill front matter block."""
@@ -103,10 +92,17 @@ def read_body(self) -> str:
10392
10493
10594class SkillResourceMetadata (TypedDict ):
106- """Metadata describing a registered skill resource."""
95+ """Metadata describing a registered skill resource following MCP spec.
96+
97+ According to MCP specification:
98+ - uri: Unique identifier for the resource (with protocol)
99+ - name: Human-readable name (path without protocol prefix)
100+ - mimeType: Optional MIME type for the resource
101+ """
107102
108- relative_path : str
109103 uri : str
104+ name : str
105+ mime_type : Optional [str ]
110106
111107
112108def slugify (value : str ) -> str :
@@ -267,12 +263,19 @@ def load(self) -> None:
267263 LOGGER .info ("Loaded %d skills" , len (self ._skills_by_slug ))
268264
269265 def _collect_resources (self , directory : Path ) -> tuple [Path , ...]:
266+ """Collect all files in skill directory except SKILL.md.
267+
268+ SKILL.md is only returned from the tool, not as a resource.
269+ All other files in the skill directory and subdirectories are
270+ resources.
271+ """
270272 root = directory .resolve ()
271- files = [root / SKILL_MARKDOWN ]
273+ skill_md_path = root / SKILL_MARKDOWN
274+ files = []
272275 for file_path in sorted (root .rglob ("*" )):
273276 if not file_path .is_file ():
274277 continue
275- if file_path == root / SKILL_MARKDOWN :
278+ if file_path == skill_md_path :
276279 continue
277280 files .append (file_path )
278281 return tuple (files )
@@ -284,23 +287,47 @@ def get(self, slug: str) -> Skill:
284287 raise SkillError (f"Unknown skill '{ slug } '" ) from exc
285288
286289
287-
288-
289290def _build_resource_uri (skill : Skill , relative_path : Path ) -> str :
291+ """Build a resource URI following MCP specification.
292+
293+ Format: [protocol]://[host]/[path]
294+ Example: resource://skillz/skill-name/path/to/file.ext
295+ """
290296 encoded_slug = quote (skill .slug , safe = "" )
291297 encoded_parts = [quote (part , safe = "" ) for part in relative_path .parts ]
292298 path_suffix = "/" .join (encoded_parts )
293- if path_suffix :
294- return f"resource://skillz/{ encoded_slug } /{ path_suffix } "
295- return f"resource://skillz/{ encoded_slug } "
299+ return f"resource://skillz/{ encoded_slug } /{ path_suffix } "
300+
301+
302+ def _get_resource_name (skill : Skill , relative_path : Path ) -> str :
303+ """Get resource name (path without protocol) following MCP specification.
304+
305+ This is the URI path without the protocol prefix.
306+ Example: skillz/skill-name/path/to/file.ext
307+ """
308+ return f"{ skill .slug } /{ relative_path .as_posix ()} "
296309
297310
311+ def _detect_mime_type (file_path : Path ) -> Optional [str ]:
312+ """Detect MIME type for a file, returning None if unknown.
313+
314+ Uses Python's mimetypes library for detection.
315+ """
316+ mime_type , _ = mimetypes .guess_type (str (file_path ))
317+ return mime_type
298318
299319
300320def register_skill_resources (
301321 mcp : FastMCP , skill : Skill
302322) -> tuple [SkillResourceMetadata , ...]:
303- """Register FastMCP resources for each file in a skill."""
323+ """Register FastMCP resources for each file in a skill.
324+
325+ Resources follow MCP specification:
326+ - URI format: resource://skillz/{skill-slug}/{path}
327+ - Name: {skill-slug}/{path} (URI without protocol)
328+ - MIME type: Detected from file extension
329+ - Content: UTF-8 text or base64-encoded binary
330+ """
304331
305332 metadata : list [SkillResourceMetadata ] = []
306333 for resource_path in skill .resources :
@@ -309,8 +336,9 @@ def register_skill_resources(
309336 except ValueError : # pragma: no cover - defensive safeguard
310337 relative_path = Path (resource_path .name )
311338
312- relative_display = relative_path .as_posix ()
313339 uri = _build_resource_uri (skill , relative_path )
340+ name = _get_resource_name (skill , relative_path )
341+ mime_type = _detect_mime_type (resource_path )
314342
315343 def _make_resource_reader (path : Path ) -> Callable [[], str | bytes ]:
316344 def _read_resource () -> str | bytes :
@@ -321,21 +349,24 @@ def _read_resource() -> str | bytes:
321349 f"Failed to read resource '{ path } ': { exc } "
322350 ) from exc
323351
352+ # Try to decode as UTF-8 text; if that fails, return binary
324353 try :
325354 return data .decode ("utf-8" )
326355 except UnicodeDecodeError :
356+ # FastMCP will handle base64 encoding for binary data
327357 return data
328358
329359 return _read_resource
330360
331- mcp .resource (uri , name = f" { skill . slug } : { relative_display } " )(
361+ mcp .resource (uri , name = name , mime_type = mime_type )(
332362 _make_resource_reader (resource_path )
333363 )
334364
335365 metadata .append (
336366 {
337- "relative_path" : relative_display ,
338367 "uri" : uri ,
368+ "name" : name ,
369+ "mime_type" : mime_type ,
339370 }
340371 )
341372
@@ -361,9 +392,8 @@ def register_skill_tool(
361392) -> Callable [..., Awaitable [Mapping [str , Any ]]]:
362393 """Register a tool that returns skill instructions and resource URIs.
363394
364- Clients are expected to read the instructions, then use
365- ctx.read_resource(uri) to access any resources they need
366- (including scripts, which they can then execute themselves if desired).
395+ Clients are expected to read the instructions and retrieve any
396+ referenced resources from the MCP server as needed.
367397 """
368398 tool_name = skill .slug
369399 description = _format_tool_description (skill )
@@ -390,8 +420,9 @@ async def _skill_tool( # type: ignore[unused-ignore]
390420 instructions = bound_skill .read_body ()
391421 resource_entries = [
392422 {
393- "relative_path" : entry ["relative_path" ],
394423 "uri" : entry ["uri" ],
424+ "name" : entry ["name" ],
425+ "mime_type" : entry ["mime_type" ],
395426 }
396427 for entry in bound_resources
397428 ]
@@ -412,14 +443,10 @@ async def _skill_tool( # type: ignore[unused-ignore]
412443 """\
413444 Apply the skill instructions to complete the task.
414445
415- All skill resources are available via MCP resources.
416- Use ctx.read_resource(uri) with any URI from the
417- 'resources' list to access files, scripts, or other
418- supporting materials.
419-
420- The skill may include executable scripts. If you need
421- to run a script, read it via ctx.read_resource(uri)
422- and execute it yourself using appropriate tooling.
446+ If the skill instructions reference additional files or
447+ resources, and you determine it's appropriate to use them
448+ for completing the task, retrieve them from the MCP server
449+ using the resource URIs listed in the 'resources' field.
423450 """
424451 ).strip (),
425452 }
@@ -473,9 +500,10 @@ def configure_logging(verbose: bool, log_to_file: bool) -> None:
473500
474501
475502def build_server (registry : SkillRegistry ) -> FastMCP :
476- summary = ", " .join (
477- skill .metadata .name for skill in registry .skills
478- ) or "No skills"
503+ summary = (
504+ ", " .join (skill .metadata .name for skill in registry .skills )
505+ or "No skills"
506+ )
479507 mcp = FastMCP (
480508 name = SERVER_NAME ,
481509 version = SERVER_VERSION ,
0 commit comments