Skip to content

Commit 156475d

Browse files
Merge pull request #9 from intellectronica/feature/improve-resources
Improve resource handling to follow MCP specification
2 parents d804cb4 + dc436ab commit 156475d

File tree

3 files changed

+202
-41
lines changed

3 files changed

+202
-41
lines changed

src/skillz/_server.py

Lines changed: 68 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,5 @@
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-
133
Skills provide instructions and resources via MCP. Clients are responsible
144
for reading resources (including any scripts) and executing them if needed.
155
"""
@@ -18,6 +8,7 @@
188

199
import argparse
2010
import logging
11+
import mimetypes
2112
import re
2213
import sys
2314
import 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)
7160
class SkillMetadata:
7261
"""Structured metadata extracted from a skill front matter block."""
@@ -103,10 +92,17 @@ def read_body(self) -> str:
10392

10493

10594
class 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

112108
def 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-
289290
def _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

300320
def 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

475502
def 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,

tests/test_resources.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""Test that resource metadata follows MCP specification."""
2+
3+
from pathlib import Path
4+
5+
from skillz import SkillRegistry
6+
from skillz._server import register_skill_resources
7+
8+
9+
def write_skill_with_resources(
10+
directory: Path, name: str = "TestSkill"
11+
) -> Path:
12+
"""Create a test skill with multiple resource types."""
13+
skill_dir = directory / name.lower()
14+
skill_dir.mkdir()
15+
16+
# Create SKILL.md
17+
(skill_dir / "SKILL.md").write_text(
18+
f"""---
19+
name: {name}
20+
description: Test skill with resources
21+
---
22+
Test skill instructions.
23+
""",
24+
encoding="utf-8",
25+
)
26+
27+
# Create text file
28+
(skill_dir / "script.py").write_text("print('hello')", encoding="utf-8")
29+
30+
# Create another text file
31+
(skill_dir / "README.md").write_text("# README", encoding="utf-8")
32+
33+
# Create binary file
34+
(skill_dir / "data.bin").write_bytes(b"\x00\x01\x02\x03")
35+
36+
return skill_dir
37+
38+
39+
def test_resource_metadata_follows_mcp_spec(tmp_path: Path) -> None:
40+
"""Resources should have uri, name, and mimeType (no description)."""
41+
write_skill_with_resources(tmp_path, name="TestSkill")
42+
43+
registry = SkillRegistry(tmp_path)
44+
registry.load()
45+
46+
skill = registry.get("testskill")
47+
48+
# Get resource metadata
49+
from fastmcp import FastMCP
50+
51+
mcp = FastMCP()
52+
metadata = register_skill_resources(mcp, skill)
53+
54+
# Should have 3 resources (script.py, README.md, data.bin)
55+
# SKILL.md is NOT a resource - it's only returned from the tool
56+
assert len(metadata) == 3
57+
58+
# Check each resource has required fields
59+
for resource in metadata:
60+
# Must have these fields according to MCP spec
61+
assert "uri" in resource
62+
assert "name" in resource
63+
assert "mime_type" in resource
64+
65+
# Should NOT have these fields
66+
assert "description" not in resource
67+
assert "relative_path" not in resource
68+
69+
# URI should follow MCP format: protocol://host/path
70+
assert resource["uri"].startswith("resource://skillz/testskill/")
71+
72+
# Name should be path without protocol
73+
assert resource["name"].startswith("testskill/")
74+
assert not resource["name"].startswith("resource://")
75+
76+
# SKILL.md should NOT be in resources
77+
assert "SKILL.md" not in resource["name"]
78+
79+
80+
def test_resource_mime_types(tmp_path: Path) -> None:
81+
"""MIME types should be detected correctly."""
82+
write_skill_with_resources(tmp_path, name="TestSkill")
83+
84+
registry = SkillRegistry(tmp_path)
85+
registry.load()
86+
87+
skill = registry.get("testskill")
88+
89+
from fastmcp import FastMCP
90+
91+
mcp = FastMCP()
92+
metadata = register_skill_resources(mcp, skill)
93+
94+
# Create lookup by name
95+
resources_by_name = {r["name"]: r for r in metadata}
96+
97+
# SKILL.md should NOT be in resources
98+
assert "testskill/SKILL.md" not in resources_by_name
99+
100+
# Check MIME types for actual resources
101+
assert (
102+
resources_by_name["testskill/script.py"]["mime_type"]
103+
== "text/x-python"
104+
)
105+
assert (
106+
resources_by_name["testskill/README.md"]["mime_type"]
107+
== "text/markdown"
108+
)
109+
# Binary files may not have a detected MIME type
110+
assert resources_by_name["testskill/data.bin"]["mime_type"] in [
111+
None,
112+
"application/octet-stream",
113+
]
114+
115+
116+
def test_resource_uris_use_resource_protocol(tmp_path: Path) -> None:
117+
"""Resource URIs should use resource:// protocol, not file://."""
118+
write_skill_with_resources(tmp_path, name="TestSkill")
119+
120+
registry = SkillRegistry(tmp_path)
121+
registry.load()
122+
123+
skill = registry.get("testskill")
124+
125+
from fastmcp import FastMCP
126+
127+
mcp = FastMCP()
128+
metadata = register_skill_resources(mcp, skill)
129+
130+
# All URIs should use resource:// protocol
131+
for resource in metadata:
132+
assert resource["uri"].startswith("resource://")
133+
assert not resource["uri"].startswith("file://")

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)