progressive-skills-mcp 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1238 @@
1
+ """Skillz MCP server exposing local Anthropic-style skills via FastMCP.
2
+
3
+ Skills provide instructions and resources via MCP. Clients are responsible
4
+ for reading resources (including any scripts) and executing them if needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import mimetypes
12
+ import re
13
+ import sys
14
+ import textwrap
15
+ import zipfile
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+ from typing import (
19
+ Any,
20
+ Awaitable,
21
+ Callable,
22
+ Dict,
23
+ Iterable,
24
+ Iterator,
25
+ Mapping,
26
+ Optional,
27
+ TypedDict,
28
+ )
29
+ from urllib.parse import quote, unquote
30
+ import base64
31
+
32
+ import yaml
33
+ from fastmcp import Context, FastMCP
34
+ from fastmcp.exceptions import ToolError
35
+
36
+ from ._version import __version__
37
+ from .progressive_disclosure import MetadataGenerator, register_progressive_disclosure_tools
38
+
39
+
40
+
41
+ LOGGER = logging.getLogger("progressive_skills_mcp")
42
+ FRONT_MATTER_PATTERN = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)", re.DOTALL)
43
+ SKILL_MARKDOWN = "SKILL.md"
44
+ # Use bundled skills by default
45
+ DEFAULT_SKILLS_ROOT = Path(__file__).parent / "skills"
46
+ SERVER_NAME = "Skillz MCP Server"
47
+ SERVER_VERSION = __version__
48
+
49
+
50
+ class SkillError(Exception):
51
+ """Base exception for skill-related failures."""
52
+
53
+ def __init__(self, message: str, *, code: str = "skill_error") -> None:
54
+ super().__init__(message)
55
+ self.code = code
56
+
57
+
58
+ class SkillValidationError(SkillError):
59
+ """Raised when a skill fails validation."""
60
+
61
+ def __init__(self, message: str) -> None:
62
+ super().__init__(message, code="validation_error")
63
+
64
+
65
+ @dataclass(slots=True)
66
+ class SkillMetadata:
67
+ """Structured metadata extracted from a skill front matter block."""
68
+
69
+ name: str
70
+ description: str
71
+ license: Optional[str] = None
72
+ allowed_tools: tuple[str, ...] = ()
73
+ extra: Dict[str, Any] = field(default_factory=dict)
74
+
75
+
76
+ @dataclass(slots=True)
77
+ class Skill:
78
+ """Runtime representation of a skill directory or zip file."""
79
+
80
+ slug: str
81
+ directory: Path
82
+ instructions_path: Path
83
+ metadata: SkillMetadata
84
+ resources: tuple[Path, ...]
85
+ zip_path: Optional[Path] = None
86
+ zip_root_prefix: str = ""
87
+ _zip_members: Optional[set[str]] = field(default=None, init=False)
88
+
89
+ def __post_init__(self) -> None:
90
+ """Cache zip members for efficient lookups."""
91
+ if self.zip_path is not None:
92
+ with zipfile.ZipFile(self.zip_path) as z:
93
+ # Cache file members (exclude directory entries)
94
+ # Strip the root prefix if present
95
+ all_members = {
96
+ name
97
+ for name in z.namelist()
98
+ if not name.endswith("/")
99
+ }
100
+ if self.zip_root_prefix:
101
+ # Store members without the prefix for easier access
102
+ self._zip_members = {
103
+ name[len(self.zip_root_prefix):]
104
+ for name in all_members
105
+ if name.startswith(self.zip_root_prefix)
106
+ }
107
+ else:
108
+ self._zip_members = all_members
109
+
110
+ @property
111
+ def is_zip(self) -> bool:
112
+ """Check if this skill is backed by a zip file."""
113
+ return self.zip_path is not None
114
+
115
+ def open_bytes(self, rel_path: str) -> bytes:
116
+ """Read file content as bytes."""
117
+ if self.is_zip:
118
+ with zipfile.ZipFile(self.zip_path) as z:
119
+ # Add the root prefix if present
120
+ zip_member_path = self.zip_root_prefix + rel_path
121
+ return z.read(zip_member_path)
122
+ else:
123
+ return (self.directory / rel_path).read_bytes()
124
+
125
+ def exists(self, rel_path: str) -> bool:
126
+ """Check if a relative path exists in this skill."""
127
+ if self.is_zip:
128
+ return rel_path in (self._zip_members or set())
129
+ else:
130
+ return (self.directory / rel_path).exists()
131
+
132
+ def iter_resource_paths(self) -> Iterator[str]:
133
+ """Iterate over resource file paths (excluding SKILL.md)."""
134
+ if self.is_zip:
135
+ # Yield file paths from zip, skip SKILL.md and macOS metadata
136
+ for name in sorted(self._zip_members or []):
137
+ if name == SKILL_MARKDOWN:
138
+ continue
139
+ if "__MACOSX/" in name or name.endswith(".DS_Store"):
140
+ continue
141
+ yield name
142
+ else:
143
+ # Walk directory tree
144
+ for file_path in sorted(self.directory.rglob("*")):
145
+ if not file_path.is_file():
146
+ continue
147
+ if file_path == self.instructions_path:
148
+ continue
149
+ try:
150
+ rel_path = file_path.relative_to(self.directory)
151
+ yield rel_path.as_posix()
152
+ except ValueError:
153
+ continue
154
+
155
+ def read_body(self) -> str:
156
+ """Return the Markdown body of the skill."""
157
+
158
+ LOGGER.debug("Reading body for skill %s", self.slug)
159
+ if self.is_zip:
160
+ data = self.open_bytes(SKILL_MARKDOWN)
161
+ text = data.decode("utf-8")
162
+ else:
163
+ text = self.instructions_path.read_text(encoding="utf-8")
164
+ match = FRONT_MATTER_PATTERN.match(text)
165
+ if match:
166
+ return match.group(2).lstrip()
167
+ raise SkillValidationError(
168
+ f"Skill {self.slug} is missing YAML front matter "
169
+ "and cannot be served."
170
+ )
171
+
172
+
173
+ class SkillResourceMetadata(TypedDict):
174
+ """Metadata describing a registered skill resource following MCP spec.
175
+
176
+ According to MCP specification:
177
+ - uri: Unique identifier for the resource (with protocol)
178
+ - name: Human-readable name (path without protocol prefix)
179
+ - mimeType: Optional MIME type for the resource
180
+ """
181
+
182
+ uri: str
183
+ name: str
184
+ mime_type: Optional[str]
185
+
186
+
187
+ def slugify(value: str) -> str:
188
+ """Convert names into stable slug identifiers."""
189
+
190
+ cleaned = re.sub(r"[^a-zA-Z0-9]+", "-", value.strip().lower()).strip("-")
191
+ return cleaned or "skill"
192
+
193
+
194
+ def parse_skill_md(path: Path) -> tuple[SkillMetadata, str]:
195
+ """Parse SKILL.md front matter and body."""
196
+
197
+ raw = path.read_text(encoding="utf-8")
198
+ match = FRONT_MATTER_PATTERN.match(raw)
199
+ if not match:
200
+ raise SkillValidationError(
201
+ f"{path} must begin with YAML front matter delimited by '---'."
202
+ )
203
+
204
+ front_matter, body = match.groups()
205
+ try:
206
+ data = yaml.safe_load(front_matter) or {}
207
+ except yaml.YAMLError as exc: # pragma: no cover - defensive
208
+ raise SkillValidationError(
209
+ f"Unable to parse YAML in {path}: {exc}"
210
+ ) from exc
211
+
212
+ if not isinstance(data, Mapping):
213
+ raise SkillValidationError(
214
+ f"Front matter in {path} must define a mapping, "
215
+ f"not {type(data).__name__}."
216
+ )
217
+
218
+ name = str(data.get("name", "")).strip()
219
+ description = str(data.get("description", "")).strip()
220
+ if not name:
221
+ raise SkillValidationError(
222
+ f"Front matter in {path} is missing 'name'."
223
+ )
224
+ if not description:
225
+ raise SkillValidationError(
226
+ f"Front matter in {path} is missing 'description'."
227
+ )
228
+
229
+ allowed = data.get("allowed-tools") or data.get("allowed_tools") or []
230
+ if isinstance(allowed, str):
231
+ allowed_list = tuple(
232
+ part.strip() for part in allowed.split(",") if part.strip()
233
+ )
234
+ elif isinstance(allowed, Iterable):
235
+ allowed_list = tuple(
236
+ str(item).strip() for item in allowed if str(item).strip()
237
+ )
238
+ else:
239
+ allowed_list = ()
240
+
241
+ extra = {
242
+ key: value
243
+ for key, value in data.items()
244
+ if key
245
+ not in {
246
+ "name",
247
+ "description",
248
+ "license",
249
+ "allowed-tools",
250
+ "allowed_tools",
251
+ }
252
+ }
253
+
254
+ metadata = SkillMetadata(
255
+ name=name,
256
+ description=description,
257
+ license=(
258
+ str(data["license"]).strip() if data.get("license") else None
259
+ ),
260
+ allowed_tools=allowed_list,
261
+ extra=extra,
262
+ )
263
+ return metadata, body.lstrip()
264
+
265
+
266
+ class SkillRegistry:
267
+ """Discover and manage skills found under a root directory."""
268
+
269
+ def __init__(self, root: Path) -> None:
270
+ self.root = root
271
+ self._skills_by_slug: dict[str, Skill] = {}
272
+ self._skills_by_name: dict[str, Skill] = {}
273
+
274
+ @property
275
+ def skills(self) -> tuple[Skill, ...]:
276
+ return tuple(self._skills_by_slug.values())
277
+
278
+ def load(self) -> None:
279
+ if not self.root.exists() or not self.root.is_dir():
280
+ raise SkillError(
281
+ f"Skills root {self.root} does not exist "
282
+ "or is not a directory."
283
+ )
284
+
285
+ LOGGER.info("Discovering skills in %s", self.root)
286
+ self._skills_by_slug.clear()
287
+ self._skills_by_name.clear()
288
+
289
+ root = self.root.resolve()
290
+ self._scan_directory(root)
291
+
292
+ LOGGER.info("Loaded %d skills", len(self._skills_by_slug))
293
+
294
+ def _scan_directory(self, directory: Path) -> None:
295
+ """Recursively scan directory for skills (both dirs and zips)."""
296
+ # If this directory has SKILL.md, treat it as a dir-based skill
297
+ skill_md_path = directory / SKILL_MARKDOWN
298
+ if skill_md_path.is_file():
299
+ self._register_dir_skill(directory, skill_md_path)
300
+ return # Don't recurse into skill directories
301
+
302
+ # Otherwise, look for zip files and subdirectories
303
+ try:
304
+ entries = list(directory.iterdir())
305
+ except (OSError, PermissionError) as exc:
306
+ LOGGER.warning("Cannot read directory %s: %s", directory, exc)
307
+ return
308
+
309
+ # First, recurse into subdirectories (to find directory skills first)
310
+ # This ensures directory skills take precedence over zip skills
311
+ for entry in sorted(entries):
312
+ if entry.is_dir():
313
+ self._scan_directory(entry)
314
+
315
+ # Then check for zip files in this directory
316
+ for entry in sorted(entries):
317
+ if entry.is_file() and entry.suffix.lower() in (".zip", ".skill"):
318
+ self._try_register_zip_skill(entry)
319
+
320
+ def _register_dir_skill(self, directory: Path, skill_md: Path) -> None:
321
+ """Register a directory-based skill."""
322
+ try:
323
+ metadata, _ = parse_skill_md(skill_md)
324
+ except SkillValidationError as exc:
325
+ LOGGER.warning(
326
+ "Skipping invalid skill at %s: %s", directory, exc
327
+ )
328
+ return
329
+
330
+ slug = slugify(metadata.name)
331
+ if slug in self._skills_by_slug:
332
+ LOGGER.error(
333
+ "Duplicate skill slug '%s'; skipping %s",
334
+ slug,
335
+ directory,
336
+ )
337
+ return
338
+
339
+ if metadata.name in self._skills_by_name:
340
+ LOGGER.warning(
341
+ "Duplicate skill name '%s' found in %s; "
342
+ "only first occurrence is kept",
343
+ metadata.name,
344
+ directory,
345
+ )
346
+ return
347
+
348
+ resources = self._collect_resources(directory)
349
+
350
+ skill = Skill(
351
+ slug=slug,
352
+ directory=directory.resolve(),
353
+ instructions_path=skill_md.resolve(),
354
+ metadata=metadata,
355
+ resources=resources,
356
+ )
357
+
358
+ if directory.name != slug:
359
+ LOGGER.debug(
360
+ "Skill directory name '%s' does not match slug '%s'",
361
+ directory.name,
362
+ slug,
363
+ )
364
+
365
+ self._skills_by_slug[slug] = skill
366
+ self._skills_by_name[metadata.name] = skill
367
+
368
+ def _try_register_zip_skill(self, zip_path: Path) -> None:
369
+ """Try to register a zip file as a skill."""
370
+ try:
371
+ with zipfile.ZipFile(zip_path) as z:
372
+ # Check if SKILL.md exists at root or in single top-level dir
373
+ members = {
374
+ name for name in z.namelist() if not name.endswith("/")
375
+ }
376
+
377
+ skill_md_path = None
378
+ zip_root_prefix = ""
379
+
380
+ # First, try SKILL.md at root
381
+ if SKILL_MARKDOWN in members:
382
+ skill_md_path = SKILL_MARKDOWN
383
+ else:
384
+ # Try to find SKILL.md in single top-level directory
385
+ # Pattern: skill-name.zip contains skill-name/SKILL.md
386
+ top_level_dirs = set()
387
+ for name in z.namelist():
388
+ if "/" in name:
389
+ top_dir = name.split("/", 1)[0]
390
+ top_level_dirs.add(top_dir)
391
+
392
+ # If there's exactly one top-level directory
393
+ if len(top_level_dirs) == 1:
394
+ top_dir = list(top_level_dirs)[0]
395
+ candidate = f"{top_dir}/{SKILL_MARKDOWN}"
396
+ if candidate in members:
397
+ skill_md_path = candidate
398
+ zip_root_prefix = f"{top_dir}/"
399
+
400
+ if skill_md_path is None:
401
+ LOGGER.debug(
402
+ "Zip %s missing SKILL.md at root or in "
403
+ "single top-level directory; skipping",
404
+ zip_path,
405
+ )
406
+ return
407
+
408
+ # Parse SKILL.md from zip
409
+ skill_md_data = z.read(skill_md_path)
410
+ skill_md_text = skill_md_data.decode("utf-8")
411
+
412
+ except zipfile.BadZipFile:
413
+ LOGGER.warning("Invalid or corrupt zip file: %s", zip_path)
414
+ return
415
+ except (OSError, UnicodeDecodeError) as exc:
416
+ LOGGER.warning("Cannot read zip file %s: %s", zip_path, exc)
417
+ return
418
+
419
+ # Parse metadata
420
+ match = FRONT_MATTER_PATTERN.match(skill_md_text)
421
+ if not match:
422
+ LOGGER.warning(
423
+ "Zip %s SKILL.md missing front matter; skipping", zip_path
424
+ )
425
+ return
426
+
427
+ front_matter, body = match.groups()
428
+ try:
429
+ data = yaml.safe_load(front_matter) or {}
430
+ except yaml.YAMLError as exc:
431
+ LOGGER.warning(
432
+ "Cannot parse YAML in %s SKILL.md: %s", zip_path, exc
433
+ )
434
+ return
435
+
436
+ if not isinstance(data, Mapping):
437
+ LOGGER.warning(
438
+ "Front matter in %s SKILL.md must be mapping", zip_path
439
+ )
440
+ return
441
+
442
+ name = str(data.get("name", "")).strip()
443
+ description = str(data.get("description", "")).strip()
444
+ if not name or not description:
445
+ LOGGER.warning(
446
+ "Zip %s SKILL.md missing name or description", zip_path
447
+ )
448
+ return
449
+
450
+ allowed = data.get("allowed-tools") or data.get("allowed_tools") or []
451
+ if isinstance(allowed, str):
452
+ allowed_list = tuple(
453
+ part.strip() for part in allowed.split(",") if part.strip()
454
+ )
455
+ elif isinstance(allowed, Iterable):
456
+ allowed_list = tuple(
457
+ str(item).strip() for item in allowed if str(item).strip()
458
+ )
459
+ else:
460
+ allowed_list = ()
461
+
462
+ extra = {
463
+ key: value
464
+ for key, value in data.items()
465
+ if key
466
+ not in {
467
+ "name",
468
+ "description",
469
+ "license",
470
+ "allowed-tools",
471
+ "allowed_tools",
472
+ }
473
+ }
474
+
475
+ metadata = SkillMetadata(
476
+ name=name,
477
+ description=description,
478
+ license=(
479
+ str(data["license"]).strip() if data.get("license") else None
480
+ ),
481
+ allowed_tools=allowed_list,
482
+ extra=extra,
483
+ )
484
+
485
+ # Use zip stem as slug
486
+ slug = slugify(metadata.name)
487
+ if slug in self._skills_by_slug:
488
+ LOGGER.warning(
489
+ "Duplicate skill slug '%s'; skipping zip %s",
490
+ slug,
491
+ zip_path,
492
+ )
493
+ return
494
+
495
+ if metadata.name in self._skills_by_name:
496
+ LOGGER.warning(
497
+ "Duplicate skill name '%s' found in zip %s; skipping",
498
+ metadata.name,
499
+ zip_path,
500
+ )
501
+ return
502
+
503
+ # Create skill with zip_path set
504
+ skill = Skill(
505
+ slug=slug,
506
+ directory=zip_path.parent.resolve(),
507
+ instructions_path=zip_path.resolve(),
508
+ metadata=metadata,
509
+ resources=(), # Will be populated from zip
510
+ zip_path=zip_path.resolve(),
511
+ zip_root_prefix=zip_root_prefix,
512
+ )
513
+
514
+ self._skills_by_slug[slug] = skill
515
+ self._skills_by_name[metadata.name] = skill
516
+ LOGGER.debug(
517
+ "Registered zip-based skill '%s' from %s (root_prefix='%s')",
518
+ slug,
519
+ zip_path,
520
+ zip_root_prefix,
521
+ )
522
+
523
+ def _collect_resources(self, directory: Path) -> tuple[Path, ...]:
524
+ """Collect all files in skill directory except SKILL.md.
525
+
526
+ SKILL.md is only returned from the tool, not as a resource.
527
+ All other files in the skill directory and subdirectories are
528
+ resources.
529
+
530
+ Note: For zip-based skills, resources are collected via
531
+ iter_resource_paths() directly from the Skill object.
532
+ """
533
+ root = directory.resolve()
534
+ skill_md_path = root / SKILL_MARKDOWN
535
+ files = []
536
+ for file_path in sorted(root.rglob("*")):
537
+ if not file_path.is_file():
538
+ continue
539
+ if file_path == skill_md_path:
540
+ continue
541
+ files.append(file_path)
542
+ return tuple(files)
543
+
544
+ def get(self, slug: str) -> Skill:
545
+ try:
546
+ return self._skills_by_slug[slug]
547
+ except KeyError as exc: # pragma: no cover - defensive
548
+ raise SkillError(f"Unknown skill '{slug}'") from exc
549
+
550
+
551
+ def _build_resource_uri(skill: Skill, relative_path: Path) -> str:
552
+ """Build a resource URI following MCP specification.
553
+
554
+ Format: [protocol]://[host]/[path]
555
+ Example: resource://skillz/skill-name/path/to/file.ext
556
+ """
557
+ encoded_slug = quote(skill.slug, safe="")
558
+ encoded_parts = [quote(part, safe="") for part in relative_path.parts]
559
+ path_suffix = "/".join(encoded_parts)
560
+ return f"resource://skillz/{encoded_slug}/{path_suffix}"
561
+
562
+
563
+ def _get_resource_name(skill: Skill, relative_path: Path) -> str:
564
+ """Get resource name (path without protocol) following MCP specification.
565
+
566
+ This is the URI path without the protocol prefix.
567
+ Example: skillz/skill-name/path/to/file.ext
568
+ """
569
+ return f"{skill.slug}/{relative_path.as_posix()}"
570
+
571
+
572
+ def _detect_mime_type(file_path: Path) -> Optional[str]:
573
+ """Detect MIME type for a file, returning None if unknown.
574
+
575
+ Uses Python's mimetypes library for detection.
576
+ """
577
+ mime_type, _ = mimetypes.guess_type(str(file_path))
578
+ return mime_type
579
+
580
+
581
+ def _make_error_resource(resource_uri: str, message: str) -> Dict[str, Any]:
582
+ """Create an error resource response.
583
+
584
+ Returns a resource-shaped JSON with an error message.
585
+ Used when resource URI is invalid or resource cannot be found.
586
+ """
587
+ # Try to extract a name from the URI
588
+ name = "invalid resource"
589
+ if resource_uri.startswith("resource://skillz/"):
590
+ try:
591
+ path_part = resource_uri[len("resource://skillz/"):]
592
+ if path_part:
593
+ name = path_part
594
+ except Exception: # pragma: no cover - defensive
595
+ pass
596
+
597
+ return {
598
+ "uri": resource_uri,
599
+ "name": name,
600
+ "mime_type": "text/plain",
601
+ "content": f"Error: {message}",
602
+ "encoding": "utf-8",
603
+ }
604
+
605
+
606
+ def _fetch_resource_json(
607
+ registry: SkillRegistry, resource_uri: str
608
+ ) -> Dict[str, Any]:
609
+ """Fetch a resource by URI and return as JSON.
610
+
611
+ Returns a dict with fields: uri, name, mime_type, content, encoding.
612
+ On any error, returns an error resource (never raises).
613
+ """
614
+ # Validate URI prefix
615
+ if not resource_uri.startswith("resource://skillz/"):
616
+ return _make_error_resource(
617
+ resource_uri,
618
+ "unsupported URI prefix. Expected resource://skillz/{skill-slug}/{path}",
619
+ )
620
+
621
+ # Parse slug and path
622
+ remainder = resource_uri[len("resource://skillz/"):]
623
+ if not remainder:
624
+ return _make_error_resource(
625
+ resource_uri, "invalid resource URI format"
626
+ )
627
+
628
+ parts = remainder.split("/", 1)
629
+ if len(parts) != 2 or not parts[0] or not parts[1]:
630
+ return _make_error_resource(
631
+ resource_uri, "invalid resource URI format"
632
+ )
633
+
634
+ slug = unquote(parts[0])
635
+ rel_path_str = unquote(parts[1])
636
+
637
+ # Validate path doesn't traverse upward
638
+ if ".." in rel_path_str or rel_path_str.startswith("/"):
639
+ return _make_error_resource(
640
+ resource_uri, "invalid path: path traversal not allowed"
641
+ )
642
+
643
+ # Lookup skill
644
+ try:
645
+ skill = registry.get(slug)
646
+ except SkillError:
647
+ return _make_error_resource(resource_uri, f"skill not found: {slug}")
648
+
649
+ # Check if resource exists
650
+ if skill.is_zip:
651
+ # For zip-based skills, check if resource exists
652
+ if not skill.exists(rel_path_str):
653
+ return _make_error_resource(
654
+ resource_uri, f"resource not found: {rel_path_str}"
655
+ )
656
+ else:
657
+ # For directory-based skills, find in resources list
658
+ rel_path = Path(rel_path_str)
659
+ resource_file: Optional[Path] = None
660
+
661
+ for resource_path in skill.resources:
662
+ try:
663
+ resource_relative = resource_path.relative_to(
664
+ skill.directory
665
+ )
666
+ if resource_relative == rel_path:
667
+ resource_file = resource_path
668
+ break
669
+ except ValueError: # pragma: no cover - defensive
670
+ continue
671
+
672
+ if resource_file is None:
673
+ return _make_error_resource(
674
+ resource_uri, f"resource not found: {rel_path_str}"
675
+ )
676
+
677
+ # Detect MIME type (from path string)
678
+ mime_type, _ = mimetypes.guess_type(rel_path_str)
679
+
680
+ # Read content
681
+ try:
682
+ if skill.is_zip:
683
+ data = skill.open_bytes(rel_path_str)
684
+ else:
685
+ data = resource_file.read_bytes()
686
+ except (OSError, KeyError) as exc:
687
+ return _make_error_resource(
688
+ resource_uri, f"failed to read resource: {exc}"
689
+ )
690
+
691
+ # Try to decode as UTF-8 text; if that fails, encode as base64
692
+ try:
693
+ content = data.decode("utf-8")
694
+ encoding = "utf-8"
695
+ except UnicodeDecodeError:
696
+ content = base64.b64encode(data).decode("ascii")
697
+ encoding = "base64"
698
+
699
+ # Build resource name
700
+ name = f"{skill.slug}/{rel_path_str}"
701
+
702
+ return {
703
+ "uri": resource_uri,
704
+ "name": name,
705
+ "mime_type": mime_type,
706
+ "content": content,
707
+ "encoding": encoding,
708
+ }
709
+
710
+
711
+ def register_skill_resources(
712
+ mcp: FastMCP, skill: Skill
713
+ ) -> tuple[SkillResourceMetadata, ...]:
714
+ """Register FastMCP resources for each file in a skill.
715
+
716
+ Resources follow MCP specification:
717
+ - URI format: resource://skillz/{skill-slug}/{path}
718
+ - Name: {skill-slug}/{path} (URI without protocol)
719
+ - MIME type: Detected from file extension
720
+ - Content: UTF-8 text or base64-encoded binary
721
+
722
+ Handles both directory-based and zip-based skills.
723
+ """
724
+
725
+ metadata: list[SkillResourceMetadata] = []
726
+
727
+ if skill.is_zip:
728
+ # For zip-based skills, iterate over resources from zip
729
+ for rel_path_str in skill.iter_resource_paths():
730
+ # Build URI and name
731
+ slug_encoded = quote(skill.slug, safe="")
732
+ path_encoded = quote(rel_path_str, safe="/")
733
+ uri = f"resource://skillz/{slug_encoded}/{path_encoded}"
734
+ name = f"{skill.slug}/{rel_path_str}"
735
+ mime_type, _ = mimetypes.guess_type(rel_path_str)
736
+
737
+ def _make_zip_resource_reader(
738
+ s: Skill, p: str
739
+ ) -> Callable[[], str | bytes]:
740
+ def _read_resource() -> str | bytes:
741
+ try:
742
+ data = s.open_bytes(p)
743
+ except (OSError, KeyError) as exc: # pragma: no cover
744
+ raise SkillError(
745
+ f"Failed to read resource '{p}' from zip: {exc}"
746
+ ) from exc
747
+
748
+ # Try to decode as UTF-8 text; if that fails, return binary
749
+ try:
750
+ return data.decode("utf-8")
751
+ except UnicodeDecodeError:
752
+ # FastMCP will handle base64 encoding for binary
753
+ return data
754
+
755
+ return _read_resource
756
+
757
+ mcp.resource(uri, name=name, mime_type=mime_type)(
758
+ _make_zip_resource_reader(skill, rel_path_str)
759
+ )
760
+
761
+ metadata.append(
762
+ {
763
+ "uri": uri,
764
+ "name": name,
765
+ "mime_type": mime_type,
766
+ }
767
+ )
768
+ else:
769
+ # For directory-based skills, iterate over file paths
770
+ for resource_path in skill.resources:
771
+ try:
772
+ relative_path = resource_path.relative_to(skill.directory)
773
+ except ValueError: # pragma: no cover - defensive safeguard
774
+ relative_path = Path(resource_path.name)
775
+
776
+ uri = _build_resource_uri(skill, relative_path)
777
+ name = _get_resource_name(skill, relative_path)
778
+ mime_type = _detect_mime_type(resource_path)
779
+
780
+ def _make_resource_reader(
781
+ path: Path,
782
+ ) -> Callable[[], str | bytes]:
783
+ def _read_resource() -> str | bytes:
784
+ try:
785
+ data = path.read_bytes()
786
+ except OSError as exc: # pragma: no cover
787
+ raise SkillError(
788
+ f"Failed to read resource '{path}': {exc}"
789
+ ) from exc
790
+
791
+ # Try to decode as UTF-8 text; if that fails, return binary
792
+ try:
793
+ return data.decode("utf-8")
794
+ except UnicodeDecodeError:
795
+ # FastMCP will handle base64 encoding for binary data
796
+ return data
797
+
798
+ return _read_resource
799
+
800
+ mcp.resource(uri, name=name, mime_type=mime_type)(
801
+ _make_resource_reader(resource_path)
802
+ )
803
+
804
+ metadata.append(
805
+ {
806
+ "uri": uri,
807
+ "name": name,
808
+ "mime_type": mime_type,
809
+ }
810
+ )
811
+
812
+ return tuple(metadata)
813
+
814
+
815
+ def _format_tool_description(skill: Skill) -> str:
816
+ """Return the concise skill description for discovery responses."""
817
+
818
+ description = skill.metadata.description.strip()
819
+ if not description: # pragma: no cover - defensive safeguard
820
+ raise SkillValidationError(
821
+ f"Skill {skill.slug} is missing a description after validation."
822
+ )
823
+
824
+ # Enhanced description that makes it clear this is a skill tool
825
+ return (
826
+ f"[SKILL] {description} - "
827
+ "Invoke this to receive specialized instructions and "
828
+ "resources for this task."
829
+ )
830
+
831
+
832
+ def register_skill_tool(
833
+ mcp: FastMCP,
834
+ skill: Skill,
835
+ *,
836
+ resources: tuple[SkillResourceMetadata, ...],
837
+ ) -> Callable[..., Awaitable[Mapping[str, Any]]]:
838
+ """Register a tool that returns skill instructions and resource URIs.
839
+
840
+ Clients are expected to read the instructions and retrieve any
841
+ referenced resources from the MCP server as needed.
842
+ """
843
+ tool_name = skill.slug
844
+ description = _format_tool_description(skill)
845
+ bound_skill = skill
846
+ bound_resources = resources
847
+
848
+ @mcp.tool(name=tool_name, description=description)
849
+ async def _skill_tool( # type: ignore[unused-ignore]
850
+ task: str,
851
+ ctx: Optional[Context] = None,
852
+ ) -> Mapping[str, Any]:
853
+ LOGGER.info(
854
+ "Skill %s tool invoked task=%s",
855
+ bound_skill.slug,
856
+ task,
857
+ )
858
+
859
+ try:
860
+ if not task.strip():
861
+ raise SkillError(
862
+ "The 'task' parameter must be a non-empty string."
863
+ )
864
+
865
+ instructions = bound_skill.read_body()
866
+ resource_entries = [
867
+ {
868
+ "uri": entry["uri"],
869
+ "name": entry["name"],
870
+ "mime_type": entry["mime_type"],
871
+ }
872
+ for entry in bound_resources
873
+ ]
874
+
875
+ response: dict[str, Any] = {
876
+ "skill": bound_skill.slug,
877
+ "task": task,
878
+ "metadata": {
879
+ "name": bound_skill.metadata.name,
880
+ "description": bound_skill.metadata.description,
881
+ "license": bound_skill.metadata.license,
882
+ "allowed_tools": list(bound_skill.metadata.allowed_tools),
883
+ "extra": bound_skill.metadata.extra,
884
+ },
885
+ "resources": resource_entries,
886
+ "instructions": instructions,
887
+ "usage": textwrap.dedent(
888
+ """\
889
+ HOW TO USE THIS SKILL:
890
+
891
+ 1. READ the instructions carefully - they contain
892
+ specialized guidance for completing the task.
893
+
894
+ 2. UNDERSTAND the context:
895
+ - The 'task' field contains the specific request
896
+ - The 'metadata.allowed_tools' list specifies which
897
+ tools to use when applying this skill (if specified,
898
+ respect these constraints)
899
+ - The 'resources' array lists additional files
900
+
901
+ 3. APPLY the skill instructions to complete the task:
902
+ - Follow the instructions as your primary guidance
903
+ - Use judgment to adapt instructions to the task
904
+ - Instructions are authored by skill creators and may
905
+ contain domain-specific expertise, best practices,
906
+ or specialized techniques
907
+
908
+ 4. ACCESS resources when needed:
909
+ - If instructions reference additional files or you
910
+ need them, retrieve from the MCP server
911
+ - PREFERRED: Use native MCP resource fetching if your
912
+ client supports it (use URIs from 'resources' field)
913
+ - FALLBACK: If your client lacks MCP resource support,
914
+ call the fetch_resource tool with the URI. Example:
915
+ fetch_resource(resource_uri="resource://skillz/...")
916
+
917
+ 5. RESPECT constraints:
918
+ - If 'metadata.allowed_tools' is specified and
919
+ non-empty, prefer using only those tools when
920
+ executing the skill instructions
921
+ - This helps ensure the skill works as intended
922
+
923
+ Remember: Skills are specialized instruction sets
924
+ created by experts. They provide domain knowledge and
925
+ best practices you can apply to user tasks.
926
+ """
927
+ ).strip(),
928
+ }
929
+
930
+ return response
931
+ except SkillError as exc:
932
+ LOGGER.error(
933
+ "Skill %s invocation failed: %s",
934
+ bound_skill.slug,
935
+ exc,
936
+ exc_info=True,
937
+ )
938
+ raise ToolError(str(exc)) from exc
939
+
940
+ return _skill_tool
941
+
942
+
943
+ def configure_logging(verbose: bool, log_to_file: bool) -> None:
944
+ """Set up console logging and optional file logging."""
945
+
946
+ log_format = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
947
+ handlers: list[logging.Handler] = []
948
+
949
+ console_handler = logging.StreamHandler()
950
+ console_handler.setLevel(logging.DEBUG if verbose else logging.INFO)
951
+ console_handler.setFormatter(logging.Formatter(log_format))
952
+ handlers.append(console_handler)
953
+
954
+ if log_to_file:
955
+ log_path = Path("/tmp/skillz.log")
956
+ try:
957
+ log_path.parent.mkdir(parents=True, exist_ok=True)
958
+ file_handler = logging.FileHandler(
959
+ log_path, mode="w", encoding="utf-8"
960
+ )
961
+ except OSError as exc: # pragma: no cover - filesystem failure is rare
962
+ print(
963
+ f"Failed to configure log file {log_path}: {exc}",
964
+ file=sys.stderr,
965
+ )
966
+ else:
967
+ file_handler.setLevel(logging.DEBUG)
968
+ file_handler.setFormatter(logging.Formatter(log_format))
969
+ handlers.append(file_handler)
970
+
971
+ logging.basicConfig(
972
+ level=logging.DEBUG if (log_to_file or verbose) else logging.INFO,
973
+ handlers=handlers,
974
+ force=True,
975
+ )
976
+
977
+
978
+ def build_server(registry: SkillRegistry) -> FastMCP:
979
+ summary = (
980
+ ", ".join(skill.metadata.name for skill in registry.skills)
981
+ or "No skills"
982
+ )
983
+
984
+ # Comprehensive server-level instructions for AI agents
985
+ skill_count = len(registry.skills)
986
+ server_instructions = textwrap.dedent(
987
+ f"""\
988
+ SKILLZ MCP SERVER - Specialized Instruction Provider
989
+
990
+ This server provides access to {skill_count} skill(s):
991
+ {summary}
992
+
993
+ ## WHAT ARE SKILLS?
994
+
995
+ Skills are specialized instruction sets created by domain experts.
996
+ Each skill provides detailed guidance, best practices, and
997
+ techniques for completing specific types of tasks. Think of skills
998
+ as expert knowledge packages that you can apply to user requests.
999
+
1000
+ ## WHEN TO USE SKILLS
1001
+
1002
+ Consider using a skill when:
1003
+ - A user's request matches a skill's description or domain
1004
+ - You need specialized knowledge or domain expertise
1005
+ - A task would benefit from expert-authored instructions or best
1006
+ practices
1007
+ - The skill provides relevant tools, resources, or techniques
1008
+
1009
+ You should still use your own judgment about whether a skill is
1010
+ appropriate for the specific task at hand.
1011
+
1012
+ ## HOW TO USE SKILLS
1013
+
1014
+ 1. DISCOVER: Review available skill tools (they're marked with
1015
+ [SKILL] prefix) to understand what specialized instructions
1016
+ are available.
1017
+
1018
+ 2. INVOKE: When a skill is relevant to a user's task, invoke the
1019
+ skill tool with the 'task' parameter describing what the user
1020
+ wants to accomplish.
1021
+
1022
+ 3. RECEIVE: The skill tool returns a structured response with:
1023
+ - instructions: Detailed guidance from the skill author
1024
+ - metadata: Info about the skill (name, allowed_tools, etc.)
1025
+ - resources: Additional files (scripts, datasets, etc.)
1026
+ - usage: Instructions for how to apply the skill
1027
+
1028
+ 4. APPLY: Read and follow the skill instructions to complete the
1029
+ user's task. Use your judgment to adapt the instructions to
1030
+ the specific request.
1031
+
1032
+ 5. RESOURCES: If the skill references additional files or you
1033
+ need them, retrieve them using MCP resources (preferred) or
1034
+ the fetch_resource tool (fallback for clients without native
1035
+ MCP resource support).
1036
+
1037
+ ## IMPORTANT GUIDELINES
1038
+
1039
+ - Skills provide INSTRUCTIONS, not direct execution - you still
1040
+ need to apply the instructions to complete the user's task
1041
+ - Respect the 'allowed_tools' metadata when specified - these
1042
+ are tool constraints that help ensure the skill works as
1043
+ intended
1044
+ - Skills may contain domain expertise beyond your training data
1045
+ - treat their instructions as authoritative guidance from
1046
+ experts
1047
+ - You can invoke multiple skills if relevant to different
1048
+ aspects of a task
1049
+ - Always read the 'usage' field in skill responses for specific
1050
+ guidance
1051
+
1052
+ ## SKILL TOOLS VS REGULAR TOOLS
1053
+
1054
+ - Skill tools (marked [SKILL]): Return specialized instructions
1055
+ for you to apply
1056
+ - Regular tools: Perform direct actions
1057
+
1058
+ When you see a [SKILL] tool, invoking it gives you expert
1059
+ instructions, not a completed result. You apply those
1060
+ instructions to help the user.
1061
+ """
1062
+ ).strip()
1063
+
1064
+ mcp = FastMCP(
1065
+ name=SERVER_NAME,
1066
+ version=SERVER_VERSION,
1067
+ instructions=server_instructions,
1068
+ )
1069
+
1070
+ # Register fetch_resource tool for clients without MCP resource support
1071
+ @mcp.tool(
1072
+ name="fetch_resource",
1073
+ description=(
1074
+ "[FALLBACK ONLY] Fetch a skill resource by URI. "
1075
+ "IMPORTANT: Only use this if your client does NOT support "
1076
+ "native MCP resource fetching. If your client supports MCP "
1077
+ "resources, use the native resource fetching mechanism "
1078
+ "instead. This tool only supports URIs in the format: "
1079
+ "resource://skillz/{skill-slug}/{path}. Resource URIs are "
1080
+ "provided in skill tool responses under the 'resources' "
1081
+ "field."
1082
+ ),
1083
+ )
1084
+ async def fetch_resource(
1085
+ resource_uri: str,
1086
+ ctx: Optional[Context] = None,
1087
+ ) -> Mapping[str, Any]:
1088
+ """Fetch a resource by URI and return its content."""
1089
+ LOGGER.info("fetch_resource invoked for URI: %s", resource_uri)
1090
+
1091
+ if not resource_uri:
1092
+ result = _make_error_resource(
1093
+ "(missing)", "resource_uri is required"
1094
+ )
1095
+ else:
1096
+ try:
1097
+ result = _fetch_resource_json(registry, resource_uri)
1098
+ except Exception as exc: # pragma: no cover - defensive
1099
+ LOGGER.error(
1100
+ "Unexpected error fetching resource %s: %s",
1101
+ resource_uri,
1102
+ exc,
1103
+ exc_info=True,
1104
+ )
1105
+ result = _make_error_resource(
1106
+ resource_uri, f"unexpected error: {exc}"
1107
+ )
1108
+
1109
+ return result
1110
+
1111
+ # Register progressive disclosure tools (3 universal tools)
1112
+ register_progressive_disclosure_tools(mcp, registry)
1113
+
1114
+ LOGGER.info(
1115
+ "Registered 3 progressive disclosure tools for %d skills",
1116
+ len(registry.skills)
1117
+ )
1118
+
1119
+ return mcp
1120
+
1121
+
1122
+ def list_skills(registry: SkillRegistry) -> None:
1123
+ if not registry.skills:
1124
+ print("No valid skills discovered.")
1125
+ return
1126
+ for skill in registry.skills:
1127
+ print(
1128
+ f"- {skill.metadata.name} (slug: {skill.slug}) -> ",
1129
+ skill.directory,
1130
+ sep="",
1131
+ )
1132
+
1133
+
1134
+ def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
1135
+ parser = argparse.ArgumentParser(description="Run the Skillz MCP server.")
1136
+ parser.add_argument(
1137
+ "skills_root",
1138
+ type=Path,
1139
+ nargs="?",
1140
+ help=(
1141
+ "Directory containing skill folders "
1142
+ f"(default: {DEFAULT_SKILLS_ROOT})"
1143
+ ),
1144
+ )
1145
+ parser.add_argument(
1146
+ "--transport",
1147
+ choices=("stdio", "http", "sse"),
1148
+ default="stdio",
1149
+ help="Transport to use when running the server",
1150
+ )
1151
+ parser.add_argument(
1152
+ "--host",
1153
+ default="127.0.0.1",
1154
+ help="Host for HTTP/SSE transports",
1155
+ )
1156
+ parser.add_argument(
1157
+ "--port",
1158
+ type=int,
1159
+ default=8000,
1160
+ help="Port for HTTP/SSE transports",
1161
+ )
1162
+ parser.add_argument(
1163
+ "--path",
1164
+ default="/mcp",
1165
+ help="Path for HTTP transport",
1166
+ )
1167
+ parser.add_argument(
1168
+ "--verbose",
1169
+ action="store_true",
1170
+ help="Enable debug logging",
1171
+ )
1172
+ parser.add_argument(
1173
+ "--log",
1174
+ action="store_true",
1175
+ help="Write very verbose logs to /tmp/skillz.log",
1176
+ )
1177
+ parser.add_argument(
1178
+ "--list-skills",
1179
+ action="store_true",
1180
+ help="List parsed skills and exit without starting the server",
1181
+ )
1182
+ parser.add_argument(
1183
+ "--generate-metadata",
1184
+ action="store_true",
1185
+ help="Generate skill metadata and exit (for system prompt integration)",
1186
+ )
1187
+ parser.add_argument(
1188
+ "--format",
1189
+ choices=["markdown", "json"],
1190
+ default="markdown",
1191
+ help="Output format for metadata (default: markdown)",
1192
+ )
1193
+ args = parser.parse_args(argv)
1194
+ skills_root = args.skills_root or DEFAULT_SKILLS_ROOT
1195
+ if not isinstance(skills_root, Path):
1196
+ skills_root = Path(skills_root)
1197
+ args.skills_root = skills_root.expanduser()
1198
+ return args
1199
+
1200
+
1201
+ def main(argv: Optional[list[str]] = None) -> None:
1202
+ args = parse_args(argv)
1203
+ configure_logging(args.verbose, args.log)
1204
+
1205
+ if args.log:
1206
+ LOGGER.info("Verbose file logging enabled at /tmp/skillz.log")
1207
+
1208
+ registry = SkillRegistry(args.skills_root)
1209
+ registry.load()
1210
+
1211
+ # Handle metadata generation mode
1212
+ if hasattr(args, 'generate_metadata') and args.generate_metadata:
1213
+ generator = MetadataGenerator(registry)
1214
+
1215
+ if hasattr(args, 'format') and args.format == "json":
1216
+ output = generator.generate_json_metadata()
1217
+ else:
1218
+ output = generator.generate_system_prompt_metadata()
1219
+
1220
+ print(output)
1221
+ return
1222
+
1223
+ if args.list_skills:
1224
+ list_skills(registry)
1225
+ return
1226
+
1227
+ server = build_server(registry)
1228
+ run_kwargs: dict[str, Any] = {"transport": args.transport}
1229
+ if args.transport in {"http", "sse"}:
1230
+ run_kwargs.update({"host": args.host, "port": args.port})
1231
+ if args.transport == "http":
1232
+ run_kwargs["path"] = args.path
1233
+
1234
+ server.run(**run_kwargs)
1235
+
1236
+
1237
+ if __name__ == "__main__":
1238
+ main()