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.
- progressive_skills_mcp/__init__.py +29 -0
- progressive_skills_mcp/__main__.py +13 -0
- progressive_skills_mcp/_server.py +1238 -0
- progressive_skills_mcp/_version.py +5 -0
- progressive_skills_mcp/progressive_disclosure.py +164 -0
- progressive_skills_mcp/skills/context7-docs-lookup.zip +0 -0
- progressive_skills_mcp-0.2.0.dist-info/METADATA +228 -0
- progressive_skills_mcp-0.2.0.dist-info/RECORD +10 -0
- progressive_skills_mcp-0.2.0.dist-info/WHEEL +4 -0
- progressive_skills_mcp-0.2.0.dist-info/entry_points.txt +3 -0
|
@@ -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()
|