From b1d7b7f6e6c512dc34d8b81510960f84a36cfbf6 Mon Sep 17 00:00:00 2001 From: olaservo Date: Sat, 7 Feb 2026 19:49:23 -0700 Subject: [PATCH 01/21] Add Skills as Resources reference implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrate the MCP Resources approach to skill delivery using the skill:// URI scheme. This answers Open Question #12 ("why not just resources?") with a working implementation. Five resource types: - skill://index — JSON index of all skills - skill://prompt-xml — XML for system prompt injection - skill://{name} — individual skill SKILL.md content - skill://{name}/documents — supplementary file listing - skill://{name}/document/{path} — individual document (ResourceTemplate) Includes TypeScript and Python implementations with shared sample skills, matching the structure of the skills-as-tools example. Co-Authored-By: Keith Groves <6021332+keithagroves@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 --- .gitignore | 11 + examples/skills-as-resources/README.md | 168 ++ .../skills-as-resources/python/pyproject.toml | 20 + .../src/skills_as_resources/__init__.py | 0 .../skills_as_resources/resource_helpers.py | 71 + .../python/src/skills_as_resources/server.py | 209 ++ .../skills_as_resources/skill_discovery.py | 328 ++++ .../sample-skills/code-review/SKILL.md | 47 + .../code-review/references/REFERENCE.md | 36 + .../sample-skills/git-commit-review/SKILL.md | 42 + .../typescript/package-lock.json | 1736 +++++++++++++++++ .../typescript/package.json | 25 + .../typescript/src/index.ts | 278 +++ .../typescript/src/resource-helpers.ts | 87 + .../typescript/src/skill-discovery.ts | 318 +++ .../typescript/src/types.ts | 44 + .../typescript/tsconfig.json | 14 + 17 files changed, 3434 insertions(+) create mode 100644 examples/skills-as-resources/README.md create mode 100644 examples/skills-as-resources/python/pyproject.toml create mode 100644 examples/skills-as-resources/python/src/skills_as_resources/__init__.py create mode 100644 examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py create mode 100644 examples/skills-as-resources/python/src/skills_as_resources/server.py create mode 100644 examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py create mode 100644 examples/skills-as-resources/sample-skills/code-review/SKILL.md create mode 100644 examples/skills-as-resources/sample-skills/code-review/references/REFERENCE.md create mode 100644 examples/skills-as-resources/sample-skills/git-commit-review/SKILL.md create mode 100644 examples/skills-as-resources/typescript/package-lock.json create mode 100644 examples/skills-as-resources/typescript/package.json create mode 100644 examples/skills-as-resources/typescript/src/index.ts create mode 100644 examples/skills-as-resources/typescript/src/resource-helpers.ts create mode 100644 examples/skills-as-resources/typescript/src/skill-discovery.ts create mode 100644 examples/skills-as-resources/typescript/src/types.ts create mode 100644 examples/skills-as-resources/typescript/tsconfig.json diff --git a/.gitignore b/.gitignore index d6b130c..b445672 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,12 @@ .claude/settings.local.json + +# Local reference repos and planning docs +%TEMP%/ +TODO.md + +# Build artifacts +node_modules/ +dist/ +__pycache__/ +*.egg-info/ +.eggs/ diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md new file mode 100644 index 0000000..f2b89b2 --- /dev/null +++ b/examples/skills-as-resources/README.md @@ -0,0 +1,168 @@ +# Skills as Resources — Reference Implementation + +> **Experimental** — This is a minimal reference implementation for evaluation by the Skills Over MCP Interest Group. Not intended for production use. + +## Pattern Overview + +This example demonstrates the **Resources approach** from [`docs/approaches.md`](../../docs/approaches.md): exposing agent skills via MCP resources using the `skill://` URI scheme. + +An MCP server scans a directory for SKILL.md files and exposes them as resources: + +| Resource | URI | MIME Type | Purpose | +| :--- | :--- | :--- | :--- | +| Index | `skill://index` | `application/json` | JSON array of all skill summaries | +| Prompt XML | `skill://prompt-xml` | `application/xml` | XML for system prompt injection | +| Skill content | `skill://{name}` | `text/markdown` | Full SKILL.md content for a specific skill | +| Document list | `skill://{name}/documents` | `application/json` | List of supplementary files (if any) | +| Document | `skill://{name}/document/{path}` | varies | Individual supplementary document | + +This is an **application-controlled** approach: the host/client decides when to read resources. See [Open Question #9](../../docs/open-questions.md) for the control model discussion. + +## How It Works + +``` +┌─────────────┐ ┌──────────────────────┐ ┌──────────────┐ +│ MCP Client │────▶│ Skills as Resources │────▶│ Skill Files │ +│ (e.g. Claude│◀────│ MCP Server │◀────│ (SKILL.md) │ +│ Code) │ └──────────────────────┘ └──────────────┘ +└─────────────┘ +``` + +1. **Startup**: Server scans the configured skills directory for `*/SKILL.md` files and supplementary documents +2. **Discovery**: Parses YAML frontmatter to extract `name` and `description` +3. **Registration**: Registers static resources for each skill, plus a `ResourceTemplate` for supplementary documents; resource descriptions include available skill names +4. **Progressive disclosure**: + - `skill://index` → Summaries only (names, descriptions, URIs) + - `skill://{name}` → Full SKILL.md content on demand + - `skill://{name}/documents` → List of supplementary files + - `skill://{name}/document/{path}` → Individual supplementary file +5. **System prompt injection**: `skill://prompt-xml` provides XML that hosts can inject into system prompts +6. **Capability declaration**: Server declares `resources.listChanged` capability (dynamic updates could be wired to a file watcher in a full implementation) + +## Implementations + +Both implementations expose the same resources with the same behavior. They share the `sample-skills/` directory as test data. + +### TypeScript + +**Prerequisites**: Node.js >= 18, npm + +```bash +cd typescript +npm install +npm run build +``` + +**Run with MCP Inspector**: +```bash +npx @modelcontextprotocol/inspector node dist/index.js ../sample-skills +``` + +**Development mode** (no build step): +```bash +npm run dev -- ../sample-skills +``` + +### Python + +**Prerequisites**: Python >= 3.10, pip (or uv) + +```bash +cd python +pip install -e . +``` + +**Run with MCP Inspector**: +```bash +npx @modelcontextprotocol/inspector -- python -m skills_as_resources.server ../sample-skills +``` + +### SDK Difference: Document Path Encoding + +The TypeScript MCP SDK supports RFC 6570 reserved expansion (`{+path}`), so document URIs use natural paths: + +``` +skill://code-review/document/references/REFERENCE.md +``` + +The Python MCP SDK uses `[^/]+` regex for all template parameters, so forward slashes in paths must be URL-encoded: + +``` +skill://code-review/document/references%2FREFERENCE.md +``` + +The SDK automatically URL-decodes the path after matching, so the handler receives the natural path in both cases. This difference is transparent to the resource handler logic. + +## Security Features + +Both implementations include: + +- **Path traversal protection** — Resolved paths are checked against the skills directory boundary using `realpathSync` (TS) / `Path.resolve()` (Python). Symlink escapes are detected. +- **Skill name validation** — Resources look up names by key in the discovered skills map. User input is never used to construct file paths. +- **Document path validation** — Paths containing `..` are rejected. All document paths are verified to be within the skills directory. +- **File size limits** — Files larger than 1MB are skipped during discovery and rejected on read. +- **Safe YAML parsing** — Python uses `yaml.safe_load()` to prevent code execution. TypeScript uses the `yaml` package which is safe by default. + +## Sample Skills + +Two sample skills are included in `sample-skills/` for testing: + +| Skill | Description | Documents | Notes | +| :--- | :--- | :--- | :--- | +| `code-review` | Structured code review methodology | `references/REFERENCE.md` | Tests document scanning and `ResourceTemplate` | +| `git-commit-review` | Review commits for quality and conventional format | None | Tests basic skill resource with no documents | + +## Key Design Decisions + +- **Resources, not tools**: Resources are application-controlled — the host/client decides when to read them. This demonstrates a fundamentally different control model than the tools approach, where the LLM decides when to invoke. See [`docs/experimental-findings.md`](../../docs/experimental-findings.md) for observations on how control model affects utilization. +- **Static resources for skills, template for documents**: Each discovered skill becomes a concrete resource visible in `resources/list`. Only supplementary document fetching uses a `ResourceTemplate`, since document paths are dynamic. +- **Progressive disclosure via URI hierarchy**: `skill://index` → `skill://{name}` → `skill://{name}/documents` → `skill://{name}/document/{path}`. Clients can fetch summaries first and load full content on demand. +- **`skill://prompt-xml` for injection**: Allows hosts to inject skill awareness into system prompts using the resources primitive, rather than embedding skill names in tool descriptions. +- **No `zod` dependency**: Unlike the tools approach, resources do not require input schemas, so the Zod dependency is not needed. + +## How This Differs from Skills as Tools + +| Aspect | Skills as Tools | Skills as Resources | +| :--- | :--- | :--- | +| Control model | Model-controlled (LLM invokes) | Application-controlled (host/client reads) | +| MCP Primitive | Tools | Resources | +| Discovery | Tool description + `list_skills` call | `resources/list` + `skill://index` | +| Loading | `read_skill(name)` tool call | `resources/read` on `skill://{name}` | +| System prompt | Via tool description embedding | Via `skill://prompt-xml` resource | +| Input validation | Zod schema on tool parameters | URI template matching | +| Supplementary files | Not demonstrated | `ResourceTemplate` for documents | + +## What This Example Intentionally Omits + +- File watching / resource subscriptions (capability is declared but not wired) +- Dynamic updates (`resources.listChanged` is declared but not triggered) +- MCP Prompts for explicit skill invocation +- GitHub sync, configuration UI +- `skill://` URI scheme registration or standardization + +## Answers to Open Question #12 + +> "Why not just use resources?" + +This implementation shows that resources **do work** for skill delivery. Key findings for evaluation: + +- **Discovery**: Skills appear in `resources/list`, making them immediately visible to any MCP-aware client +- **Progressive disclosure**: The URI hierarchy (`index` → `skill` → `documents` → `document`) provides the same layered loading as the tools approach +- **System prompt injection**: `skill://prompt-xml` provides a clean mechanism for hosts to inject skill awareness +- **Control model trade-off**: Resources are application-controlled — the host decides when/whether to read them. This may lead to lower utilization compared to model-controlled tools (see experimental findings), but gives the host more control over context management + +## Relationship to Other Approaches + +| Approach | How it differs | +| :--- | :--- | +| **1. Skills as Primitives** (SEP-2076) | Uses dedicated `skills/list` and `skills/get` protocol methods instead of resources | +| **3. Skills as Tools** (sibling example) | Uses MCP tools (model-controlled) instead of resources (application-controlled) | +| **5. Server Instructions** | Uses server instructions to point to resources instead of exposing resources directly | +| **6. Convention** | This example could become part of a documented convention pattern | + +## Inspirations and Attribution + +This reference implementation is original code inspired by patterns from: + +- **[skills-over-mcp](https://github.com/keithagroves/skills-over-mcp)** by [Keith Groves](https://github.com/keithagroves) — Resource-based skill exposure, `skill://` URI scheme, JSON index, XML prompt injection, document templates +- **[skilljack-mcp](https://github.com/olaservo/skilljack-mcp)** by [Ola Hungerford](https://github.com/olaservo) — Resource template patterns, subscription architecture, path security diff --git a/examples/skills-as-resources/python/pyproject.toml b/examples/skills-as-resources/python/pyproject.toml new file mode 100644 index 0000000..4e7d695 --- /dev/null +++ b/examples/skills-as-resources/python/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "skills-as-resources-example" +version = "0.1.0" +description = "Minimal reference implementation: Skills as MCP Resources" +requires-python = ">=3.10" +dependencies = [ + "mcp>=1.0.0", + "pyyaml>=6.0", +] +license = "Apache-2.0" + +[project.scripts] +skills-as-resources = "skills_as_resources.server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/skills_as_resources"] diff --git a/examples/skills-as-resources/python/src/skills_as_resources/__init__.py b/examples/skills-as-resources/python/src/skills_as_resources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py b/examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py new file mode 100644 index 0000000..2e46452 --- /dev/null +++ b/examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py @@ -0,0 +1,71 @@ +""" +Resource helper utilities for the Skills as Resources implementation. + +Provides XML generation for system prompt injection and MIME type mapping +for skill documents. + +Inspired by: +- skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) +""" + +from __future__ import annotations + +import os +from xml.sax.saxutils import escape + +from .skill_discovery import SkillMetadata + +# Map file extensions to MIME types +MIME_TYPES: dict[str, str] = { + ".md": "text/markdown", + ".txt": "text/plain", + ".py": "text/x-python", + ".js": "text/javascript", + ".ts": "text/typescript", + ".sh": "text/x-shellscript", + ".bash": "text/x-shellscript", + ".json": "application/json", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".xml": "application/xml", + ".html": "text/html", + ".css": "text/css", + ".sql": "text/x-sql", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", +} + + +def get_mime_type(filepath: str) -> str: + """Get the MIME type for a file based on its extension.""" + _, ext = os.path.splitext(filepath) + return MIME_TYPES.get(ext.lower(), "application/octet-stream") + + +def generate_skills_xml(skill_map: dict[str, SkillMetadata]) -> str: + """Generate XML for injecting into system prompts. + + Format: + + + code-review + Perform structured code reviews... + skill://code-review + + + """ + lines: list[str] = [""] + + for skill in skill_map.values(): + lines.append(" ") + lines.append(f" {escape(skill.name)}") + lines.append(f" {escape(skill.description)}") + lines.append(f" skill://{escape(skill.name)}") + lines.append(" ") + + lines.append("") + return "\n".join(lines) diff --git a/examples/skills-as-resources/python/src/skills_as_resources/server.py b/examples/skills-as-resources/python/src/skills_as_resources/server.py new file mode 100644 index 0000000..541aa84 --- /dev/null +++ b/examples/skills-as-resources/python/src/skills_as_resources/server.py @@ -0,0 +1,209 @@ +""" +Skills as Resources — MCP Server (Python) + +A minimal reference implementation demonstrating the Resources approach +from the Skills Over MCP Interest Group: exposing agent skills via +MCP resources using the skill:// URI scheme. + +Exposes resources: + - skill://index — JSON index of all available skills + - skill://prompt-xml — XML for system prompt injection + - skill://{name} — Individual skill SKILL.md content + - skill://{name}/documents — List of supplementary files + - skill://{name}/document/{document_path} — Individual document (template) + +Note: The Python MCP SDK does not support RFC 6570 {+path} expansion, +so document paths containing "/" are URL-encoded (e.g., references%2FREFERENCE.md). +The SDK automatically URL-decodes them after template matching. + +Inspired by: +- skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) +- skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + +License: Apache-2.0 +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from urllib.parse import quote + +from mcp.server.fastmcp import FastMCP + +from .resource_helpers import generate_skills_xml +from .skill_discovery import discover_skills, load_document, load_skill_content + +# Resolve skills directory from CLI arg or default to ../sample-skills +if len(sys.argv) > 1: + skills_dir = str(Path(sys.argv[1]).resolve()) +else: + skills_dir = str( + Path(__file__).resolve().parent.parent.parent.parent / "sample-skills" + ) + +# Discover skills at startup +skill_map = discover_skills(skills_dir) +skill_names = list(skill_map.keys()) + +print( + f"[skills-as-resources] Discovered {len(skill_map)} skill(s): " + f"{', '.join(skill_names) or 'none'}", + file=sys.stderr, +) +for name, skill in skill_map.items(): + if skill.documents: + print( + f" - {name}: {len(skill.documents)} document(s)", + file=sys.stderr, + ) + +# Create MCP server +mcp = FastMCP( + name="skills-as-resources-example", +) + + +def _encode_document_path(path: str) -> str: + """URL-encode a document path for use in skill:// URIs. + + The Python MCP SDK uses [^/]+ regex for template parameters, + so forward slashes in paths must be percent-encoded. + """ + return quote(path, safe="") + + +def _build_index() -> list[dict]: + """Build the JSON index of all skills.""" + index = [] + for s in skill_map.values(): + entry: dict = { + "name": s.name, + "description": s.description, + "uri": f"skill://{s.name}", + "documentCount": len(s.documents), + } + if s.documents: + entry["documentsUri"] = f"skill://{s.name}/documents" + if s.metadata: + entry["metadata"] = s.metadata + index.append(entry) + return index + + +def _build_document_list(skill_name: str) -> dict: + """Build the document list for a skill.""" + skill = skill_map[skill_name] + return { + "skill": skill_name, + "documents": [ + { + "path": doc.path, + "mimeType": doc.mime_type, + "size": doc.size, + "uri": f"skill://{skill_name}/document/{_encode_document_path(doc.path)}", + } + for doc in skill.documents + ], + } + + +# --- Static resources --- + +@mcp.resource( + "skill://index", + name="skills-index", + description=( + "Index of all available skills with their descriptions, URIs, and document counts. " + f"Currently available: {', '.join(skill_names) or 'none'}" + ), + mime_type="application/json", +) +def get_index() -> str: + """Return JSON index of all available skills.""" + return json.dumps(_build_index(), indent=2) + + +@mcp.resource( + "skill://prompt-xml", + name="skills-prompt-xml", + description="XML representation of available skills for injecting into system prompts", + mime_type="application/xml", +) +def get_prompt_xml() -> str: + """Return XML representation for system prompt injection.""" + return generate_skills_xml(skill_map) + + +# Per-skill static resources registered in a loop. +# Uses closure binding to avoid Python's late-binding issue. +for _skill_name, _skill_meta in skill_map.items(): + + def _register_skill(s_name: str, s_meta): # noqa: ANN001 + @mcp.resource( + f"skill://{s_name}", + name=f"skill-{s_name}", + description=s_meta.description, + mime_type="text/markdown", + ) + def _get_skill() -> str: + try: + return load_skill_content(s_meta.path, skills_dir) + except (OSError, ValueError) as exc: + return f'# Error\n\nFailed to load skill "{s_name}": {exc}' + + if s_meta.documents: + @mcp.resource( + f"skill://{s_name}/documents", + name=f"skill-{s_name}-documents", + description=f"List of supplementary documents for the {s_name} skill", + mime_type="application/json", + ) + def _get_documents() -> str: + return json.dumps(_build_document_list(s_name), indent=2) + + _register_skill(_skill_name, _skill_meta) + + +# --- Dynamic resource template --- + +@mcp.resource( + "skill://{skill_name}/document/{document_path}", + name="skill-document", + description="Fetch a specific supplementary document from a skill", + mime_type="text/plain", +) +def get_document(skill_name: str, document_path: str) -> str: + """Fetch a supplementary document from a skill. + + The document_path is automatically URL-decoded by the SDK, + so encoded paths like "references%2FREFERENCE.md" arrive as + "references/REFERENCE.md". + """ + skill = skill_map.get(skill_name) + if not skill: + available = ", ".join(skill_names) or "none" + return f'# Error\n\nSkill "{skill_name}" not found. Available: {available}' + + doc = next((d for d in skill.documents if d.path == document_path), None) + if not doc: + available = "\n".join(f"- {d.path}" for d in skill.documents) + return ( + f'# Error\n\nDocument "{document_path}" not found in skill "{skill_name}".\n\n' + f"## Available Documents\n\n{available or 'No documents available.'}" + ) + + try: + return load_document(skill, document_path, skills_dir) + except (OSError, ValueError) as exc: + return f"# Error\n\nFailed to read document: {exc}" + + +def main() -> None: + """Entry point: run the MCP server via stdio transport.""" + mcp.run(transport="stdio") + + +if __name__ == "__main__": + main() diff --git a/examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py b/examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py new file mode 100644 index 0000000..8695202 --- /dev/null +++ b/examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py @@ -0,0 +1,328 @@ +""" +Skill discovery, content loading, and document scanning module. + +Discovers Agent Skills by scanning a directory for subdirectories +containing SKILL.md files, parses YAML frontmatter for metadata, +scans for supplementary documents, and provides secure content loading. + +Inspired by: +- skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) +- skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) +""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass, field +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + +# Maximum file size for skill files (1MB) +MAX_FILE_SIZE = 1 * 1024 * 1024 + +# Map file extensions to MIME types +_MIME_TYPES: dict[str, str] = { + ".md": "text/markdown", + ".txt": "text/plain", + ".py": "text/x-python", + ".js": "text/javascript", + ".ts": "text/typescript", + ".sh": "text/x-shellscript", + ".bash": "text/x-shellscript", + ".json": "application/json", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".xml": "application/xml", + ".html": "text/html", + ".css": "text/css", + ".sql": "text/x-sql", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", +} + + +@dataclass +class SkillDocument: + """A supplementary document found in a skill's subdirectories.""" + + path: str # Relative path from skill root (e.g., "references/REFERENCE.md") + mime_type: str + size: int + + +@dataclass +class SkillMetadata: + """Metadata extracted from a skill's SKILL.md YAML frontmatter.""" + + name: str + description: str + path: str # Absolute path to the SKILL.md file + skill_dir: str # Absolute path to the skill's directory + metadata: dict[str, str] = field(default_factory=dict) + documents: list[SkillDocument] = field(default_factory=list) + + +def _get_mime_type(filepath: str) -> str: + """Get the MIME type for a file based on its extension.""" + _, ext = os.path.splitext(filepath) + return _MIME_TYPES.get(ext.lower(), "application/octet-stream") + + +def _parse_frontmatter(content: str) -> tuple[dict, str]: + """Parse YAML frontmatter from SKILL.md content. + + Returns (frontmatter_dict, body_text). + """ + if not content.startswith("---"): + raise ValueError("SKILL.md must start with YAML frontmatter (---)") + + parts = content.split("---") + if len(parts) < 3: + raise ValueError("SKILL.md frontmatter not properly closed with ---") + + # Use safe_load to prevent arbitrary code execution + frontmatter = yaml.safe_load(parts[1]) + if not isinstance(frontmatter, dict): + raise ValueError("SKILL.md frontmatter must be a YAML mapping") + + body = "---".join(parts[2:]).strip() + return frontmatter, body + + +def _is_path_within_base(target: Path, base: Path) -> bool: + """Check if a resolved path is within the allowed base directory.""" + try: + resolved_base = base.resolve(strict=True) + resolved_target = target.resolve(strict=True) + return resolved_target == resolved_base or str( + resolved_target + ).startswith(str(resolved_base) + os.sep) + except OSError: + # Fall back to non-strict resolve + resolved_base = base.resolve() + resolved_target = target.resolve() + return str(resolved_target).startswith(str(resolved_base) + os.sep) + + +def _scan_dir(dir_path: Path, relative_to: Path, base_dir: Path) -> list[SkillDocument]: + """Recursively scan a directory for files, returning SkillDocument entries.""" + documents: list[SkillDocument] = [] + + if not dir_path.is_dir(): + return documents + + try: + entries = list(dir_path.iterdir()) + except OSError: + return documents + + for entry in entries: + # Security: verify path stays within the skills directory + if not _is_path_within_base(entry, base_dir): + continue + + if entry.is_file(): + try: + stat = entry.stat() + if stat.st_size > MAX_FILE_SIZE: + continue + + relative_path = str(entry.relative_to(relative_to)).replace("\\", "/") + documents.append( + SkillDocument( + path=relative_path, + mime_type=_get_mime_type(entry.name), + size=stat.st_size, + ) + ) + except OSError: + pass + elif entry.is_dir(): + documents.extend(_scan_dir(entry, relative_to, base_dir)) + + return documents + + +def scan_documents(skill_dir: str, base_dir: str) -> list[SkillDocument]: + """Scan a skill directory for supplementary documents. + + Finds all files in subdirectories of the skill directory, + excluding SKILL.md itself. + """ + documents: list[SkillDocument] = [] + skill_path = Path(skill_dir) + base_path = Path(base_dir) + + try: + entries = list(skill_path.iterdir()) + except OSError: + return documents + + for entry in entries: + if entry.is_dir(): + documents.extend(_scan_dir(entry, skill_path, base_path)) + + return documents + + +def discover_skills(skills_dir: str) -> dict[str, SkillMetadata]: + """Discover all skills in a directory. + + Scans for immediate subdirectories containing SKILL.md files, + and scans for supplementary documents in each skill directory. + Security: skips files larger than MAX_FILE_SIZE, validates frontmatter. + """ + skill_map: dict[str, SkillMetadata] = {} + resolved_dir = Path(skills_dir).resolve() + + if not resolved_dir.is_dir(): + logger.error("Skills directory not found: %s", resolved_dir) + return skill_map + + for entry in resolved_dir.iterdir(): + if not entry.is_dir(): + continue + + # Find SKILL.md (prefer uppercase, accept lowercase) + skill_md_path = None + for name in ("SKILL.md", "skill.md"): + candidate = entry / name + if candidate.exists(): + skill_md_path = candidate + break + + if skill_md_path is None: + continue + + # Security: check file size before reading + stat = skill_md_path.stat() + if stat.st_size > MAX_FILE_SIZE: + logger.error( + "Skipping %s: file size %.2fMB exceeds limit", + skill_md_path, + stat.st_size / 1024 / 1024, + ) + continue + + # Security: verify path is within skills directory + if not _is_path_within_base(skill_md_path, resolved_dir): + logger.error( + "Skipping %s: path escapes skills directory", skill_md_path + ) + continue + + try: + content = skill_md_path.read_text(encoding="utf-8") + frontmatter, _body = _parse_frontmatter(content) + + name = frontmatter.get("name") + description = frontmatter.get("description") + + if not isinstance(name, str) or not name.strip(): + logger.error( + "Skill at %s: missing or invalid 'name' field", entry + ) + continue + if not isinstance(description, str) or not description.strip(): + logger.error( + "Skill at %s: missing or invalid 'description' field", entry + ) + continue + + # Extract optional metadata + extra_metadata: dict[str, str] = {} + raw_meta = frontmatter.get("metadata") + if isinstance(raw_meta, dict): + for k, v in raw_meta.items(): + if isinstance(v, str): + extra_metadata[k] = v + + skill_name = name.strip() + if skill_name in skill_map: + logger.warning( + "Duplicate skill name '%s' at %s — keeping first", + skill_name, + skill_md_path, + ) + continue + + # Scan for supplementary documents + skill_dir_str = str(entry) + documents = scan_documents(skill_dir_str, str(resolved_dir)) + + skill_map[skill_name] = SkillMetadata( + name=skill_name, + description=description.strip(), + path=str(skill_md_path), + skill_dir=skill_dir_str, + metadata=extra_metadata if extra_metadata else {}, + documents=documents, + ) + except (OSError, ValueError) as exc: + logger.error("Failed to parse skill at %s: %s", entry, exc) + + return skill_map + + +def load_skill_content(skill_path: str, skills_dir: str) -> str: + """Load the full content of a SKILL.md file. + + Security: validates path is within skills directory, only reads .md files, + and enforces a file size limit. + """ + target = Path(skill_path) + base = Path(skills_dir) + + # Security: only allow .md files + if target.suffix.lower() != ".md": + raise ValueError("Only .md files can be read") + + # Security: verify path is within skills directory + if not _is_path_within_base(target, base): + raise ValueError("Path escapes the skills directory") + + # Security: check file size + stat = target.stat() + if stat.st_size > MAX_FILE_SIZE: + raise ValueError( + f"File size {stat.st_size / 1024 / 1024:.2f}MB exceeds " + f"{MAX_FILE_SIZE / 1024 / 1024:.0f}MB limit" + ) + + return target.read_text(encoding="utf-8") + + +def load_document(skill: SkillMetadata, document_path: str, skills_dir: str) -> str: + """Load a supplementary document from a skill directory. + + Security: validates path is within skills directory, rejects path + traversal attempts, and enforces a file size limit. + """ + # Security: reject path traversal attempts + if ".." in document_path: + raise ValueError("Path traversal not allowed") + + target = Path(skill.skill_dir) / document_path + base = Path(skills_dir) + + # Security: verify path is within skills directory + if not _is_path_within_base(target, base): + raise ValueError("Path escapes the skills directory") + + # Security: check file size + stat = target.stat() + if stat.st_size > MAX_FILE_SIZE: + raise ValueError( + f"File size {stat.st_size / 1024 / 1024:.2f}MB exceeds " + f"{MAX_FILE_SIZE / 1024 / 1024:.0f}MB limit" + ) + + return target.read_text(encoding="utf-8") diff --git a/examples/skills-as-resources/sample-skills/code-review/SKILL.md b/examples/skills-as-resources/sample-skills/code-review/SKILL.md new file mode 100644 index 0000000..123dc3a --- /dev/null +++ b/examples/skills-as-resources/sample-skills/code-review/SKILL.md @@ -0,0 +1,47 @@ +--- +name: code-review +description: Perform structured code reviews focusing on correctness, readability, and maintainability. Use when asked to review code changes or pull requests. +metadata: + author: skills-over-mcp-ig + version: "0.1" +--- + +# Code Review + +Perform structured code reviews using a consistent methodology. + +## When to Use + +- User asks you to review code, a diff, or a pull request +- User asks for feedback on code quality +- You are evaluating code changes before merge + +## Process + +1. **Understand the context** — read the PR description or ask what the change is trying to accomplish +2. **Review for correctness** — does the code do what it claims? Are there logic errors, off-by-one bugs, or unhandled edge cases? +3. **Review for security** — check for injection vulnerabilities, improper input validation, hardcoded secrets, and OWASP top 10 issues +4. **Review for readability** — are names clear? Is the structure easy to follow? Is there unnecessary complexity? +5. **Review for maintainability** — is the code testable? Are dependencies reasonable? Will this be easy to change later? +6. **Check the tests** — are there tests? Do they cover the important cases? Are they testing behavior, not implementation? + +## Severity Levels + +- **Blocker**: Must fix before merge (security issues, data loss risk, broken functionality) +- **Major**: Should fix before merge (logic errors, missing edge cases, poor error handling) +- **Minor**: Nice to fix (naming, style, minor simplifications) +- **Nit**: Optional (personal preference, cosmetic) + +## Reference + +For a detailed checklist, see `references/REFERENCE.md` in this skill's directory. + +## Output Format + +For each finding: +- **File and line**: Where the issue is +- **Severity**: Blocker / Major / Minor / Nit +- **Issue**: What's wrong +- **Suggestion**: How to fix it + +End with an overall summary: approve, request changes, or comment. diff --git a/examples/skills-as-resources/sample-skills/code-review/references/REFERENCE.md b/examples/skills-as-resources/sample-skills/code-review/references/REFERENCE.md new file mode 100644 index 0000000..718b739 --- /dev/null +++ b/examples/skills-as-resources/sample-skills/code-review/references/REFERENCE.md @@ -0,0 +1,36 @@ +# Code Review Checklist + +## Correctness +- [ ] Logic matches the stated intent +- [ ] Edge cases handled (null, empty, boundary values) +- [ ] Error paths return meaningful messages +- [ ] Async operations properly awaited +- [ ] Resources cleaned up (connections, file handles, timers) + +## Security +- [ ] User input validated and sanitized +- [ ] No SQL injection, XSS, or command injection vectors +- [ ] No hardcoded secrets or credentials +- [ ] Authentication/authorization checks in place +- [ ] Sensitive data not logged or exposed in errors + +## Readability +- [ ] Names describe purpose (not implementation) +- [ ] Functions do one thing +- [ ] No deeply nested conditionals (max 3 levels) +- [ ] Comments explain "why", not "what" +- [ ] Consistent formatting with project style + +## Maintainability +- [ ] No code duplication (DRY where appropriate) +- [ ] Dependencies are justified +- [ ] Configuration externalized (not hardcoded) +- [ ] Backward compatibility considered +- [ ] Migration path documented if breaking + +## Testing +- [ ] Tests exist for new/changed behavior +- [ ] Tests cover happy path and error cases +- [ ] Tests are independent (no shared mutable state) +- [ ] Test names describe the scenario +- [ ] No flaky tests (timing, ordering, external dependencies) diff --git a/examples/skills-as-resources/sample-skills/git-commit-review/SKILL.md b/examples/skills-as-resources/sample-skills/git-commit-review/SKILL.md new file mode 100644 index 0000000..c2dc72d --- /dev/null +++ b/examples/skills-as-resources/sample-skills/git-commit-review/SKILL.md @@ -0,0 +1,42 @@ +--- +name: git-commit-review +description: Review git commits for quality, conventional commit format compliance, and potential issues. Use when asked to review commits or improve commit messages. +metadata: + author: skills-over-mcp-ig + version: "0.1" +--- + +# Git Commit Review + +Review git commits against conventional commit standards and common quality issues. + +## When to Use + +- User asks you to review a commit or commit message +- User asks for help improving commit quality +- You are reviewing a PR and want to assess commit hygiene + +## Process + +1. **Read the commit message** — check for conventional commit format: `type(scope): description` +2. **Verify the type** — must be one of: `feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `ci`, `build`, `perf` +3. **Check the description** — should be imperative mood, lowercase, no period at end, under 72 characters +4. **Review the body** (if present) — should explain *why* not *what*, wrapped at 72 characters +5. **Check for breaking changes** — must include `BREAKING CHANGE:` footer or `!` after type/scope +6. **Assess the diff** — does the commit message accurately describe the changes? + +## Common Issues + +- Vague messages ("fix stuff", "update code", "wip") +- Type mismatch (using `feat` for a bug fix) +- Scope too broad (single commit touching unrelated files) +- Missing breaking change annotation +- Commit contains unrelated changes that should be separate commits + +## Output Format + +Provide a structured review: +- **Format**: Pass/Fail with specific issues +- **Message quality**: Rating and suggestions +- **Scope assessment**: Whether changes match the stated scope +- **Recommendations**: Concrete improvements diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json new file mode 100644 index 0000000..2f11610 --- /dev/null +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -0,0 +1,1736 @@ +{ + "name": "@skills-over-mcp-ig/skills-as-resources-example", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@skills-over-mcp-ig/skills-as-resources-example", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.26.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", + "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@types/node": { + "version": "22.19.10", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", + "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.6", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.6.tgz", + "integrity": "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", + "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json new file mode 100644 index 0000000..381336d --- /dev/null +++ b/examples/skills-as-resources/typescript/package.json @@ -0,0 +1,25 @@ +{ + "name": "@skills-over-mcp-ig/skills-as-resources-example", + "version": "0.1.0", + "description": "Minimal reference implementation: Skills as MCP Resources", + "type": "module", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.0", + "yaml": "^2.7.0" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "Apache-2.0" +} diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts new file mode 100644 index 0000000..16a5c29 --- /dev/null +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -0,0 +1,278 @@ +#!/usr/bin/env node +/** + * Skills as Resources — MCP Server (TypeScript) + * + * A minimal reference implementation demonstrating the Resources approach + * from the Skills Over MCP Interest Group: exposing agent skills via + * MCP resources using the skill:// URI scheme. + * + * Exposes resources: + * - skill://index — JSON index of all available skills + * - skill://prompt-xml — XML for system prompt injection + * - skill://{name} — Individual skill SKILL.md content + * - skill://{name}/documents — List of supplementary files + * - skill://{name}/document/{+documentPath} — Individual document (template) + * + * Inspired by: + * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) + * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + * + * @license Apache-2.0 + */ + +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { discoverSkills, loadSkillContent, loadDocument } from "./skill-discovery.js"; +import { generateSkillsXML } from "./resource-helpers.js"; +import type { SkillSummary } from "./types.js"; + +// Resolve skills directory from CLI arg or default to ../sample-skills +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const skillsDir = process.argv[2] + ? path.resolve(process.argv[2]) + : path.resolve(__dirname, "../../sample-skills"); + +// Discover skills at startup +const skillMap = discoverSkills(skillsDir); +const skillNames = Array.from(skillMap.keys()); + +console.error( + `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${skillNames.join(", ") || "none"}` +); +for (const [name, skill] of skillMap) { + if (skill.documents.length > 0) { + console.error( + ` - ${name}: ${skill.documents.length} document(s)` + ); + } +} + +// Create MCP server with resources.listChanged capability +const server = new McpServer( + { name: "skills-as-resources-example", version: "0.1.0" }, + { capabilities: { resources: { listChanged: true } } } +); + +// --- Static resources --- + +// Resource: skill://index — JSON index of all available skills +server.registerResource( + "skills-index", + "skill://index", + { + description: + "Index of all available skills with their descriptions, URIs, and document counts. " + + `Currently available: ${skillNames.join(", ") || "none"}`, + mimeType: "application/json", + }, + async (uri) => { + const index: SkillSummary[] = Array.from(skillMap.values()).map((s) => ({ + name: s.name, + description: s.description, + uri: `skill://${s.name}`, + ...(s.documents.length > 0 && { + documentsUri: `skill://${s.name}/documents`, + }), + documentCount: s.documents.length, + ...(s.metadata && { metadata: s.metadata }), + })); + + return { + contents: [ + { + uri: uri.href, + text: JSON.stringify(index, null, 2), + }, + ], + }; + } +); + +// Resource: skill://prompt-xml — XML for system prompt injection +server.registerResource( + "skills-prompt-xml", + "skill://prompt-xml", + { + description: + "XML representation of available skills for injecting into system prompts", + mimeType: "application/xml", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: generateSkillsXML(skillMap), + }, + ], + }) +); + +// Per-skill static resources +for (const [name, skill] of skillMap) { + // Resource: skill://{name} — individual skill SKILL.md content + server.registerResource( + `skill-${name}`, + `skill://${name}`, + { + description: skill.description, + mimeType: "text/markdown", + }, + async (uri) => { + try { + const content = loadSkillContent(skill.path, skillsDir); + return { + contents: [{ uri: uri.href, text: content }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nFailed to load skill "${name}": ${message}`, + }, + ], + }; + } + } + ); + + // Resource: skill://{name}/documents — list of supplementary files + if (skill.documents.length > 0) { + server.registerResource( + `skill-${name}-documents`, + `skill://${name}/documents`, + { + description: `List of supplementary documents for the ${name} skill`, + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: JSON.stringify( + { + skill: name, + documents: skill.documents.map((doc) => ({ + path: doc.path, + mimeType: doc.mimeType, + size: doc.size, + uri: `skill://${name}/document/${doc.path}`, + })), + }, + null, + 2 + ), + }, + ], + }) + ); + } +} + +// --- Dynamic resource template --- + +// Template: skill://{skillName}/document/{+documentPath} +// The {+} prefix uses RFC 6570 reserved expansion, matching paths with slashes +server.registerResource( + "skill-document", + new ResourceTemplate("skill://{skillName}/document/{+documentPath}", { + list: async () => { + const resources = Array.from(skillMap.values()).flatMap((skill) => + skill.documents.map((doc) => ({ + uri: `skill://${skill.name}/document/${doc.path}`, + name: `${skill.name}/${doc.path}`, + description: `Document from ${skill.name} skill`, + mimeType: doc.mimeType, + })) + ); + return { resources }; + }, + complete: { + skillName: (value) => { + return Array.from(skillMap.values()) + .filter((s) => s.documents.length > 0) + .map((s) => s.name) + .filter((name) => name.startsWith(value)); + }, + documentPath: (value, context) => { + const skillName = context?.arguments?.skillName; + if (!skillName) return []; + + const skill = skillMap.get(skillName); + if (!skill) return []; + + return skill.documents + .map((d) => d.path) + .filter((p) => p.startsWith(value)); + }, + }, + }), + { + description: "Fetch a specific supplementary document from a skill", + mimeType: "text/plain", + }, + async (uri, variables) => { + const skillName = Array.isArray(variables.skillName) + ? variables.skillName[0] + : variables.skillName; + const documentPath = Array.isArray(variables.documentPath) + ? variables.documentPath[0] + : variables.documentPath; + + const skill = skillMap.get(skillName); + if (!skill) { + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nSkill "${skillName}" not found. Available: ${skillNames.join(", ") || "none"}`, + }, + ], + }; + } + + const doc = skill.documents.find((d) => d.path === documentPath); + if (!doc) { + const available = skill.documents.map((d) => `- ${d.path}`).join("\n"); + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nDocument "${documentPath}" not found in skill "${skillName}".\n\n## Available Documents\n\n${available || "No documents available."}`, + }, + ], + }; + } + + try { + const content = loadDocument(skill, documentPath, skillsDir); + return { + contents: [ + { + uri: uri.href, + text: content, + mimeType: doc.mimeType, + }, + ], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nFailed to read document: ${message}`, + }, + ], + }; + } + } +); + +// Connect via stdio transport +const transport = new StdioServerTransport(); +await server.connect(transport); +console.error("[skills-as-resources] Server connected via stdio"); diff --git a/examples/skills-as-resources/typescript/src/resource-helpers.ts b/examples/skills-as-resources/typescript/src/resource-helpers.ts new file mode 100644 index 0000000..20e8fe0 --- /dev/null +++ b/examples/skills-as-resources/typescript/src/resource-helpers.ts @@ -0,0 +1,87 @@ +/** + * Resource helper utilities for the Skills as Resources implementation. + * + * Provides XML generation for system prompt injection and MIME type mapping + * for skill documents. + * + * Inspired by: + * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + */ + +import * as path from "node:path"; +import type { SkillMetadata } from "./types.js"; + +/** Map file extensions to MIME types. */ +const MIME_TYPES: Record = { + ".md": "text/markdown", + ".txt": "text/plain", + ".py": "text/x-python", + ".js": "text/javascript", + ".ts": "text/typescript", + ".sh": "text/x-shellscript", + ".bash": "text/x-shellscript", + ".json": "application/json", + ".yaml": "text/yaml", + ".yml": "text/yaml", + ".xml": "application/xml", + ".html": "text/html", + ".css": "text/css", + ".sql": "text/x-sql", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", +}; + +/** + * Get the MIME type for a file based on its extension. + */ +export function getMimeType(filepath: string): string { + const ext = path.extname(filepath).toLowerCase(); + return MIME_TYPES[ext] || "application/octet-stream"; +} + +/** + * Escape XML special characters. + */ +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Generate XML for injecting into system prompts. + * + * Format: + * ```xml + * + * + * code-review + * Perform structured code reviews... + * skill://code-review + * + * + * ``` + */ +export function generateSkillsXML( + skillMap: Map +): string { + const lines: string[] = [""]; + + for (const skill of skillMap.values()) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push(` ${escapeXml(skill.description)}`); + lines.push(` skill://${escapeXml(skill.name)}`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} diff --git a/examples/skills-as-resources/typescript/src/skill-discovery.ts b/examples/skills-as-resources/typescript/src/skill-discovery.ts new file mode 100644 index 0000000..a00384a --- /dev/null +++ b/examples/skills-as-resources/typescript/src/skill-discovery.ts @@ -0,0 +1,318 @@ +/** + * Skill discovery, content loading, and document scanning module. + * + * Discovers Agent Skills by scanning a directory for subdirectories + * containing SKILL.md files, parses YAML frontmatter for metadata, + * scans for supplementary documents, and provides secure content loading. + * + * Inspired by: + * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) + * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { parse as parseYaml } from "yaml"; +import type { SkillMetadata, SkillDocument } from "./types.js"; +import { getMimeType } from "./resource-helpers.js"; + +/** Maximum file size for skill files (1MB). */ +const MAX_FILE_SIZE = 1 * 1024 * 1024; + +/** + * Parse YAML frontmatter from SKILL.md content. + * Expects content to start with --- and have a closing ---. + */ +function parseFrontmatter(content: string): { + frontmatter: Record; + body: string; +} { + if (!content.startsWith("---")) { + throw new Error("SKILL.md must start with YAML frontmatter (---)"); + } + + const parts = content.split("---"); + if (parts.length < 3) { + throw new Error("SKILL.md frontmatter not properly closed with ---"); + } + + const frontmatter = parseYaml(parts[1]) as Record; + if (typeof frontmatter !== "object" || frontmatter === null) { + throw new Error("SKILL.md frontmatter must be a YAML mapping"); + } + + const body = parts.slice(2).join("---").trim(); + return { frontmatter, body }; +} + +/** + * Check if a resolved path is within the allowed base directory. + * Uses fs.realpathSync to resolve symlinks and prevent escape attacks. + */ +export function isPathWithinBase( + targetPath: string, + baseDir: string +): boolean { + try { + const realBase = fs.realpathSync(baseDir); + const realTarget = fs.realpathSync(targetPath); + const normalizedBase = realBase + path.sep; + return realTarget === realBase || realTarget.startsWith(normalizedBase); + } catch { + // Fall back to resolve check if realpathSync fails + const normalizedBase = path.resolve(baseDir) + path.sep; + const normalizedPath = path.resolve(targetPath); + return normalizedPath.startsWith(normalizedBase); + } +} + +/** + * Recursively scan a directory for files, returning SkillDocument entries. + * Security: applies path traversal checks and file size limits. + */ +function scanDir( + dirPath: string, + relativeTo: string, + baseDir: string +): SkillDocument[] { + const documents: SkillDocument[] = []; + + if (!fs.existsSync(dirPath)) return documents; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(dirPath, { withFileTypes: true }); + } catch { + return documents; + } + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + + // Security: verify path stays within the skills directory + if (!isPathWithinBase(fullPath, baseDir)) continue; + + if (entry.isFile()) { + try { + const stat = fs.statSync(fullPath); + if (stat.size > MAX_FILE_SIZE) continue; + + const relativePath = path.relative(relativeTo, fullPath).replace(/\\/g, "/"); + documents.push({ + path: relativePath, + mimeType: getMimeType(entry.name), + size: stat.size, + }); + } catch { + // Skip files we can't stat + } + } else if (entry.isDirectory()) { + // Recurse into subdirectories + documents.push(...scanDir(fullPath, relativeTo, baseDir)); + } + } + + return documents; +} + +/** + * Scan a skill directory for supplementary documents. + * Finds all files in subdirectories of the skill directory, + * excluding SKILL.md itself. + */ +export function scanDocuments( + skillDir: string, + baseDir: string +): SkillDocument[] { + const documents: SkillDocument[] = []; + + let entries: fs.Dirent[]; + try { + entries = fs.readdirSync(skillDir, { withFileTypes: true }); + } catch { + return documents; + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const subDirPath = path.join(skillDir, entry.name); + documents.push(...scanDir(subDirPath, skillDir, baseDir)); + } + + return documents; +} + +/** + * Discover all skills in a directory. + * Scans for immediate subdirectories containing SKILL.md (or skill.md) files, + * and scans for supplementary documents in each skill directory. + * + * Security: Skips files larger than MAX_FILE_SIZE, validates frontmatter fields. + */ +export function discoverSkills(skillsDir: string): Map { + const skillMap = new Map(); + const resolvedDir = path.resolve(skillsDir); + + if (!fs.existsSync(resolvedDir)) { + console.error(`Skills directory not found: ${resolvedDir}`); + return skillMap; + } + + const entries = fs.readdirSync(resolvedDir, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const skillDir = path.join(resolvedDir, entry.name); + + // Find SKILL.md (prefer uppercase, accept lowercase) + let skillMdPath: string | null = null; + for (const name of ["SKILL.md", "skill.md"]) { + const candidate = path.join(skillDir, name); + if (fs.existsSync(candidate)) { + skillMdPath = candidate; + break; + } + } + + if (!skillMdPath) continue; + + // Security: check file size before reading + const stat = fs.statSync(skillMdPath); + if (stat.size > MAX_FILE_SIZE) { + console.error( + `Skipping ${skillMdPath}: file size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds limit` + ); + continue; + } + + // Security: verify path is within skills directory + if (!isPathWithinBase(skillMdPath, resolvedDir)) { + console.error(`Skipping ${skillMdPath}: path escapes skills directory`); + continue; + } + + try { + const content = fs.readFileSync(skillMdPath, "utf-8"); + const { frontmatter } = parseFrontmatter(content); + + const name = frontmatter.name; + const description = frontmatter.description; + + if (typeof name !== "string" || !name.trim()) { + console.error(`Skill at ${skillDir}: missing or invalid 'name' field`); + continue; + } + if (typeof description !== "string" || !description.trim()) { + console.error( + `Skill at ${skillDir}: missing or invalid 'description' field` + ); + continue; + } + + // Extract optional metadata fields + const metadata: Record = {}; + if ( + frontmatter.metadata && + typeof frontmatter.metadata === "object" + ) { + for (const [k, v] of Object.entries( + frontmatter.metadata as Record + )) { + if (typeof v === "string") { + metadata[k] = v; + } + } + } + + const trimmedName = name.trim(); + if (skillMap.has(trimmedName)) { + console.error( + `Warning: Duplicate skill name "${trimmedName}" at ${skillMdPath} — keeping first` + ); + continue; + } + + // Scan for supplementary documents + const documents = scanDocuments(skillDir, resolvedDir); + + skillMap.set(trimmedName, { + name: trimmedName, + description: description.trim(), + path: skillMdPath, + skillDir, + metadata: Object.keys(metadata).length > 0 ? metadata : undefined, + documents, + }); + } catch (error) { + console.error(`Failed to parse skill at ${skillDir}:`, error); + } + } + + return skillMap; +} + +/** + * Load the full content of a SKILL.md file. + * + * Security: Validates that the path is within the skills directory, + * only reads .md files, and enforces a file size limit. + */ +export function loadSkillContent( + skillPath: string, + skillsDir: string +): string { + // Security: only allow .md files + if (!skillPath.endsWith(".md")) { + throw new Error("Only .md files can be read"); + } + + // Security: verify path is within skills directory + if (!isPathWithinBase(skillPath, skillsDir)) { + throw new Error("Path escapes the skills directory"); + } + + // Security: check file size + const stat = fs.statSync(skillPath); + if (stat.size > MAX_FILE_SIZE) { + throw new Error( + `File size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB limit` + ); + } + + return fs.readFileSync(skillPath, "utf-8"); +} + +/** + * Load a supplementary document from a skill directory. + * + * Security: Validates that the path is within the skills directory, + * rejects path traversal attempts, and enforces a file size limit. + */ +export function loadDocument( + skill: SkillMetadata, + documentPath: string, + skillsDir: string +): string { + // Security: reject path traversal attempts + if (documentPath.includes("..")) { + throw new Error("Path traversal not allowed"); + } + + const fullPath = path.join(skill.skillDir, documentPath); + + // Security: verify path is within skills directory + if (!isPathWithinBase(fullPath, skillsDir)) { + throw new Error("Path escapes the skills directory"); + } + + // Security: check file size + const stat = fs.statSync(fullPath); + if (stat.size > MAX_FILE_SIZE) { + throw new Error( + `File size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB limit` + ); + } + + return fs.readFileSync(fullPath, "utf-8"); +} diff --git a/examples/skills-as-resources/typescript/src/types.ts b/examples/skills-as-resources/typescript/src/types.ts new file mode 100644 index 0000000..604728b --- /dev/null +++ b/examples/skills-as-resources/typescript/src/types.ts @@ -0,0 +1,44 @@ +/** + * Type definitions for the Skills as Resources reference implementation. + * + * Inspired by: + * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) + * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + */ + +/** + * A supplementary document found in a skill's subdirectories. + */ +export interface SkillDocument { + /** Relative path from skill root (e.g., "references/REFERENCE.md") */ + path: string; + /** MIME type based on file extension */ + mimeType: string; + /** File size in bytes */ + size: number; +} + +/** + * Metadata extracted from a skill's SKILL.md YAML frontmatter, + * extended with document scanning results. + */ +export interface SkillMetadata { + name: string; + description: string; + path: string; // Absolute path to the SKILL.md file + skillDir: string; // Absolute path to the skill's directory + metadata?: Record; // Optional extra frontmatter fields + documents: SkillDocument[]; // Supplementary files found in subdirectories +} + +/** + * Summary returned in the skill://index resource (progressive disclosure). + */ +export interface SkillSummary { + name: string; + description: string; + uri: string; // skill://{name} + documentsUri?: string; // skill://{name}/documents (only if documents exist) + documentCount: number; + metadata?: Record; +} diff --git a/examples/skills-as-resources/typescript/tsconfig.json b/examples/skills-as-resources/typescript/tsconfig.json new file mode 100644 index 0000000..486fed8 --- /dev/null +++ b/examples/skills-as-resources/typescript/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true + }, + "include": ["src/**/*"] +} From 0ad4f468f56952444b4aa720c45fb3e32d79d49c Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 08:17:47 -0700 Subject: [PATCH 02/21] Align skills-as-resources with skillsdotnet conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt patterns from the SkillsDotNet C# implementation into the TypeScript reference implementation for URI scheme interoperability and improved progressive disclosure. URI scheme changes: - skill://{name} → skill://{name}/SKILL.md (self-describing, enables client-side discovery via resources/list pattern matching) - skill://{name}/documents → skill://{name}/_manifest (pre-computed file inventory with SHA256 content hashes) - skill://{name}/document/{+documentPath} → skill://{name}/{+path} (not listed, discoverable via _manifest) - Removed skill://index (redundant with resources/list scanning) New features: - load_skill server-side MCP tool for model-controlled progressive disclosure, with dynamic description listing available skill names - SHA256 file hashes computed at startup for manifest integrity - Binary file support via base64 blob content for non-text MIME types - Root-level files now included in document scanning and manifest - Absolute path rejection added to loadDocument security checks - isTextMimeType helper matching skillsdotnet's text/binary detection Co-Authored-By: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 - examples/skills-as-resources/README.md | 110 +++++---- .../typescript/package-lock.json | 13 +- .../typescript/package.json | 7 +- .../typescript/src/index.ts | 224 +++++++++--------- .../typescript/src/resource-helpers.ts | 17 +- .../typescript/src/skill-discovery.ts | 88 ++++++- .../typescript/src/types.ts | 48 +++- 8 files changed, 316 insertions(+), 192 deletions(-) diff --git a/.gitignore b/.gitignore index b445672..418f780 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ .claude/settings.local.json # Local reference repos and planning docs -%TEMP%/ TODO.md # Build artifacts diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index f2b89b2..359aa61 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -1,22 +1,24 @@ # Skills as Resources — Reference Implementation -> **Experimental** — This is a minimal reference implementation for evaluation by the Skills Over MCP Interest Group. Not intended for production use. +> **Experimental** — This is a reference implementation for evaluation by the Skills Over MCP Interest Group. ## Pattern Overview -This example demonstrates the **Resources approach** from [`docs/approaches.md`](../../docs/approaches.md): exposing agent skills via MCP resources using the `skill://` URI scheme. +This example demonstrates the **Resources approach** from [`docs/approaches.md`](../../docs/approaches.md): exposing agent skills via MCP resources using the `skill://` URI scheme, combined with a `load_skill` tool for model-controlled progressive disclosure. -An MCP server scans a directory for SKILL.md files and exposes them as resources: +An MCP server scans a directory for SKILL.md files and exposes them as resources and tools: -| Resource | URI | MIME Type | Purpose | +| Type | Name / URI | MIME Type | Purpose | | :--- | :--- | :--- | :--- | -| Index | `skill://index` | `application/json` | JSON array of all skill summaries | -| Prompt XML | `skill://prompt-xml` | `application/xml` | XML for system prompt injection | -| Skill content | `skill://{name}` | `text/markdown` | Full SKILL.md content for a specific skill | -| Document list | `skill://{name}/documents` | `application/json` | List of supplementary files (if any) | -| Document | `skill://{name}/document/{path}` | varies | Individual supplementary document | +| Resource | `skill://{name}/SKILL.md` | `text/markdown` | Full SKILL.md content (listed) | +| Resource | `skill://{name}/_manifest` | `application/json` | File inventory with SHA256 hashes (listed) | +| Resource | `skill://{name}/{+path}` | varies | Supporting file (template, not listed) | +| Resource | `skill://prompt-xml` | `application/xml` | XML for system prompt injection (optional) | +| Tool | `load_skill` | — | Model-controlled skill loading | -This is an **application-controlled** approach: the host/client decides when to read resources. See [Open Question #9](../../docs/open-questions.md) for the control model discussion. +The URI scheme is aligned with the [SkillsDotNet](https://github.com/bradwilson/skillsdotnet) conventions, enabling interoperability between TypeScript and C# implementations. Clients can discover skills by scanning `resources/list` for URIs matching `skill://*/SKILL.md`. + +This is a **hybrid** approach: resources provide **application-controlled** access (the host/client decides when to read), while the `load_skill` tool provides **model-controlled** access (the LLM decides when to invoke). See [Open Question #9](../../docs/open-questions.md) for the control model discussion. ## How It Works @@ -28,20 +30,18 @@ This is an **application-controlled** approach: the host/client decides when to └─────────────┘ ``` -1. **Startup**: Server scans the configured skills directory for `*/SKILL.md` files and supplementary documents +1. **Startup**: Server scans the configured skills directory for `*/SKILL.md` files and supplementary documents; computes SHA256 hashes and builds file manifests 2. **Discovery**: Parses YAML frontmatter to extract `name` and `description` -3. **Registration**: Registers static resources for each skill, plus a `ResourceTemplate` for supplementary documents; resource descriptions include available skill names -4. **Progressive disclosure**: - - `skill://index` → Summaries only (names, descriptions, URIs) - - `skill://{name}` → Full SKILL.md content on demand - - `skill://{name}/documents` → List of supplementary files - - `skill://{name}/document/{path}` → Individual supplementary file +3. **Registration**: Registers static resources (`SKILL.md` + `_manifest`) for each skill, a `ResourceTemplate` for supporting files, and a `load_skill` tool +4. **Progressive disclosure** (two paths): + - **Application-controlled** (via resources): `resources/list` → scan for `skill://*/SKILL.md` → read `skill://{name}/SKILL.md` on demand → read `skill://{name}/_manifest` for file inventory → read `skill://{name}/{path}` for supporting files + - **Model-controlled** (via tool): `tools/list` → discover `load_skill` with available skill names in description → call `load_skill("code-review")` to get full content 5. **System prompt injection**: `skill://prompt-xml` provides XML that hosts can inject into system prompts 6. **Capability declaration**: Server declares `resources.listChanged` capability (dynamic updates could be wired to a file watcher in a full implementation) ## Implementations -Both implementations expose the same resources with the same behavior. They share the `sample-skills/` directory as test data. +> **Note**: The TypeScript implementation has been updated with the new URI scheme, `_manifest` resource, and `load_skill` tool. The Python implementation still uses the previous URI scheme and will be updated in a follow-up. ### TypeScript @@ -77,18 +77,20 @@ pip install -e . npx @modelcontextprotocol/inspector -- python -m skills_as_resources.server ../sample-skills ``` -### SDK Difference: Document Path Encoding +> **Note**: The Python implementation uses the previous URI scheme (`skill://{name}`, `skill://{name}/documents`, `skill://{name}/document/{path}`). It will be updated to match the TypeScript implementation in a follow-up. + +### SDK Difference: Supporting File Path Encoding -The TypeScript MCP SDK supports RFC 6570 reserved expansion (`{+path}`), so document URIs use natural paths: +The TypeScript MCP SDK supports RFC 6570 reserved expansion (`{+path}`), so file URIs use natural paths: ``` -skill://code-review/document/references/REFERENCE.md +skill://code-review/references/REFERENCE.md ``` The Python MCP SDK uses `[^/]+` regex for all template parameters, so forward slashes in paths must be URL-encoded: ``` -skill://code-review/document/references%2FREFERENCE.md +skill://code-review/references%2FREFERENCE.md ``` The SDK automatically URL-decodes the path after matching, so the handler receives the natural path in both cases. This difference is transparent to the resource handler logic. @@ -98,10 +100,11 @@ The SDK automatically URL-decodes the path after matching, so the handler receiv Both implementations include: - **Path traversal protection** — Resolved paths are checked against the skills directory boundary using `realpathSync` (TS) / `Path.resolve()` (Python). Symlink escapes are detected. -- **Skill name validation** — Resources look up names by key in the discovered skills map. User input is never used to construct file paths. -- **Document path validation** — Paths containing `..` are rejected. All document paths are verified to be within the skills directory. +- **Skill name validation** — Resources and the `load_skill` tool look up names by key in the discovered skills map. User input is never used to construct file paths. +- **File path validation** — Paths containing `..` are rejected. All paths are verified to be within the skills directory. - **File size limits** — Files larger than 1MB are skipped during discovery and rejected on read. - **Safe YAML parsing** — Python uses `yaml.safe_load()` to prevent code execution. TypeScript uses the `yaml` package which is safe by default. +- **Content integrity** — The `_manifest` resource includes SHA256 hashes for all files, enabling clients to verify downloaded content. ## Sample Skills @@ -109,34 +112,36 @@ Two sample skills are included in `sample-skills/` for testing: | Skill | Description | Documents | Notes | | :--- | :--- | :--- | :--- | -| `code-review` | Structured code review methodology | `references/REFERENCE.md` | Tests document scanning and `ResourceTemplate` | -| `git-commit-review` | Review commits for quality and conventional format | None | Tests basic skill resource with no documents | +| `code-review` | Structured code review methodology | `references/REFERENCE.md` | Tests document scanning, `_manifest` with hashes, and `ResourceTemplate` | +| `git-commit-review` | Review commits for quality and conventional format | None | Tests basic skill resource with no supporting files | ## Key Design Decisions -- **Resources, not tools**: Resources are application-controlled — the host/client decides when to read them. This demonstrates a fundamentally different control model than the tools approach, where the LLM decides when to invoke. See [`docs/experimental-findings.md`](../../docs/experimental-findings.md) for observations on how control model affects utilization. -- **Static resources for skills, template for documents**: Each discovered skill becomes a concrete resource visible in `resources/list`. Only supplementary document fetching uses a `ResourceTemplate`, since document paths are dynamic. -- **Progressive disclosure via URI hierarchy**: `skill://index` → `skill://{name}` → `skill://{name}/documents` → `skill://{name}/document/{path}`. Clients can fetch summaries first and load full content on demand. -- **`skill://prompt-xml` for injection**: Allows hosts to inject skill awareness into system prompts using the resources primitive, rather than embedding skill names in tool descriptions. -- **No `zod` dependency**: Unlike the tools approach, resources do not require input schemas, so the Zod dependency is not needed. +- **Hybrid approach (resources + tool)**: Resources provide application-controlled access for hosts that want to manage context. The `load_skill` tool provides model-controlled access for progressive disclosure. Experimental findings show models reliably use tools but tend to ignore resources (see [`docs/experimental-findings.md`](../../docs/experimental-findings.md)), making the hybrid approach more practical than resources alone. +- **URI scheme aligned with skillsdotnet**: The `skill://{name}/SKILL.md` and `skill://{name}/_manifest` URI conventions match the [SkillsDotNet](https://github.com/bradwilson/skillsdotnet) C# implementation. This enables cross-implementation interoperability — a client-side `SkillCatalog` can discover skills from either implementation by scanning `resources/list` for `skill://*/SKILL.md` URIs. +- **`_manifest` with SHA256 hashes**: Pre-computed at startup with file sizes and content hashes. Enables download/sync workflows and cache invalidation without re-reading files on each request. +- **Listed resources for skills, template for supporting files**: Each skill's `SKILL.md` and `_manifest` are concrete resources visible in `resources/list`. Supporting files are accessed via a `ResourceTemplate` (`skill://{name}/{+path}`) and are discoverable through the `_manifest` — keeping `resources/list` clean. +- **`skill://prompt-xml` for injection**: Optional convenience resource that allows hosts to inject skill awareness into system prompts using the resources primitive. ## How This Differs from Skills as Tools -| Aspect | Skills as Tools | Skills as Resources | +| Aspect | Skills as Tools | Skills as Resources (this example) | | :--- | :--- | :--- | -| Control model | Model-controlled (LLM invokes) | Application-controlled (host/client reads) | -| MCP Primitive | Tools | Resources | -| Discovery | Tool description + `list_skills` call | `resources/list` + `skill://index` | -| Loading | `read_skill(name)` tool call | `resources/read` on `skill://{name}` | -| System prompt | Via tool description embedding | Via `skill://prompt-xml` resource | -| Input validation | Zod schema on tool parameters | URI template matching | -| Supplementary files | Not demonstrated | `ResourceTemplate` for documents | +| Control model | Model-controlled only | Hybrid: application-controlled (resources) + model-controlled (`load_skill` tool) | +| MCP Primitive | Tools | Resources + Tools | +| Discovery | Tool description + `list_skills` call | `resources/list` scan for `skill://*/SKILL.md` + `load_skill` tool description | +| Loading | `read_skill(name)` tool call | `resources/read` on `skill://{name}/SKILL.md` or `load_skill(name)` tool call | +| File inventory | Not demonstrated | `skill://{name}/_manifest` with SHA256 hashes | +| System prompt | Via tool description embedding | Via `skill://prompt-xml` resource or `load_skill` tool description | +| Input validation | Zod schema on tool parameters | URI template matching (resources) + Zod schema (`load_skill` tool) | +| Supporting files | Not demonstrated | `ResourceTemplate` for files, `_manifest` for discovery | ## What This Example Intentionally Omits - File watching / resource subscriptions (capability is declared but not wired) - Dynamic updates (`resources.listChanged` is declared but not triggered) - MCP Prompts for explicit skill invocation +- Client-side `SkillCatalog` for multi-server skill aggregation (see skillsdotnet for a reference implementation) - GitHub sync, configuration UI - `skill://` URI scheme registration or standardization @@ -144,25 +149,42 @@ Two sample skills are included in `sample-skills/` for testing: > "Why not just use resources?" -This implementation shows that resources **do work** for skill delivery. Key findings for evaluation: +This implementation shows that resources **do work** for skill delivery, and that combining them with a `load_skill` tool creates a more practical system. Key findings for evaluation: -- **Discovery**: Skills appear in `resources/list`, making them immediately visible to any MCP-aware client -- **Progressive disclosure**: The URI hierarchy (`index` → `skill` → `documents` → `document`) provides the same layered loading as the tools approach +- **Discovery**: Skills appear in `resources/list` as `skill://*/SKILL.md`, making them immediately visible to any MCP-aware client. The `load_skill` tool description also lists available skills for model discovery. +- **Progressive disclosure**: The URI hierarchy (`SKILL.md` → `_manifest` → `{path}`) provides layered loading. The `load_skill` tool provides an alternative on-demand loading path. - **System prompt injection**: `skill://prompt-xml` provides a clean mechanism for hosts to inject skill awareness -- **Control model trade-off**: Resources are application-controlled — the host decides when/whether to read them. This may lead to lower utilization compared to model-controlled tools (see experimental findings), but gives the host more control over context management +- **Control model**: The hybrid approach gives both the host (via resources) and the model (via `load_skill` tool) agency over when skill content gets loaded +- **Interoperability**: The `skill://` URI convention is shared with skillsdotnet, enabling cross-implementation discovery ## Relationship to Other Approaches | Approach | How it differs | | :--- | :--- | | **1. Skills as Primitives** (SEP-2076) | Uses dedicated `skills/list` and `skills/get` protocol methods instead of resources | -| **3. Skills as Tools** (sibling example) | Uses MCP tools (model-controlled) instead of resources (application-controlled) | +| **3. Skills as Tools** (sibling example) | Uses MCP tools only (model-controlled) instead of the hybrid resources + tool approach | | **5. Server Instructions** | Uses server instructions to point to resources instead of exposing resources directly | | **6. Convention** | This example could become part of a documented convention pattern | +## Convergence with SkillsDotNet + +The URI scheme in this implementation is aligned with [SkillsDotNet](https://github.com/bradwilson/skillsdotnet), a C# implementation of the same pattern. Both implementations use: + +- `skill://{name}/SKILL.md` — listed resource for skill content +- `skill://{name}/_manifest` — listed resource for file inventory (with SHA256 hashes) +- `skill://{name}/{+path}` — resource template for supporting files + +This convergence enables a future client-side `SkillCatalog` to discover and load skills from either implementation without knowing which language the server is written in. The key client-side pattern (from skillsdotnet) is: + +1. Scan `resources/list` for URIs matching `skill://*/SKILL.md` +2. Read each SKILL.md to extract frontmatter (name + description) +3. Build compact context summaries for the system prompt (~50-100 tokens per skill) +4. Expose a `load_skill` tool/function for on-demand full content loading + ## Inspirations and Attribution This reference implementation is original code inspired by patterns from: - **[skills-over-mcp](https://github.com/keithagroves/skills-over-mcp)** by [Keith Groves](https://github.com/keithagroves) — Resource-based skill exposure, `skill://` URI scheme, JSON index, XML prompt injection, document templates - **[skilljack-mcp](https://github.com/olaservo/skilljack-mcp)** by [Ola Hungerford](https://github.com/olaservo) — Resource template patterns, subscription architecture, path security +- **[SkillsDotNet](https://github.com/bradwilson/skillsdotnet)** by [Brad Wilson](https://github.com/bradwilson) — `_manifest` resource with file hashes, `load_skill` tool, `SkillCatalog` client-side pattern, URI scheme conventions diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json index 2f11610..053e2ac 100644 --- a/examples/skills-as-resources/typescript/package-lock.json +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -1,16 +1,17 @@ { "name": "@skills-over-mcp-ig/skills-as-resources-example", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@skills-over-mcp-ig/skills-as-resources-example", - "version": "0.1.0", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.0", - "yaml": "^2.7.0" + "yaml": "^2.7.0", + "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^22.0.0", @@ -1715,9 +1716,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index 381336d..702fd44 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -1,7 +1,7 @@ { "name": "@skills-over-mcp-ig/skills-as-resources-example", - "version": "0.1.0", - "description": "Minimal reference implementation: Skills as MCP Resources", + "version": "0.2.0", + "description": "Reference implementation: Skills as MCP Resources with progressive disclosure", "type": "module", "main": "dist/index.js", "scripts": { @@ -11,7 +11,8 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.0", - "yaml": "^2.7.0" + "yaml": "^2.7.0", + "zod": "^3.23.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 16a5c29..7058e49 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -2,31 +2,35 @@ /** * Skills as Resources — MCP Server (TypeScript) * - * A minimal reference implementation demonstrating the Resources approach + * A reference implementation demonstrating the Resources approach * from the Skills Over MCP Interest Group: exposing agent skills via - * MCP resources using the skill:// URI scheme. + * MCP resources using the skill:// URI scheme, with a load_skill tool + * for model-controlled progressive disclosure. * - * Exposes resources: - * - skill://index — JSON index of all available skills - * - skill://prompt-xml — XML for system prompt injection - * - skill://{name} — Individual skill SKILL.md content - * - skill://{name}/documents — List of supplementary files - * - skill://{name}/document/{+documentPath} — Individual document (template) + * URI scheme (aligned with skillsdotnet conventions): + * - skill://{name}/SKILL.md — Skill content (listed resource) + * - skill://{name}/_manifest — File inventory with SHA256 hashes (listed resource) + * - skill://{name}/{+path} — Supporting file (resource template, not listed) + * - skill://prompt-xml — XML for system prompt injection (optional) + * + * Tool: + * - load_skill — Model-controlled skill loading (progressive disclosure) * * Inspired by: * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + * - SkillsDotNet by Brad Wilson (https://github.com/bradwilson/skillsdotnet) * * @license Apache-2.0 */ import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { z } from "zod"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { discoverSkills, loadSkillContent, loadDocument } from "./skill-discovery.js"; -import { generateSkillsXML } from "./resource-helpers.js"; -import type { SkillSummary } from "./types.js"; +import { generateSkillsXML, isTextMimeType } from "./resource-helpers.js"; // Resolve skills directory from CLI arg or default to ../sample-skills const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -37,60 +41,25 @@ const skillsDir = process.argv[2] // Discover skills at startup const skillMap = discoverSkills(skillsDir); const skillNames = Array.from(skillMap.keys()); +const skillListStr = skillNames.join(", ") || "none"; console.error( - `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${skillNames.join(", ") || "none"}` + `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${skillListStr}` ); for (const [name, skill] of skillMap) { - if (skill.documents.length > 0) { - console.error( - ` - ${name}: ${skill.documents.length} document(s)` - ); - } + const fileCount = skill.manifest.files.length; + console.error(` - ${name}: ${fileCount} file(s) in manifest`); } -// Create MCP server with resources.listChanged capability +// Create MCP server with resources and tools capabilities const server = new McpServer( - { name: "skills-as-resources-example", version: "0.1.0" }, - { capabilities: { resources: { listChanged: true } } } + { name: "skills-as-resources-example", version: "0.2.0" }, + { capabilities: { resources: { listChanged: true }, tools: {} } } ); // --- Static resources --- -// Resource: skill://index — JSON index of all available skills -server.registerResource( - "skills-index", - "skill://index", - { - description: - "Index of all available skills with their descriptions, URIs, and document counts. " + - `Currently available: ${skillNames.join(", ") || "none"}`, - mimeType: "application/json", - }, - async (uri) => { - const index: SkillSummary[] = Array.from(skillMap.values()).map((s) => ({ - name: s.name, - description: s.description, - uri: `skill://${s.name}`, - ...(s.documents.length > 0 && { - documentsUri: `skill://${s.name}/documents`, - }), - documentCount: s.documents.length, - ...(s.metadata && { metadata: s.metadata }), - })); - - return { - contents: [ - { - uri: uri.href, - text: JSON.stringify(index, null, 2), - }, - ], - }; - } -); - -// Resource: skill://prompt-xml — XML for system prompt injection +// Resource: skill://prompt-xml — XML for system prompt injection (optional convenience) server.registerResource( "skills-prompt-xml", "skill://prompt-xml", @@ -111,10 +80,10 @@ server.registerResource( // Per-skill static resources for (const [name, skill] of skillMap) { - // Resource: skill://{name} — individual skill SKILL.md content + // Resource: skill://{name}/SKILL.md — skill content (listed) server.registerResource( `skill-${name}`, - `skill://${name}`, + `skill://${name}/SKILL.md`, { description: skill.description, mimeType: "text/markdown", @@ -139,57 +108,34 @@ for (const [name, skill] of skillMap) { } ); - // Resource: skill://{name}/documents — list of supplementary files - if (skill.documents.length > 0) { - server.registerResource( - `skill-${name}-documents`, - `skill://${name}/documents`, - { - description: `List of supplementary documents for the ${name} skill`, - mimeType: "application/json", - }, - async (uri) => ({ - contents: [ - { - uri: uri.href, - text: JSON.stringify( - { - skill: name, - documents: skill.documents.map((doc) => ({ - path: doc.path, - mimeType: doc.mimeType, - size: doc.size, - uri: `skill://${name}/document/${doc.path}`, - })), - }, - null, - 2 - ), - }, - ], - }) - ); - } + // Resource: skill://{name}/_manifest — file inventory with SHA256 hashes (listed) + server.registerResource( + `skill-${name}-manifest`, + `skill://${name}/_manifest`, + { + description: `File manifest for skill '${name}' with content hashes`, + mimeType: "application/json", + }, + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: skill.manifestJson, + }, + ], + }) + ); } -// --- Dynamic resource template --- +// --- Resource template for supporting files --- -// Template: skill://{skillName}/document/{+documentPath} -// The {+} prefix uses RFC 6570 reserved expansion, matching paths with slashes +// Template: skill://{skillName}/{+path} +// The {+} prefix uses RFC 6570 reserved expansion, matching paths with slashes. +// NOT listed — supporting files are discoverable via the _manifest resource. server.registerResource( - "skill-document", - new ResourceTemplate("skill://{skillName}/document/{+documentPath}", { - list: async () => { - const resources = Array.from(skillMap.values()).flatMap((skill) => - skill.documents.map((doc) => ({ - uri: `skill://${skill.name}/document/${doc.path}`, - name: `${skill.name}/${doc.path}`, - description: `Document from ${skill.name} skill`, - mimeType: doc.mimeType, - })) - ); - return { resources }; - }, + "skill-file", + new ResourceTemplate("skill://{skillName}/{+path}", { + list: undefined, complete: { skillName: (value) => { return Array.from(skillMap.values()) @@ -197,13 +143,14 @@ server.registerResource( .map((s) => s.name) .filter((name) => name.startsWith(value)); }, - documentPath: (value, context) => { + path: (value, context) => { const skillName = context?.arguments?.skillName; if (!skillName) return []; const skill = skillMap.get(skillName); if (!skill) return []; + // SDK's createCompletionResult handles truncation to 100 and sets total/hasMore return skill.documents .map((d) => d.path) .filter((p) => p.startsWith(value)); @@ -211,16 +158,16 @@ server.registerResource( }, }), { - description: "Fetch a specific supplementary document from a skill", + description: "Fetch a supporting file from a skill directory", mimeType: "text/plain", }, async (uri, variables) => { const skillName = Array.isArray(variables.skillName) ? variables.skillName[0] : variables.skillName; - const documentPath = Array.isArray(variables.documentPath) - ? variables.documentPath[0] - : variables.documentPath; + const filePath = Array.isArray(variables.path) + ? variables.path[0] + : variables.path; const skill = skillMap.get(skillName); if (!skill) { @@ -228,33 +175,34 @@ server.registerResource( contents: [ { uri: uri.href, - text: `# Error\n\nSkill "${skillName}" not found. Available: ${skillNames.join(", ") || "none"}`, + text: `# Error\n\nSkill "${skillName}" not found. Available: ${skillListStr}`, }, ], }; } - const doc = skill.documents.find((d) => d.path === documentPath); + const doc = skill.documents.find((d) => d.path === filePath); if (!doc) { const available = skill.documents.map((d) => `- ${d.path}`).join("\n"); return { contents: [ { uri: uri.href, - text: `# Error\n\nDocument "${documentPath}" not found in skill "${skillName}".\n\n## Available Documents\n\n${available || "No documents available."}`, + text: `# Error\n\nFile "${filePath}" not found in skill "${skillName}".\n\n## Available Files\n\n${available || "No supporting files available."}`, }, ], }; } try { - const content = loadDocument(skill, documentPath, skillsDir); + const isText = isTextMimeType(doc.mimeType); + const content = loadDocument(skill, filePath, skillsDir, isText); return { contents: [ { uri: uri.href, - text: content, mimeType: doc.mimeType, + ...content, }, ], }; @@ -264,9 +212,59 @@ server.registerResource( contents: [ { uri: uri.href, - text: `# Error\n\nFailed to read document: ${message}`, + text: `# Error\n\nFailed to read file: ${message}`, + }, + ], + }; + } + } +); + +// --- Tool for model-controlled progressive disclosure --- + +// Tool: load_skill — allows models to discover and load skills on demand. +// Description dynamically lists available skill names, mirroring +// skillsdotnet's SkillCatalog pattern but implemented server-side. +server.registerTool( + "load_skill", + { + description: + `Load the full SKILL.md content for a named skill. ` + + `Use this when you need detailed instructions for performing a specific task. ` + + `Available skills: ${skillListStr}`, + inputSchema: { + skillName: z.string().describe("The name of the skill to load"), + }, + }, + async ({ skillName }) => { + const skill = skillMap.get(skillName); + if (!skill) { + return { + content: [ + { + type: "text" as const, + text: `Skill "${skillName}" not found. Available skills: ${skillListStr}`, + }, + ], + isError: true, + }; + } + + try { + const content = loadSkillContent(skill.path, skillsDir); + return { + content: [{ type: "text" as const, text: content }], + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { + content: [ + { + type: "text" as const, + text: `Failed to load skill "${skillName}": ${message}`, }, ], + isError: true, }; } } diff --git a/examples/skills-as-resources/typescript/src/resource-helpers.ts b/examples/skills-as-resources/typescript/src/resource-helpers.ts index 20e8fe0..4056b3e 100644 --- a/examples/skills-as-resources/typescript/src/resource-helpers.ts +++ b/examples/skills-as-resources/typescript/src/resource-helpers.ts @@ -43,6 +43,21 @@ export function getMimeType(filepath: string): string { return MIME_TYPES[ext] || "application/octet-stream"; } +/** + * Check if a MIME type represents text content (as opposed to binary). + * Matches skillsdotnet's logic: text/* types, plus application/json, + * application/xml, application/javascript, and +json/+xml suffixes. + */ +export function isTextMimeType(mimeType: string): boolean { + if (mimeType.startsWith("text/")) return true; + if (mimeType === "application/json") return true; + if (mimeType === "application/xml") return true; + if (mimeType === "application/javascript") return true; + if (mimeType.endsWith("+json")) return true; + if (mimeType.endsWith("+xml")) return true; + return false; +} + /** * Escape XML special characters. */ @@ -78,7 +93,7 @@ export function generateSkillsXML( lines.push(" "); lines.push(` ${escapeXml(skill.name)}`); lines.push(` ${escapeXml(skill.description)}`); - lines.push(` skill://${escapeXml(skill.name)}`); + lines.push(` skill://${escapeXml(skill.name)}/SKILL.md`); lines.push(" "); } diff --git a/examples/skills-as-resources/typescript/src/skill-discovery.ts b/examples/skills-as-resources/typescript/src/skill-discovery.ts index a00384a..bdebcc9 100644 --- a/examples/skills-as-resources/typescript/src/skill-discovery.ts +++ b/examples/skills-as-resources/typescript/src/skill-discovery.ts @@ -12,13 +12,24 @@ import * as fs from "node:fs"; import * as path from "node:path"; +import * as crypto from "node:crypto"; import { parse as parseYaml } from "yaml"; -import type { SkillMetadata, SkillDocument } from "./types.js"; +import type { SkillMetadata, SkillDocument, SkillManifest } from "./types.js"; import { getMimeType } from "./resource-helpers.js"; /** Maximum file size for skill files (1MB). */ const MAX_FILE_SIZE = 1 * 1024 * 1024; +/** + * Compute SHA256 hash of a file's contents. + * Returns a string in the format "sha256:". + */ +function computeFileHash(filePath: string): string { + const content = fs.readFileSync(filePath); + const hash = crypto.createHash("sha256").update(content).digest("hex"); + return `sha256:${hash}`; +} + /** * Parse YAML frontmatter from SKILL.md content. * Expects content to start with --- and have a closing ---. @@ -102,9 +113,10 @@ function scanDir( path: relativePath, mimeType: getMimeType(entry.name), size: stat.size, + hash: computeFileHash(fullPath), }); } catch { - // Skip files we can't stat + // Skip files we can't stat or hash } } else if (entry.isDirectory()) { // Recurse into subdirectories @@ -116,9 +128,10 @@ function scanDir( } /** - * Scan a skill directory for supplementary documents. - * Finds all files in subdirectories of the skill directory, - * excluding SKILL.md itself. + * Scan a skill directory for all supplementary files. + * Finds all files in the skill directory (including root-level files + * and subdirectories), excluding SKILL.md / skill.md itself. + * This matches skillsdotnet's behavior of including all files recursively. */ export function scanDocuments( skillDir: string, @@ -133,10 +146,34 @@ export function scanDocuments( return documents; } + // Skip the main skill file names + const skipFiles = new Set(["SKILL.md", "skill.md"]); + for (const entry of entries) { - if (!entry.isDirectory()) continue; - const subDirPath = path.join(skillDir, entry.name); - documents.push(...scanDir(subDirPath, skillDir, baseDir)); + const fullPath = path.join(skillDir, entry.name); + + if (entry.isDirectory()) { + // Recurse into subdirectories + documents.push(...scanDir(fullPath, skillDir, baseDir)); + } else if (entry.isFile() && !skipFiles.has(entry.name)) { + // Include root-level files (excluding SKILL.md) + if (!isPathWithinBase(fullPath, baseDir)) continue; + + try { + const stat = fs.statSync(fullPath); + if (stat.size > MAX_FILE_SIZE) continue; + + const relativePath = path.relative(skillDir, fullPath).replace(/\\/g, "/"); + documents.push({ + path: relativePath, + mimeType: getMimeType(entry.name), + size: stat.size, + hash: computeFileHash(fullPath), + }); + } catch { + // Skip files we can't stat or hash + } + } } return documents; @@ -236,6 +273,21 @@ export function discoverSkills(skillsDir: string): Map { // Scan for supplementary documents const documents = scanDocuments(skillDir, resolvedDir); + // Build pre-computed manifest with file hashes + const skillMdHash = computeFileHash(skillMdPath); + const manifest: SkillManifest = { + skill: trimmedName, + files: [ + { path: "SKILL.md", size: stat.size, hash: skillMdHash }, + ...documents.map((doc) => ({ + path: doc.path, + size: doc.size, + hash: doc.hash, + })), + ], + }; + const manifestJson = JSON.stringify(manifest); + skillMap.set(trimmedName, { name: trimmedName, description: description.trim(), @@ -243,6 +295,8 @@ export function discoverSkills(skillsDir: string): Map { skillDir, metadata: Object.keys(metadata).length > 0 ? metadata : undefined, documents, + manifest, + manifestJson, }); } catch (error) { console.error(`Failed to parse skill at ${skillDir}:`, error); @@ -285,6 +339,8 @@ export function loadSkillContent( /** * Load a supplementary document from a skill directory. + * Returns text content (string) for text MIME types and + * base64-encoded content for binary MIME types. * * Security: Validates that the path is within the skills directory, * rejects path traversal attempts, and enforces a file size limit. @@ -292,13 +348,19 @@ export function loadSkillContent( export function loadDocument( skill: SkillMetadata, documentPath: string, - skillsDir: string -): string { + skillsDir: string, + isText: boolean +): { text: string } | { blob: string } { // Security: reject path traversal attempts if (documentPath.includes("..")) { throw new Error("Path traversal not allowed"); } + // Security: reject absolute paths + if (path.isAbsolute(documentPath)) { + throw new Error("Absolute paths not allowed"); + } + const fullPath = path.join(skill.skillDir, documentPath); // Security: verify path is within skills directory @@ -314,5 +376,9 @@ export function loadDocument( ); } - return fs.readFileSync(fullPath, "utf-8"); + if (isText) { + return { text: fs.readFileSync(fullPath, "utf-8") }; + } else { + return { blob: fs.readFileSync(fullPath).toString("base64") }; + } } diff --git a/examples/skills-as-resources/typescript/src/types.ts b/examples/skills-as-resources/typescript/src/types.ts index 604728b..2a6d302 100644 --- a/examples/skills-as-resources/typescript/src/types.ts +++ b/examples/skills-as-resources/typescript/src/types.ts @@ -1,10 +1,40 @@ /** * Type definitions for the Skills as Resources reference implementation. * + * URI scheme aligned with skillsdotnet conventions: + * - skill://{name}/SKILL.md — listed resource for skill content + * - skill://{name}/_manifest — listed resource for file inventory + * - skill://{name}/{+path} — template for supporting files + * * Inspired by: * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + * - SkillsDotNet by Brad Wilson (https://github.com/bradwilson/skillsdotnet) + */ + +/** + * A file entry in the skill manifest, including content hash. + * Used in the skill://{name}/_manifest resource. + */ +export interface ManifestFileEntry { + /** Relative path from skill root (e.g., "SKILL.md", "references/REFERENCE.md") */ + path: string; + /** File size in bytes */ + size: number; + /** Content hash in format "sha256:" */ + hash: string; +} + +/** + * Pre-computed manifest for a skill, listing all files with hashes. + * Served at skill://{name}/_manifest. */ +export interface SkillManifest { + /** Skill name */ + skill: string; + /** All files in the skill directory, including SKILL.md */ + files: ManifestFileEntry[]; +} /** * A supplementary document found in a skill's subdirectories. @@ -16,11 +46,13 @@ export interface SkillDocument { mimeType: string; /** File size in bytes */ size: number; + /** SHA256 hash of file content in format "sha256:" */ + hash: string; } /** * Metadata extracted from a skill's SKILL.md YAML frontmatter, - * extended with document scanning results. + * extended with document scanning results and pre-computed manifest. */ export interface SkillMetadata { name: string; @@ -29,16 +61,6 @@ export interface SkillMetadata { skillDir: string; // Absolute path to the skill's directory metadata?: Record; // Optional extra frontmatter fields documents: SkillDocument[]; // Supplementary files found in subdirectories -} - -/** - * Summary returned in the skill://index resource (progressive disclosure). - */ -export interface SkillSummary { - name: string; - description: string; - uri: string; // skill://{name} - documentsUri?: string; // skill://{name}/documents (only if documents exist) - documentCount: number; - metadata?: Record; + manifest: SkillManifest; // Pre-computed file manifest + manifestJson: string; // Pre-serialized manifest JSON (avoids I/O on request) } From e8105f4e81036c00bf0516a29f8e7b191dea951c Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 08:19:19 -0700 Subject: [PATCH 03/21] Remove Python skills-as-resources implementation Co-Authored-By: Claude Opus 4.6 --- .../skills-as-resources/python/pyproject.toml | 20 -- .../src/skills_as_resources/__init__.py | 0 .../skills_as_resources/resource_helpers.py | 71 ---- .../python/src/skills_as_resources/server.py | 209 ----------- .../skills_as_resources/skill_discovery.py | 328 ------------------ 5 files changed, 628 deletions(-) delete mode 100644 examples/skills-as-resources/python/pyproject.toml delete mode 100644 examples/skills-as-resources/python/src/skills_as_resources/__init__.py delete mode 100644 examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py delete mode 100644 examples/skills-as-resources/python/src/skills_as_resources/server.py delete mode 100644 examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py diff --git a/examples/skills-as-resources/python/pyproject.toml b/examples/skills-as-resources/python/pyproject.toml deleted file mode 100644 index 4e7d695..0000000 --- a/examples/skills-as-resources/python/pyproject.toml +++ /dev/null @@ -1,20 +0,0 @@ -[project] -name = "skills-as-resources-example" -version = "0.1.0" -description = "Minimal reference implementation: Skills as MCP Resources" -requires-python = ">=3.10" -dependencies = [ - "mcp>=1.0.0", - "pyyaml>=6.0", -] -license = "Apache-2.0" - -[project.scripts] -skills-as-resources = "skills_as_resources.server:main" - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["src/skills_as_resources"] diff --git a/examples/skills-as-resources/python/src/skills_as_resources/__init__.py b/examples/skills-as-resources/python/src/skills_as_resources/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py b/examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py deleted file mode 100644 index 2e46452..0000000 --- a/examples/skills-as-resources/python/src/skills_as_resources/resource_helpers.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Resource helper utilities for the Skills as Resources implementation. - -Provides XML generation for system prompt injection and MIME type mapping -for skill documents. - -Inspired by: -- skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) -""" - -from __future__ import annotations - -import os -from xml.sax.saxutils import escape - -from .skill_discovery import SkillMetadata - -# Map file extensions to MIME types -MIME_TYPES: dict[str, str] = { - ".md": "text/markdown", - ".txt": "text/plain", - ".py": "text/x-python", - ".js": "text/javascript", - ".ts": "text/typescript", - ".sh": "text/x-shellscript", - ".bash": "text/x-shellscript", - ".json": "application/json", - ".yaml": "text/yaml", - ".yml": "text/yaml", - ".xml": "application/xml", - ".html": "text/html", - ".css": "text/css", - ".sql": "text/x-sql", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".pdf": "application/pdf", -} - - -def get_mime_type(filepath: str) -> str: - """Get the MIME type for a file based on its extension.""" - _, ext = os.path.splitext(filepath) - return MIME_TYPES.get(ext.lower(), "application/octet-stream") - - -def generate_skills_xml(skill_map: dict[str, SkillMetadata]) -> str: - """Generate XML for injecting into system prompts. - - Format: - - - code-review - Perform structured code reviews... - skill://code-review - - - """ - lines: list[str] = [""] - - for skill in skill_map.values(): - lines.append(" ") - lines.append(f" {escape(skill.name)}") - lines.append(f" {escape(skill.description)}") - lines.append(f" skill://{escape(skill.name)}") - lines.append(" ") - - lines.append("") - return "\n".join(lines) diff --git a/examples/skills-as-resources/python/src/skills_as_resources/server.py b/examples/skills-as-resources/python/src/skills_as_resources/server.py deleted file mode 100644 index 541aa84..0000000 --- a/examples/skills-as-resources/python/src/skills_as_resources/server.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Skills as Resources — MCP Server (Python) - -A minimal reference implementation demonstrating the Resources approach -from the Skills Over MCP Interest Group: exposing agent skills via -MCP resources using the skill:// URI scheme. - -Exposes resources: - - skill://index — JSON index of all available skills - - skill://prompt-xml — XML for system prompt injection - - skill://{name} — Individual skill SKILL.md content - - skill://{name}/documents — List of supplementary files - - skill://{name}/document/{document_path} — Individual document (template) - -Note: The Python MCP SDK does not support RFC 6570 {+path} expansion, -so document paths containing "/" are URL-encoded (e.g., references%2FREFERENCE.md). -The SDK automatically URL-decodes them after template matching. - -Inspired by: -- skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) -- skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) - -License: Apache-2.0 -""" - -from __future__ import annotations - -import json -import sys -from pathlib import Path -from urllib.parse import quote - -from mcp.server.fastmcp import FastMCP - -from .resource_helpers import generate_skills_xml -from .skill_discovery import discover_skills, load_document, load_skill_content - -# Resolve skills directory from CLI arg or default to ../sample-skills -if len(sys.argv) > 1: - skills_dir = str(Path(sys.argv[1]).resolve()) -else: - skills_dir = str( - Path(__file__).resolve().parent.parent.parent.parent / "sample-skills" - ) - -# Discover skills at startup -skill_map = discover_skills(skills_dir) -skill_names = list(skill_map.keys()) - -print( - f"[skills-as-resources] Discovered {len(skill_map)} skill(s): " - f"{', '.join(skill_names) or 'none'}", - file=sys.stderr, -) -for name, skill in skill_map.items(): - if skill.documents: - print( - f" - {name}: {len(skill.documents)} document(s)", - file=sys.stderr, - ) - -# Create MCP server -mcp = FastMCP( - name="skills-as-resources-example", -) - - -def _encode_document_path(path: str) -> str: - """URL-encode a document path for use in skill:// URIs. - - The Python MCP SDK uses [^/]+ regex for template parameters, - so forward slashes in paths must be percent-encoded. - """ - return quote(path, safe="") - - -def _build_index() -> list[dict]: - """Build the JSON index of all skills.""" - index = [] - for s in skill_map.values(): - entry: dict = { - "name": s.name, - "description": s.description, - "uri": f"skill://{s.name}", - "documentCount": len(s.documents), - } - if s.documents: - entry["documentsUri"] = f"skill://{s.name}/documents" - if s.metadata: - entry["metadata"] = s.metadata - index.append(entry) - return index - - -def _build_document_list(skill_name: str) -> dict: - """Build the document list for a skill.""" - skill = skill_map[skill_name] - return { - "skill": skill_name, - "documents": [ - { - "path": doc.path, - "mimeType": doc.mime_type, - "size": doc.size, - "uri": f"skill://{skill_name}/document/{_encode_document_path(doc.path)}", - } - for doc in skill.documents - ], - } - - -# --- Static resources --- - -@mcp.resource( - "skill://index", - name="skills-index", - description=( - "Index of all available skills with their descriptions, URIs, and document counts. " - f"Currently available: {', '.join(skill_names) or 'none'}" - ), - mime_type="application/json", -) -def get_index() -> str: - """Return JSON index of all available skills.""" - return json.dumps(_build_index(), indent=2) - - -@mcp.resource( - "skill://prompt-xml", - name="skills-prompt-xml", - description="XML representation of available skills for injecting into system prompts", - mime_type="application/xml", -) -def get_prompt_xml() -> str: - """Return XML representation for system prompt injection.""" - return generate_skills_xml(skill_map) - - -# Per-skill static resources registered in a loop. -# Uses closure binding to avoid Python's late-binding issue. -for _skill_name, _skill_meta in skill_map.items(): - - def _register_skill(s_name: str, s_meta): # noqa: ANN001 - @mcp.resource( - f"skill://{s_name}", - name=f"skill-{s_name}", - description=s_meta.description, - mime_type="text/markdown", - ) - def _get_skill() -> str: - try: - return load_skill_content(s_meta.path, skills_dir) - except (OSError, ValueError) as exc: - return f'# Error\n\nFailed to load skill "{s_name}": {exc}' - - if s_meta.documents: - @mcp.resource( - f"skill://{s_name}/documents", - name=f"skill-{s_name}-documents", - description=f"List of supplementary documents for the {s_name} skill", - mime_type="application/json", - ) - def _get_documents() -> str: - return json.dumps(_build_document_list(s_name), indent=2) - - _register_skill(_skill_name, _skill_meta) - - -# --- Dynamic resource template --- - -@mcp.resource( - "skill://{skill_name}/document/{document_path}", - name="skill-document", - description="Fetch a specific supplementary document from a skill", - mime_type="text/plain", -) -def get_document(skill_name: str, document_path: str) -> str: - """Fetch a supplementary document from a skill. - - The document_path is automatically URL-decoded by the SDK, - so encoded paths like "references%2FREFERENCE.md" arrive as - "references/REFERENCE.md". - """ - skill = skill_map.get(skill_name) - if not skill: - available = ", ".join(skill_names) or "none" - return f'# Error\n\nSkill "{skill_name}" not found. Available: {available}' - - doc = next((d for d in skill.documents if d.path == document_path), None) - if not doc: - available = "\n".join(f"- {d.path}" for d in skill.documents) - return ( - f'# Error\n\nDocument "{document_path}" not found in skill "{skill_name}".\n\n' - f"## Available Documents\n\n{available or 'No documents available.'}" - ) - - try: - return load_document(skill, document_path, skills_dir) - except (OSError, ValueError) as exc: - return f"# Error\n\nFailed to read document: {exc}" - - -def main() -> None: - """Entry point: run the MCP server via stdio transport.""" - mcp.run(transport="stdio") - - -if __name__ == "__main__": - main() diff --git a/examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py b/examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py deleted file mode 100644 index 8695202..0000000 --- a/examples/skills-as-resources/python/src/skills_as_resources/skill_discovery.py +++ /dev/null @@ -1,328 +0,0 @@ -""" -Skill discovery, content loading, and document scanning module. - -Discovers Agent Skills by scanning a directory for subdirectories -containing SKILL.md files, parses YAML frontmatter for metadata, -scans for supplementary documents, and provides secure content loading. - -Inspired by: -- skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) -- skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) -""" - -from __future__ import annotations - -import logging -import os -from dataclasses import dataclass, field -from pathlib import Path - -import yaml - -logger = logging.getLogger(__name__) - -# Maximum file size for skill files (1MB) -MAX_FILE_SIZE = 1 * 1024 * 1024 - -# Map file extensions to MIME types -_MIME_TYPES: dict[str, str] = { - ".md": "text/markdown", - ".txt": "text/plain", - ".py": "text/x-python", - ".js": "text/javascript", - ".ts": "text/typescript", - ".sh": "text/x-shellscript", - ".bash": "text/x-shellscript", - ".json": "application/json", - ".yaml": "text/yaml", - ".yml": "text/yaml", - ".xml": "application/xml", - ".html": "text/html", - ".css": "text/css", - ".sql": "text/x-sql", - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".svg": "image/svg+xml", - ".pdf": "application/pdf", -} - - -@dataclass -class SkillDocument: - """A supplementary document found in a skill's subdirectories.""" - - path: str # Relative path from skill root (e.g., "references/REFERENCE.md") - mime_type: str - size: int - - -@dataclass -class SkillMetadata: - """Metadata extracted from a skill's SKILL.md YAML frontmatter.""" - - name: str - description: str - path: str # Absolute path to the SKILL.md file - skill_dir: str # Absolute path to the skill's directory - metadata: dict[str, str] = field(default_factory=dict) - documents: list[SkillDocument] = field(default_factory=list) - - -def _get_mime_type(filepath: str) -> str: - """Get the MIME type for a file based on its extension.""" - _, ext = os.path.splitext(filepath) - return _MIME_TYPES.get(ext.lower(), "application/octet-stream") - - -def _parse_frontmatter(content: str) -> tuple[dict, str]: - """Parse YAML frontmatter from SKILL.md content. - - Returns (frontmatter_dict, body_text). - """ - if not content.startswith("---"): - raise ValueError("SKILL.md must start with YAML frontmatter (---)") - - parts = content.split("---") - if len(parts) < 3: - raise ValueError("SKILL.md frontmatter not properly closed with ---") - - # Use safe_load to prevent arbitrary code execution - frontmatter = yaml.safe_load(parts[1]) - if not isinstance(frontmatter, dict): - raise ValueError("SKILL.md frontmatter must be a YAML mapping") - - body = "---".join(parts[2:]).strip() - return frontmatter, body - - -def _is_path_within_base(target: Path, base: Path) -> bool: - """Check if a resolved path is within the allowed base directory.""" - try: - resolved_base = base.resolve(strict=True) - resolved_target = target.resolve(strict=True) - return resolved_target == resolved_base or str( - resolved_target - ).startswith(str(resolved_base) + os.sep) - except OSError: - # Fall back to non-strict resolve - resolved_base = base.resolve() - resolved_target = target.resolve() - return str(resolved_target).startswith(str(resolved_base) + os.sep) - - -def _scan_dir(dir_path: Path, relative_to: Path, base_dir: Path) -> list[SkillDocument]: - """Recursively scan a directory for files, returning SkillDocument entries.""" - documents: list[SkillDocument] = [] - - if not dir_path.is_dir(): - return documents - - try: - entries = list(dir_path.iterdir()) - except OSError: - return documents - - for entry in entries: - # Security: verify path stays within the skills directory - if not _is_path_within_base(entry, base_dir): - continue - - if entry.is_file(): - try: - stat = entry.stat() - if stat.st_size > MAX_FILE_SIZE: - continue - - relative_path = str(entry.relative_to(relative_to)).replace("\\", "/") - documents.append( - SkillDocument( - path=relative_path, - mime_type=_get_mime_type(entry.name), - size=stat.st_size, - ) - ) - except OSError: - pass - elif entry.is_dir(): - documents.extend(_scan_dir(entry, relative_to, base_dir)) - - return documents - - -def scan_documents(skill_dir: str, base_dir: str) -> list[SkillDocument]: - """Scan a skill directory for supplementary documents. - - Finds all files in subdirectories of the skill directory, - excluding SKILL.md itself. - """ - documents: list[SkillDocument] = [] - skill_path = Path(skill_dir) - base_path = Path(base_dir) - - try: - entries = list(skill_path.iterdir()) - except OSError: - return documents - - for entry in entries: - if entry.is_dir(): - documents.extend(_scan_dir(entry, skill_path, base_path)) - - return documents - - -def discover_skills(skills_dir: str) -> dict[str, SkillMetadata]: - """Discover all skills in a directory. - - Scans for immediate subdirectories containing SKILL.md files, - and scans for supplementary documents in each skill directory. - Security: skips files larger than MAX_FILE_SIZE, validates frontmatter. - """ - skill_map: dict[str, SkillMetadata] = {} - resolved_dir = Path(skills_dir).resolve() - - if not resolved_dir.is_dir(): - logger.error("Skills directory not found: %s", resolved_dir) - return skill_map - - for entry in resolved_dir.iterdir(): - if not entry.is_dir(): - continue - - # Find SKILL.md (prefer uppercase, accept lowercase) - skill_md_path = None - for name in ("SKILL.md", "skill.md"): - candidate = entry / name - if candidate.exists(): - skill_md_path = candidate - break - - if skill_md_path is None: - continue - - # Security: check file size before reading - stat = skill_md_path.stat() - if stat.st_size > MAX_FILE_SIZE: - logger.error( - "Skipping %s: file size %.2fMB exceeds limit", - skill_md_path, - stat.st_size / 1024 / 1024, - ) - continue - - # Security: verify path is within skills directory - if not _is_path_within_base(skill_md_path, resolved_dir): - logger.error( - "Skipping %s: path escapes skills directory", skill_md_path - ) - continue - - try: - content = skill_md_path.read_text(encoding="utf-8") - frontmatter, _body = _parse_frontmatter(content) - - name = frontmatter.get("name") - description = frontmatter.get("description") - - if not isinstance(name, str) or not name.strip(): - logger.error( - "Skill at %s: missing or invalid 'name' field", entry - ) - continue - if not isinstance(description, str) or not description.strip(): - logger.error( - "Skill at %s: missing or invalid 'description' field", entry - ) - continue - - # Extract optional metadata - extra_metadata: dict[str, str] = {} - raw_meta = frontmatter.get("metadata") - if isinstance(raw_meta, dict): - for k, v in raw_meta.items(): - if isinstance(v, str): - extra_metadata[k] = v - - skill_name = name.strip() - if skill_name in skill_map: - logger.warning( - "Duplicate skill name '%s' at %s — keeping first", - skill_name, - skill_md_path, - ) - continue - - # Scan for supplementary documents - skill_dir_str = str(entry) - documents = scan_documents(skill_dir_str, str(resolved_dir)) - - skill_map[skill_name] = SkillMetadata( - name=skill_name, - description=description.strip(), - path=str(skill_md_path), - skill_dir=skill_dir_str, - metadata=extra_metadata if extra_metadata else {}, - documents=documents, - ) - except (OSError, ValueError) as exc: - logger.error("Failed to parse skill at %s: %s", entry, exc) - - return skill_map - - -def load_skill_content(skill_path: str, skills_dir: str) -> str: - """Load the full content of a SKILL.md file. - - Security: validates path is within skills directory, only reads .md files, - and enforces a file size limit. - """ - target = Path(skill_path) - base = Path(skills_dir) - - # Security: only allow .md files - if target.suffix.lower() != ".md": - raise ValueError("Only .md files can be read") - - # Security: verify path is within skills directory - if not _is_path_within_base(target, base): - raise ValueError("Path escapes the skills directory") - - # Security: check file size - stat = target.stat() - if stat.st_size > MAX_FILE_SIZE: - raise ValueError( - f"File size {stat.st_size / 1024 / 1024:.2f}MB exceeds " - f"{MAX_FILE_SIZE / 1024 / 1024:.0f}MB limit" - ) - - return target.read_text(encoding="utf-8") - - -def load_document(skill: SkillMetadata, document_path: str, skills_dir: str) -> str: - """Load a supplementary document from a skill directory. - - Security: validates path is within skills directory, rejects path - traversal attempts, and enforces a file size limit. - """ - # Security: reject path traversal attempts - if ".." in document_path: - raise ValueError("Path traversal not allowed") - - target = Path(skill.skill_dir) / document_path - base = Path(skills_dir) - - # Security: verify path is within skills directory - if not _is_path_within_base(target, base): - raise ValueError("Path escapes the skills directory") - - # Security: check file size - stat = target.stat() - if stat.st_size > MAX_FILE_SIZE: - raise ValueError( - f"File size {stat.st_size / 1024 / 1024:.2f}MB exceeds " - f"{MAX_FILE_SIZE / 1024 / 1024:.0f}MB limit" - ) - - return target.read_text(encoding="utf-8") From 29a3bef567706a2782af7fa2732fac756de00a0f Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 08:21:40 -0700 Subject: [PATCH 04/21] Remove Python references from README Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/README.md | 40 ++------------------------ 1 file changed, 3 insertions(+), 37 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 359aa61..0fcd445 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -41,8 +41,6 @@ This is a **hybrid** approach: resources provide **application-controlled** acce ## Implementations -> **Note**: The TypeScript implementation has been updated with the new URI scheme, `_manifest` resource, and `load_skill` tool. The Python implementation still uses the previous URI scheme and will be updated in a follow-up. - ### TypeScript **Prerequisites**: Node.js >= 18, npm @@ -63,47 +61,15 @@ npx @modelcontextprotocol/inspector node dist/index.js ../sample-skills npm run dev -- ../sample-skills ``` -### Python - -**Prerequisites**: Python >= 3.10, pip (or uv) - -```bash -cd python -pip install -e . -``` - -**Run with MCP Inspector**: -```bash -npx @modelcontextprotocol/inspector -- python -m skills_as_resources.server ../sample-skills -``` - -> **Note**: The Python implementation uses the previous URI scheme (`skill://{name}`, `skill://{name}/documents`, `skill://{name}/document/{path}`). It will be updated to match the TypeScript implementation in a follow-up. - -### SDK Difference: Supporting File Path Encoding - -The TypeScript MCP SDK supports RFC 6570 reserved expansion (`{+path}`), so file URIs use natural paths: - -``` -skill://code-review/references/REFERENCE.md -``` - -The Python MCP SDK uses `[^/]+` regex for all template parameters, so forward slashes in paths must be URL-encoded: - -``` -skill://code-review/references%2FREFERENCE.md -``` - -The SDK automatically URL-decodes the path after matching, so the handler receives the natural path in both cases. This difference is transparent to the resource handler logic. - ## Security Features -Both implementations include: +The implementation includes: -- **Path traversal protection** — Resolved paths are checked against the skills directory boundary using `realpathSync` (TS) / `Path.resolve()` (Python). Symlink escapes are detected. +- **Path traversal protection** — Resolved paths are checked against the skills directory boundary using `realpathSync`. Symlink escapes are detected. - **Skill name validation** — Resources and the `load_skill` tool look up names by key in the discovered skills map. User input is never used to construct file paths. - **File path validation** — Paths containing `..` are rejected. All paths are verified to be within the skills directory. - **File size limits** — Files larger than 1MB are skipped during discovery and rejected on read. -- **Safe YAML parsing** — Python uses `yaml.safe_load()` to prevent code execution. TypeScript uses the `yaml` package which is safe by default. +- **Safe YAML parsing** — Uses the `yaml` package which is safe by default. - **Content integrity** — The `_manifest` resource includes SHA256 hashes for all files, enabling clients to verify downloaded content. ## Sample Skills From af4885624a581bfcc3b156f939e6f0e22939e649 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 08:53:26 -0700 Subject: [PATCH 05/21] Add resources/subscribe + resources/unsubscribe support Implement MCP resource subscriptions so clients can watch skill files for changes. A new SubscriptionManager (src/subscriptions.ts) uses chokidar to create on-demand file watchers for subscribed URIs and sends notifications/resources/updated when files change on disk. - Add chokidar ^4.0.0 dependency for cross-platform file watching - Create subscriptions.ts with bidirectional URI-to-path mapping, per-URI debouncing (100ms), and automatic watcher cleanup - Register subscribe/unsubscribe handlers via low-level Server API - Declare subscribe: true in server capabilities Co-Authored-By: Claude Opus 4.6 --- .../typescript/package-lock.json | 29 +++ .../typescript/package.json | 1 + .../typescript/src/index.ts | 29 ++- .../typescript/src/subscriptions.ts | 237 ++++++++++++++++++ 4 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 examples/skills-as-resources/typescript/src/subscriptions.ts diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json index 053e2ac..3a6d87e 100644 --- a/examples/skills-as-resources/typescript/package-lock.json +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.0", + "chokidar": "^4.0.3", "yaml": "^2.7.0", "zod": "^3.23.0" }, @@ -634,6 +635,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1403,6 +1419,19 @@ "node": ">= 0.10" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index 702fd44..a6e5d15 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.25.0", + "chokidar": "^4.0.3", "yaml": "^2.7.0", "zod": "^3.23.0" }, diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 7058e49..1805ff9 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -29,8 +29,10 @@ import { fileURLToPath } from "node:url"; import { z } from "zod"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { discoverSkills, loadSkillContent, loadDocument } from "./skill-discovery.js"; import { generateSkillsXML, isTextMimeType } from "./resource-helpers.js"; +import { createSubscriptionManager } from "./subscriptions.js"; // Resolve skills directory from CLI arg or default to ../sample-skills const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -54,7 +56,7 @@ for (const [name, skill] of skillMap) { // Create MCP server with resources and tools capabilities const server = new McpServer( { name: "skills-as-resources-example", version: "0.2.0" }, - { capabilities: { resources: { listChanged: true }, tools: {} } } + { capabilities: { resources: { listChanged: true, subscribe: true }, tools: {} } } ); // --- Static resources --- @@ -270,6 +272,31 @@ server.registerTool( } ); +// --- Resource subscriptions --- + +// Watch subscribed skill files and notify on changes. +const subscriptions = createSubscriptionManager( + skillMap, + skillsDir, + (uri) => { server.server.sendResourceUpdated({ uri }); }, +); + +server.server.setRequestHandler(SubscribeRequestSchema, async (request) => { + subscriptions.subscribe(request.params.uri); + return {}; +}); + +server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { + subscriptions.unsubscribe(request.params.uri); + return {}; +}); + +// Clean up watchers on exit +process.on("SIGINT", () => { + subscriptions.close(); + process.exit(0); +}); + // Connect via stdio transport const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/examples/skills-as-resources/typescript/src/subscriptions.ts b/examples/skills-as-resources/typescript/src/subscriptions.ts new file mode 100644 index 0000000..93cfaf5 --- /dev/null +++ b/examples/skills-as-resources/typescript/src/subscriptions.ts @@ -0,0 +1,237 @@ +/** + * Resource subscription manager for the Skills as Resources MCP server. + * + * Tracks client subscriptions to skill:// URIs and sets up file watchers + * (via chokidar) so that `notifications/resources/updated` is sent when + * the underlying file(s) change on disk. Watchers are created on-demand + * when a URI is subscribed and cleaned up when unsubscribed. + * + * Inspired by: + * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) + */ + +import * as path from "node:path"; +import { watch, type FSWatcher } from "chokidar"; +import { isPathWithinBase } from "./skill-discovery.js"; +import type { SkillMetadata } from "./types.js"; + +/** Debounce interval (ms) for coalescing rapid file changes. */ +const DEBOUNCE_MS = 100; + +export interface SubscriptionManager { + /** Register interest in change notifications for `uri`. */ + subscribe(uri: string): void; + /** Remove interest in change notifications for `uri`. */ + unsubscribe(uri: string): void; + /** Tear down all watchers and clear internal state. */ + close(): void; +} + +/** + * Resolve a `skill://` URI to the file path(s) that should be watched. + * + * Returns an empty array for URIs that cannot be resolved (unknown skill, + * path traversal, etc.) — the subscribe still succeeds per MCP spec but + * no watcher is created. + */ +function resolveUriToFilePaths( + uri: string, + skillMap: Map, + skillsDir: string, +): string[] { + // skill://prompt-xml — depends on every SKILL.md + if (uri === "skill://prompt-xml") { + return Array.from(skillMap.values()).map((s) => s.path); + } + + // Parse skill:// URIs: skill://{name}/SKILL.md | skill://{name}/_manifest | skill://{name}/{path} + const match = uri.match(/^skill:\/\/([^/]+)\/(.+)$/); + if (!match) return []; + + const [, skillName, rest] = match; + const skill = skillMap.get(skillName); + if (!skill) return []; + + if (rest === "SKILL.md") { + return [skill.path]; + } + + if (rest === "_manifest") { + // Any file change in the skill directory affects the manifest. + // Watch the directory itself (chokidar watches recursively). + return [skill.skillDir]; + } + + // Supporting file: skill://{name}/{path} + const filePath = path.join(skill.skillDir, rest); + if (!isPathWithinBase(filePath, skillsDir)) return []; + return [filePath]; +} + +/** + * Create a subscription manager bound to the given skill map. + * + * @param skillMap Discovered skills (name → metadata). + * @param skillsDir Root skills directory (for path security checks). + * @param notifyCallback Called with the URI when a subscribed resource changes. + */ +export function createSubscriptionManager( + skillMap: Map, + skillsDir: string, + notifyCallback: (uri: string) => void, +): SubscriptionManager { + /** URIs the client has subscribed to. */ + const subscribedUris = new Set(); + + /** URI → set of absolute file paths being watched for it. */ + const uriToFilePaths = new Map>(); + + /** Absolute file path → set of URIs that depend on it. */ + const filePathToUris = new Map>(); + + /** Active chokidar watchers keyed by the path being watched. */ + const watchers = new Map(); + + /** Per-URI debounce timers. */ + const debounceTimers = new Map>(); + + /** + * Called by chokidar when a watched path changes. + * Fans out to every URI that depends on the changed path, + * debouncing each independently. + */ + function onFileChange(changedPath: string): void { + // Normalize to forward slashes for consistent lookup + const normalized = changedPath.replace(/\\/g, "/"); + + // Check both the normalized path and the original against the map. + // Also check if the changed path is *inside* a watched directory. + for (const [watchedPath, uris] of filePathToUris) { + const watchedNorm = watchedPath.replace(/\\/g, "/"); + const changedNorm = normalized; + + const isMatch = + changedNorm === watchedNorm || + changedNorm.startsWith(watchedNorm + "/"); + + if (!isMatch) continue; + + for (const uri of uris) { + // Clear any existing timer for this URI + const existing = debounceTimers.get(uri); + if (existing) clearTimeout(existing); + + debounceTimers.set( + uri, + setTimeout(() => { + debounceTimers.delete(uri); + // Only notify if still subscribed (may have unsubscribed during debounce) + if (subscribedUris.has(uri)) { + notifyCallback(uri); + } + }, DEBOUNCE_MS), + ); + } + } + } + + /** + * Start watching `filePath` if not already watched. + */ + function ensureWatcher(filePath: string): void { + if (watchers.has(filePath)) return; + + const watcher = watch(filePath, { + ignoreInitial: true, + awaitWriteFinish: { stabilityThreshold: 50, pollInterval: 10 }, + }); + + watcher.on("change", (p) => onFileChange(p)); + watcher.on("add", (p) => onFileChange(p)); + watcher.on("unlink", (p) => onFileChange(p)); + + watchers.set(filePath, watcher); + } + + /** + * Stop watching `filePath` and remove the watcher. + */ + function removeWatcher(filePath: string): void { + const watcher = watchers.get(filePath); + if (watcher) { + watcher.close(); + watchers.delete(filePath); + } + } + + return { + subscribe(uri: string): void { + if (subscribedUris.has(uri)) return; // already subscribed + subscribedUris.add(uri); + + const paths = resolveUriToFilePaths(uri, skillMap, skillsDir); + if (paths.length === 0) return; // unknown URI — accept silently + + uriToFilePaths.set(uri, new Set(paths)); + + for (const p of paths) { + let uris = filePathToUris.get(p); + if (!uris) { + uris = new Set(); + filePathToUris.set(p, uris); + } + uris.add(uri); + ensureWatcher(p); + } + + console.error(`[subscriptions] Subscribed: ${uri} (watching ${paths.length} path(s))`); + }, + + unsubscribe(uri: string): void { + if (!subscribedUris.has(uri)) return; // not subscribed — no-op + subscribedUris.delete(uri); + + // Clear any pending debounce timer + const timer = debounceTimers.get(uri); + if (timer) { + clearTimeout(timer); + debounceTimers.delete(uri); + } + + const paths = uriToFilePaths.get(uri); + if (!paths) return; + + for (const p of paths) { + const uris = filePathToUris.get(p); + if (uris) { + uris.delete(uri); + if (uris.size === 0) { + filePathToUris.delete(p); + removeWatcher(p); + } + } + } + + uriToFilePaths.delete(uri); + console.error(`[subscriptions] Unsubscribed: ${uri}`); + }, + + close(): void { + for (const timer of debounceTimers.values()) { + clearTimeout(timer); + } + debounceTimers.clear(); + + for (const watcher of watchers.values()) { + watcher.close(); + } + watchers.clear(); + + subscribedUris.clear(); + uriToFilePaths.clear(); + filePathToUris.clear(); + + console.error("[subscriptions] Closed all watchers"); + }, + }; +} From 09882d3b9364b6a4a515e8e7a4dac0e7c0db9e50 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 08:54:50 -0700 Subject: [PATCH 06/21] Update README to reflect resource subscription support Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 0fcd445..501bdab 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -37,7 +37,8 @@ This is a **hybrid** approach: resources provide **application-controlled** acce - **Application-controlled** (via resources): `resources/list` → scan for `skill://*/SKILL.md` → read `skill://{name}/SKILL.md` on demand → read `skill://{name}/_manifest` for file inventory → read `skill://{name}/{path}` for supporting files - **Model-controlled** (via tool): `tools/list` → discover `load_skill` with available skill names in description → call `load_skill("code-review")` to get full content 5. **System prompt injection**: `skill://prompt-xml` provides XML that hosts can inject into system prompts -6. **Capability declaration**: Server declares `resources.listChanged` capability (dynamic updates could be wired to a file watcher in a full implementation) +6. **Capability declaration**: Server declares `resources.listChanged` and `resources.subscribe` capabilities +7. **Resource subscriptions**: Clients can call `resources/subscribe` on any `skill://` URI to receive `notifications/resources/updated` when the underlying file(s) change on disk. Watchers are created on-demand via [chokidar](https://github.com/paulmillr/chokidar) and cleaned up on unsubscribe. ## Implementations @@ -104,8 +105,7 @@ Two sample skills are included in `sample-skills/` for testing: ## What This Example Intentionally Omits -- File watching / resource subscriptions (capability is declared but not wired) -- Dynamic updates (`resources.listChanged` is declared but not triggered) +- Directory watching for new/removed skills (`resources.listChanged` is declared but not triggered) - MCP Prompts for explicit skill invocation - Client-side `SkillCatalog` for multi-server skill aggregation (see skillsdotnet for a reference implementation) - GitHub sync, configuration UI From 1425e124d8c68de3c1dd8c1b0a12e3d0f181d496 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 08:59:12 -0700 Subject: [PATCH 07/21] Fix skillsdotnet attribution to correct author (PederHP) Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 501bdab..19cf8ca 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -153,4 +153,4 @@ This reference implementation is original code inspired by patterns from: - **[skills-over-mcp](https://github.com/keithagroves/skills-over-mcp)** by [Keith Groves](https://github.com/keithagroves) — Resource-based skill exposure, `skill://` URI scheme, JSON index, XML prompt injection, document templates - **[skilljack-mcp](https://github.com/olaservo/skilljack-mcp)** by [Ola Hungerford](https://github.com/olaservo) — Resource template patterns, subscription architecture, path security -- **[SkillsDotNet](https://github.com/bradwilson/skillsdotnet)** by [Brad Wilson](https://github.com/bradwilson) — `_manifest` resource with file hashes, `load_skill` tool, `SkillCatalog` client-side pattern, URI scheme conventions +- **[skillsdotnet](https://github.com/PederHP/skillsdotnet)** by [Peder HP](https://github.com/PederHP) — `_manifest` resource with file hashes, `load_skill` tool, `SkillCatalog` client-side pattern, URI scheme conventions From 225a9cf2c99cb7bc9263361a5d63375df8b11b91 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 18 Feb 2026 21:19:42 -0700 Subject: [PATCH 08/21] Add file watching for dynamic skill discovery (resources/listChanged) Watch the skills directory for structural changes (new/removed skill directories) using chokidar and dynamically update the MCP resource list, sending resources/listChanged notifications to connected clients. Co-Authored-By: Claude Opus 4.6 --- .../typescript/src/index.ts | 95 ++++++++++++--- .../typescript/src/skill-watcher.ts | 112 ++++++++++++++++++ .../typescript/src/subscriptions.ts | 11 ++ 3 files changed, 202 insertions(+), 16 deletions(-) create mode 100644 examples/skills-as-resources/typescript/src/skill-watcher.ts diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 1805ff9..7f181e4 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -28,11 +28,13 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { z } from "zod"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RegisteredResource, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { discoverSkills, loadSkillContent, loadDocument } from "./skill-discovery.js"; import { generateSkillsXML, isTextMimeType } from "./resource-helpers.js"; import { createSubscriptionManager } from "./subscriptions.js"; +import { createSkillDirectoryWatcher } from "./skill-watcher.js"; // Resolve skills directory from CLI arg or default to ../sample-skills const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -42,11 +44,15 @@ const skillsDir = process.argv[2] // Discover skills at startup const skillMap = discoverSkills(skillsDir); -const skillNames = Array.from(skillMap.keys()); -const skillListStr = skillNames.join(", ") || "none"; + +/** Dynamic skill list string — reflects current skillMap contents. */ +function getSkillListStr(): string { + const names = Array.from(skillMap.keys()); + return names.join(", ") || "none"; +} console.error( - `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${skillListStr}` + `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${getSkillListStr()}` ); for (const [name, skill] of skillMap) { const fileCount = skill.manifest.files.length; @@ -80,10 +86,21 @@ server.registerResource( }) ); -// Per-skill static resources -for (const [name, skill] of skillMap) { - // Resource: skill://{name}/SKILL.md — skill content (listed) - server.registerResource( +// Track resource handles so we can remove them when skills are removed dynamically. +const resourceHandles = new Map(); + +/** + * Register the SKILL.md and _manifest resources for a single skill. + * Returns the resource handles for later removal. + */ +function registerSkillResources( + name: string, + skill: import("./types.js").SkillMetadata, +): { skill: RegisteredResource; manifest: RegisteredResource } { + const skillHandle = server.registerResource( `skill-${name}`, `skill://${name}/SKILL.md`, { @@ -110,8 +127,7 @@ for (const [name, skill] of skillMap) { } ); - // Resource: skill://{name}/_manifest — file inventory with SHA256 hashes (listed) - server.registerResource( + const manifestHandle = server.registerResource( `skill-${name}-manifest`, `skill://${name}/_manifest`, { @@ -127,6 +143,13 @@ for (const [name, skill] of skillMap) { ], }) ); + + return { skill: skillHandle, manifest: manifestHandle }; +} + +// Per-skill static resources +for (const [name, skill] of skillMap) { + resourceHandles.set(name, registerSkillResources(name, skill)); } // --- Resource template for supporting files --- @@ -177,7 +200,7 @@ server.registerResource( contents: [ { uri: uri.href, - text: `# Error\n\nSkill "${skillName}" not found. Available: ${skillListStr}`, + text: `# Error\n\nSkill "${skillName}" not found. Available: ${getSkillListStr()}`, }, ], }; @@ -227,13 +250,15 @@ server.registerResource( // Tool: load_skill — allows models to discover and load skills on demand. // Description dynamically lists available skill names, mirroring // skillsdotnet's SkillCatalog pattern but implemented server-side. -server.registerTool( +const loadSkillToolDescription = () => + `Load the full SKILL.md content for a named skill. ` + + `Use this when you need detailed instructions for performing a specific task. ` + + `Available skills: ${getSkillListStr()}`; + +const loadSkillTool: RegisteredTool = server.registerTool( "load_skill", { - description: - `Load the full SKILL.md content for a named skill. ` + - `Use this when you need detailed instructions for performing a specific task. ` + - `Available skills: ${skillListStr}`, + description: loadSkillToolDescription(), inputSchema: { skillName: z.string().describe("The name of the skill to load"), }, @@ -245,7 +270,7 @@ server.registerTool( content: [ { type: "text" as const, - text: `Skill "${skillName}" not found. Available skills: ${skillListStr}`, + text: `Skill "${skillName}" not found. Available skills: ${getSkillListStr()}`, }, ], isError: true, @@ -291,8 +316,46 @@ server.server.setRequestHandler(UnsubscribeRequestSchema, async (request) => { return {}; }); +// --- Directory watcher for dynamic skill discovery --- + +// Watch skillsDir for structural changes (new/removed skill directories) +// and update the resource list + notify clients via resources/listChanged. +const directoryWatcher = createSkillDirectoryWatcher(skillsDir, () => { + const newSkillMap = discoverSkills(skillsDir); + + // Find removed skills + for (const name of skillMap.keys()) { + if (!newSkillMap.has(name)) { + const handles = resourceHandles.get(name); + if (handles) { + handles.skill.remove(); + handles.manifest.remove(); + resourceHandles.delete(name); + } + subscriptions.unsubscribeByPrefix(`skill://${name}/`); + skillMap.delete(name); + console.error(`[skills-as-resources] Skill removed: ${name}`); + } + } + + // Find added skills + for (const [name, metadata] of newSkillMap) { + if (!skillMap.has(name)) { + skillMap.set(name, metadata); + resourceHandles.set(name, registerSkillResources(name, metadata)); + console.error( + `[skills-as-resources] Skill added: ${name} (${metadata.manifest.files.length} file(s))` + ); + } + } + + // Update load_skill tool description with current skill list + loadSkillTool.update({ description: loadSkillToolDescription() }); +}); + // Clean up watchers on exit process.on("SIGINT", () => { + directoryWatcher.close(); subscriptions.close(); process.exit(0); }); diff --git a/examples/skills-as-resources/typescript/src/skill-watcher.ts b/examples/skills-as-resources/typescript/src/skill-watcher.ts new file mode 100644 index 0000000..6d02878 --- /dev/null +++ b/examples/skills-as-resources/typescript/src/skill-watcher.ts @@ -0,0 +1,112 @@ +/** + * Directory-level file watcher for the Skills as Resources MCP server. + * + * Watches the skills directory for structural changes — new or removed + * skill directories, and SKILL.md files appearing or disappearing — so + * the server can dynamically update its resource list and send + * `notifications/resources/list_changed` to connected clients. + * + * Inspired by: + * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) + */ + +import * as path from "node:path"; +import { watch, type FSWatcher } from "chokidar"; + +/** Debounce interval (ms) — long enough for a directory + SKILL.md to be written. */ +const DEBOUNCE_MS = 500; + +/** File names that make a directory a valid skill. */ +const SKILL_FILE_NAMES = new Set(["SKILL.md", "skill.md"]); + +export interface SkillDirectoryWatcher { + /** Tear down the watcher and clear pending timers. */ + close(): void; +} + +/** + * Watch `skillsDir` for structural changes (new/removed skills) and call + * `onChanged` when the set of valid skill directories may have changed. + * + * Only reacts to: + * - Directory additions/removals directly inside `skillsDir` + * - SKILL.md / skill.md files appearing or disappearing inside those directories + * + * Changes are debounced so that a directory being populated (mkdir → write SKILL.md) + * triggers a single callback invocation. + */ +export function createSkillDirectoryWatcher( + skillsDir: string, + onChanged: () => void, +): SkillDirectoryWatcher { + let debounceTimer: ReturnType | null = null; + + function scheduleCallback(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + onChanged(); + }, DEBOUNCE_MS); + } + + /** + * Determine whether a filesystem event is relevant to skill discovery. + * We care about: + * - Directories added/removed directly under skillsDir + * - SKILL.md files added/removed one level deep + */ + function isRelevantEvent(eventPath: string, eventType: string): boolean { + const normalized = eventPath.replace(/\\/g, "/"); + const base = skillsDir.replace(/\\/g, "/"); + const relative = normalized.startsWith(base + "/") + ? normalized.slice(base.length + 1) + : null; + + if (!relative) return false; + + const segments = relative.split("/"); + + // Direct subdirectory added/removed: "my-skill" + if (segments.length === 1 && (eventType === "addDir" || eventType === "unlinkDir")) { + return true; + } + + // SKILL.md added/removed inside a direct subdirectory: "my-skill/SKILL.md" + if (segments.length === 2 && SKILL_FILE_NAMES.has(segments[1])) { + return true; + } + + return false; + } + + const watcher = watch(skillsDir, { + ignoreInitial: true, + depth: 1, + }); + + watcher.on("addDir", (p) => { + if (isRelevantEvent(p, "addDir")) scheduleCallback(); + }); + watcher.on("unlinkDir", (p) => { + if (isRelevantEvent(p, "unlinkDir")) scheduleCallback(); + }); + watcher.on("add", (p) => { + if (isRelevantEvent(p, "add")) scheduleCallback(); + }); + watcher.on("unlink", (p) => { + if (isRelevantEvent(p, "unlink")) scheduleCallback(); + }); + + console.error(`[skill-watcher] Watching for skill directory changes: ${skillsDir}`); + + return { + close(): void { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + watcher.close(); + console.error("[skill-watcher] Stopped watching"); + }, + }; +} diff --git a/examples/skills-as-resources/typescript/src/subscriptions.ts b/examples/skills-as-resources/typescript/src/subscriptions.ts index 93cfaf5..a75483a 100644 --- a/examples/skills-as-resources/typescript/src/subscriptions.ts +++ b/examples/skills-as-resources/typescript/src/subscriptions.ts @@ -23,6 +23,8 @@ export interface SubscriptionManager { subscribe(uri: string): void; /** Remove interest in change notifications for `uri`. */ unsubscribe(uri: string): void; + /** Unsubscribe all URIs matching a prefix (used when a skill is removed). */ + unsubscribeByPrefix(prefix: string): void; /** Tear down all watchers and clear internal state. */ close(): void; } @@ -216,6 +218,15 @@ export function createSubscriptionManager( console.error(`[subscriptions] Unsubscribed: ${uri}`); }, + unsubscribeByPrefix(prefix: string): void { + const toRemove = Array.from(subscribedUris).filter((uri) => + uri.startsWith(prefix), + ); + for (const uri of toRemove) { + this.unsubscribe(uri); + } + }, + close(): void { for (const timer of debounceTimers.values()) { clearTimeout(timer); From 4eaaf8606f15e26d901f2c0592c3b27e7b89c1a9 Mon Sep 17 00:00:00 2001 From: olaservo Date: Thu, 19 Feb 2026 08:55:51 -0700 Subject: [PATCH 09/21] Remove stale "Intentionally Omits" section from README File watching, listChanged, and subscriptions are all implemented now. The remaining items (MCP Prompts, SkillCatalog, GitHub sync) are tracked in TODO.md rather than the user-facing README. Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 19cf8ca..20b4772 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -103,14 +103,6 @@ Two sample skills are included in `sample-skills/` for testing: | Input validation | Zod schema on tool parameters | URI template matching (resources) + Zod schema (`load_skill` tool) | | Supporting files | Not demonstrated | `ResourceTemplate` for files, `_manifest` for discovery | -## What This Example Intentionally Omits - -- Directory watching for new/removed skills (`resources.listChanged` is declared but not triggered) -- MCP Prompts for explicit skill invocation -- Client-side `SkillCatalog` for multi-server skill aggregation (see skillsdotnet for a reference implementation) -- GitHub sync, configuration UI -- `skill://` URI scheme registration or standardization - ## Answers to Open Question #12 > "Why not just use resources?" From 6a5ff313b2c2fad18a7ee7edd78007b9e971e76b Mon Sep 17 00:00:00 2001 From: Ola Hungerford Date: Thu, 19 Feb 2026 18:51:28 -0800 Subject: [PATCH 10/21] Apply suggestions from code review Co-authored-by: Peder Holdgaard Pedersen <127606677+PederHP@users.noreply.github.com> --- examples/skills-as-resources/README.md | 2 +- examples/skills-as-resources/typescript/src/index.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 20b4772..0fbb708 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -126,7 +126,7 @@ This implementation shows that resources **do work** for skill delivery, and tha ## Convergence with SkillsDotNet -The URI scheme in this implementation is aligned with [SkillsDotNet](https://github.com/bradwilson/skillsdotnet), a C# implementation of the same pattern. Both implementations use: +The URI scheme in this implementation is aligned with [SkillsDotNet](https://github.com/pederhp/skillsdotnet), a C# implementation of the same pattern. Both implementations use: - `skill://{name}/SKILL.md` — listed resource for skill content - `skill://{name}/_manifest` — listed resource for file inventory (with SHA256 hashes) diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 7f181e4..506e3c2 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -19,7 +19,7 @@ * Inspired by: * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) - * - SkillsDotNet by Brad Wilson (https://github.com/bradwilson/skillsdotnet) + * - SkillsDotNet by Peder Holdgaard Pedersen (https://github.com/pederhp/skillsdotnet) * * @license Apache-2.0 */ From 581587be8d8bb1fef92115abaf5de5cf9f59189e Mon Sep 17 00:00:00 2001 From: olaservo Date: Sat, 21 Feb 2026 16:01:50 -0700 Subject: [PATCH 11/21] Add MCP resource annotations (audience, priority, lastModified) All skill resources now include content annotations to help clients filter and prioritize resources. Defaults to both audiences (user and assistant); per-skill audience narrowing will be supported when the Agent Skills spec finalizes a metadata field for this. Co-Authored-By: Claude Opus 4.6 --- .../sample-skills/code-review/SKILL.md | 0 .../code-review/references/REFERENCE.md | 0 .../sample-skills/git-commit-review/SKILL.md | 0 examples/skills-as-resources/README.md | 40 ++++++++++++-- .../typescript/src/index.ts | 52 ++++++++++++++++--- .../typescript/src/skill-discovery.ts | 1 + .../typescript/src/types.ts | 1 + 7 files changed, 82 insertions(+), 12 deletions(-) rename examples/{skills-as-resources => }/sample-skills/code-review/SKILL.md (100%) rename examples/{skills-as-resources => }/sample-skills/code-review/references/REFERENCE.md (100%) rename examples/{skills-as-resources => }/sample-skills/git-commit-review/SKILL.md (100%) diff --git a/examples/skills-as-resources/sample-skills/code-review/SKILL.md b/examples/sample-skills/code-review/SKILL.md similarity index 100% rename from examples/skills-as-resources/sample-skills/code-review/SKILL.md rename to examples/sample-skills/code-review/SKILL.md diff --git a/examples/skills-as-resources/sample-skills/code-review/references/REFERENCE.md b/examples/sample-skills/code-review/references/REFERENCE.md similarity index 100% rename from examples/skills-as-resources/sample-skills/code-review/references/REFERENCE.md rename to examples/sample-skills/code-review/references/REFERENCE.md diff --git a/examples/skills-as-resources/sample-skills/git-commit-review/SKILL.md b/examples/sample-skills/git-commit-review/SKILL.md similarity index 100% rename from examples/skills-as-resources/sample-skills/git-commit-review/SKILL.md rename to examples/sample-skills/git-commit-review/SKILL.md diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 0fbb708..3516d2b 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -54,12 +54,24 @@ npm run build **Run with MCP Inspector**: ```bash -npx @modelcontextprotocol/inspector node dist/index.js ../sample-skills +npx @modelcontextprotocol/inspector node dist/index.js ../../sample-skills ``` **Development mode** (no build step): ```bash -npm run dev -- ../sample-skills +npm run dev -- ../../sample-skills +``` + +### Options + +| Flag | Default | Description | +| :--- | :--- | :--- | +| `[skillsDir]` | `examples/sample-skills/` | Path to the skills directory | +| `--no-embed-catalog` | off (catalog embedded) | Disable embedding `` XML in the `load_skill` tool description. Use when the client already injects skill context from resources, to avoid duplicate injection. | + +**Example** — disable catalog embedding for a client with native `skill://` support: +```bash +node dist/index.js ../../sample-skills --no-embed-catalog ``` ## Security Features @@ -75,7 +87,7 @@ The implementation includes: ## Sample Skills -Two sample skills are included in `sample-skills/` for testing: +Two shared sample skills are included in [`examples/sample-skills/`](../../sample-skills/) for testing: | Skill | Description | Documents | Notes | | :--- | :--- | :--- | :--- | @@ -85,11 +97,31 @@ Two sample skills are included in `sample-skills/` for testing: ## Key Design Decisions - **Hybrid approach (resources + tool)**: Resources provide application-controlled access for hosts that want to manage context. The `load_skill` tool provides model-controlled access for progressive disclosure. Experimental findings show models reliably use tools but tend to ignore resources (see [`docs/experimental-findings.md`](../../docs/experimental-findings.md)), making the hybrid approach more practical than resources alone. -- **URI scheme aligned with skillsdotnet**: The `skill://{name}/SKILL.md` and `skill://{name}/_manifest` URI conventions match the [SkillsDotNet](https://github.com/bradwilson/skillsdotnet) C# implementation. This enables cross-implementation interoperability — a client-side `SkillCatalog` can discover skills from either implementation by scanning `resources/list` for `skill://*/SKILL.md` URIs. +- **URI scheme aligned with skillsdotnet**: The `skill://{name}/SKILL.md` and `skill://{name}/_manifest` URI conventions match the [SkillsDotNet](https://github.com/pederhp/skillsdotnet) C# implementation. This enables cross-implementation interoperability — a client-side `SkillCatalog` can discover skills from either implementation by scanning `resources/list` for `skill://*/SKILL.md` URIs. - **`_manifest` with SHA256 hashes**: Pre-computed at startup with file sizes and content hashes. Enables download/sync workflows and cache invalidation without re-reading files on each request. - **Listed resources for skills, template for supporting files**: Each skill's `SKILL.md` and `_manifest` are concrete resources visible in `resources/list`. Supporting files are accessed via a `ResourceTemplate` (`skill://{name}/{+path}`) and are discoverable through the `_manifest` — keeping `resources/list` clean. - **`skill://prompt-xml` for injection**: Optional convenience resource that allows hosts to inject skill awareness into system prompts using the resources primitive. +## Resource Annotations + +All resources include MCP [resource annotations](https://modelcontextprotocol.io/specification/draft/server/resources#annotations) to help clients filter, prioritize, and display skill resources appropriately. + +| Resource | `audience` | `priority` | `lastModified` | +| :--- | :--- | :--- | :--- | +| `skill://{name}/SKILL.md` | `["user", "assistant"]` | `1.0` | SKILL.md file mtime | +| `skill://{name}/_manifest` | `["user", "assistant"]` | `0.5` | SKILL.md file mtime | +| `skill://{name}/{+path}` | `["user", "assistant"]` | `0.2` | — | +| `skill://prompt-xml` | `["user", "assistant"]` | `0.3` | — | + +All resources default to both audiences. The [Agent Skills specification](https://agentskills.org) is actively discussing a frontmatter `metadata` field (e.g., `invocation: model | user`) that would allow skill authors to narrow the intended audience per-skill. When that is finalized, this implementation will parse it from `metadata` and map it to the MCP `audience` annotation accordingly. + +### Priority Scale + +- **1.0** — Primary skill content (SKILL.md). Clients should include these in context. +- **0.5** — Supporting metadata (\_manifest). Include if context budget allows. +- **0.3** — Convenience resources (prompt-xml). Optional. +- **0.2** — Supporting files (template). Load on demand only. + ## How This Differs from Skills as Tools | Aspect | Skills as Tools | Skills as Resources (this example) | diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 506e3c2..1bfd6f3 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -26,6 +26,7 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; import { z } from "zod"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { RegisteredResource, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -36,11 +37,19 @@ import { generateSkillsXML, isTextMimeType } from "./resource-helpers.js"; import { createSubscriptionManager } from "./subscriptions.js"; import { createSkillDirectoryWatcher } from "./skill-watcher.js"; -// Resolve skills directory from CLI arg or default to ../sample-skills +// Parse CLI arguments: [skillsDir] [--no-embed-catalog] const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const skillsDir = process.argv[2] - ? path.resolve(process.argv[2]) - : path.resolve(__dirname, "../../sample-skills"); +const { values: flags, positionals } = parseArgs({ + args: process.argv.slice(2), + options: { + "no-embed-catalog": { type: "boolean", default: false }, + }, + allowPositionals: true, +}); +const embedCatalog = !flags["no-embed-catalog"]; +const skillsDir = positionals[0] + ? path.resolve(positionals[0]) + : path.resolve(__dirname, "../../../sample-skills"); // Discover skills at startup const skillMap = discoverSkills(skillsDir); @@ -54,6 +63,9 @@ function getSkillListStr(): string { console.error( `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${getSkillListStr()}` ); +console.error( + `[skills-as-resources] Catalog embedding: ${embedCatalog ? "on" : "off (--no-embed-catalog)"}` +); for (const [name, skill] of skillMap) { const fileCount = skill.manifest.files.length; console.error(` - ${name}: ${fileCount} file(s) in manifest`); @@ -75,6 +87,10 @@ server.registerResource( description: "XML representation of available skills for injecting into system prompts", mimeType: "application/xml", + annotations: { + audience: ["user", "assistant"], + priority: 0.3, + }, }, async (uri) => ({ contents: [ @@ -106,6 +122,11 @@ function registerSkillResources( { description: skill.description, mimeType: "text/markdown", + annotations: { + audience: ["user", "assistant"], + priority: 1.0, + lastModified: skill.lastModified, + }, }, async (uri) => { try { @@ -133,6 +154,11 @@ function registerSkillResources( { description: `File manifest for skill '${name}' with content hashes`, mimeType: "application/json", + annotations: { + audience: ["user", "assistant"], + priority: 0.5, + lastModified: skill.lastModified, + }, }, async (uri) => ({ contents: [ @@ -185,6 +211,10 @@ server.registerResource( { description: "Fetch a supporting file from a skill directory", mimeType: "text/plain", + annotations: { + audience: ["user", "assistant"], + priority: 0.2, + }, }, async (uri, variables) => { const skillName = Array.isArray(variables.skillName) @@ -250,10 +280,16 @@ server.registerResource( // Tool: load_skill — allows models to discover and load skills on demand. // Description dynamically lists available skill names, mirroring // skillsdotnet's SkillCatalog pattern but implemented server-side. -const loadSkillToolDescription = () => - `Load the full SKILL.md content for a named skill. ` + - `Use this when you need detailed instructions for performing a specific task. ` + - `Available skills: ${getSkillListStr()}`; +// When --no-embed-catalog is set, the description only lists skill names +// (useful when the client already injects skill context from resources). +const loadSkillToolDescription = () => { + const base = + `Load the full SKILL.md content for a named skill. ` + + `Use this when you need detailed instructions for performing a specific task.`; + return embedCatalog + ? base + `\n\n` + generateSkillsXML(skillMap) + : base + ` Available skills: ${getSkillListStr()}`; +}; const loadSkillTool: RegisteredTool = server.registerTool( "load_skill", diff --git a/examples/skills-as-resources/typescript/src/skill-discovery.ts b/examples/skills-as-resources/typescript/src/skill-discovery.ts index bdebcc9..97ce7ce 100644 --- a/examples/skills-as-resources/typescript/src/skill-discovery.ts +++ b/examples/skills-as-resources/typescript/src/skill-discovery.ts @@ -297,6 +297,7 @@ export function discoverSkills(skillsDir: string): Map { documents, manifest, manifestJson, + lastModified: stat.mtime.toISOString(), }); } catch (error) { console.error(`Failed to parse skill at ${skillDir}:`, error); diff --git a/examples/skills-as-resources/typescript/src/types.ts b/examples/skills-as-resources/typescript/src/types.ts index 2a6d302..32395d9 100644 --- a/examples/skills-as-resources/typescript/src/types.ts +++ b/examples/skills-as-resources/typescript/src/types.ts @@ -63,4 +63,5 @@ export interface SkillMetadata { documents: SkillDocument[]; // Supplementary files found in subdirectories manifest: SkillManifest; // Pre-computed file manifest manifestJson: string; // Pre-serialized manifest JSON (avoids I/O on request) + lastModified: string; // ISO 8601 timestamp from SKILL.md file mtime } From b186b9d0cb51a90e9b7ff0502e791d119f0f751e Mon Sep 17 00:00:00 2001 From: olaservo Date: Sun, 22 Feb 2026 06:53:03 -0700 Subject: [PATCH 12/21] Add tool annotations to load_skill and bump SDK to 1.27.0 Mark load_skill as readOnly, non-destructive, idempotent, and closed-world since it only reads local skill files with no side effects. Bump @modelcontextprotocol/sdk from ^1.25.0 to ^1.27.0. Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/typescript/package-lock.json | 8 ++++---- examples/skills-as-resources/typescript/package.json | 2 +- examples/skills-as-resources/typescript/src/index.ts | 6 ++++++ 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json index 3a6d87e..7fe64a8 100644 --- a/examples/skills-as-resources/typescript/package-lock.json +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.0", + "@modelcontextprotocol/sdk": "^1.27.0", "chokidar": "^4.0.3", "yaml": "^2.7.0", "zod": "^3.23.0" @@ -478,9 +478,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.27.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.0.tgz", + "integrity": "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index a6e5d15..a0f1ba7 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -10,7 +10,7 @@ "dev": "tsx src/index.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.25.0", + "@modelcontextprotocol/sdk": "^1.27.0", "chokidar": "^4.0.3", "yaml": "^2.7.0", "zod": "^3.23.0" diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 1bfd6f3..89b54f2 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -295,6 +295,12 @@ const loadSkillTool: RegisteredTool = server.registerTool( "load_skill", { description: loadSkillToolDescription(), + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, inputSchema: { skillName: z.string().describe("The name of the skill to load"), }, From 1ce3df9c94ce7b09397ac63ecf69a21b47439c74 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 25 Feb 2026 06:00:08 -0700 Subject: [PATCH 13/21] Bump @modelcontextprotocol/sdk to 1.27.1 Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/typescript/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index a0f1ba7..7256150 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -10,7 +10,7 @@ "dev": "tsx src/index.ts" }, "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.0", + "@modelcontextprotocol/sdk": "^1.27.1", "chokidar": "^4.0.3", "yaml": "^2.7.0", "zod": "^3.23.0" From 048ae19e7c3fcb14f6f22b3143a5b5133b873833 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 25 Feb 2026 07:02:55 -0700 Subject: [PATCH 14/21] Enhance README with detailed descriptions for supporting files and dynamic skill management --- examples/skills-as-resources/README.md | 27 +++++++------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 3516d2b..f9058c8 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -12,7 +12,7 @@ An MCP server scans a directory for SKILL.md files and exposes them as resources | :--- | :--- | :--- | :--- | | Resource | `skill://{name}/SKILL.md` | `text/markdown` | Full SKILL.md content (listed) | | Resource | `skill://{name}/_manifest` | `application/json` | File inventory with SHA256 hashes (listed) | -| Resource | `skill://{name}/{+path}` | varies | Supporting file (template, not listed) | +| Resource | `skill://{name}/{+path}` | varies | Supporting file (template, not listed); text files return UTF-8 content, binary files return base64-encoded blobs | | Resource | `skill://prompt-xml` | `application/xml` | XML for system prompt injection (optional) | | Tool | `load_skill` | — | Model-controlled skill loading | @@ -30,15 +30,16 @@ This is a **hybrid** approach: resources provide **application-controlled** acce └─────────────┘ ``` -1. **Startup**: Server scans the configured skills directory for `*/SKILL.md` files and supplementary documents; computes SHA256 hashes and builds file manifests +1. **Startup**: Server scans the configured skills directory for `*/SKILL.md` (or `skill.md`) files and supplementary documents; computes SHA256 hashes and builds file manifests 2. **Discovery**: Parses YAML frontmatter to extract `name` and `description` -3. **Registration**: Registers static resources (`SKILL.md` + `_manifest`) for each skill, a `ResourceTemplate` for supporting files, and a `load_skill` tool +3. **Registration**: Registers static resources (`SKILL.md` + `_manifest`) for each skill, a `ResourceTemplate` for supporting files with auto-completion hints (skill name and file path), and a `load_skill` tool 4. **Progressive disclosure** (two paths): - **Application-controlled** (via resources): `resources/list` → scan for `skill://*/SKILL.md` → read `skill://{name}/SKILL.md` on demand → read `skill://{name}/_manifest` for file inventory → read `skill://{name}/{path}` for supporting files - **Model-controlled** (via tool): `tools/list` → discover `load_skill` with available skill names in description → call `load_skill("code-review")` to get full content 5. **System prompt injection**: `skill://prompt-xml` provides XML that hosts can inject into system prompts 6. **Capability declaration**: Server declares `resources.listChanged` and `resources.subscribe` capabilities 7. **Resource subscriptions**: Clients can call `resources/subscribe` on any `skill://` URI to receive `notifications/resources/updated` when the underlying file(s) change on disk. Watchers are created on-demand via [chokidar](https://github.com/paulmillr/chokidar) and cleaned up on unsubscribe. +8. **Dynamic skill management (hot-reload)**: The server watches the skills directory for structural changes — new or removed skill subdirectories and `SKILL.md` files appearing or disappearing. When changes are detected (debounced at 500ms), the server re-scans the directory: new skills get their `SKILL.md` and `_manifest` resources registered, removed skills get their resources unregistered and subscriptions cleaned up, and the `load_skill` tool description is updated with the current skill list. A `notifications/resources/list_changed` notification is sent to connected clients so they can refresh their resource lists. ## Implementations @@ -97,6 +98,7 @@ Two shared sample skills are included in [`examples/sample-skills/`](../../sampl ## Key Design Decisions - **Hybrid approach (resources + tool)**: Resources provide application-controlled access for hosts that want to manage context. The `load_skill` tool provides model-controlled access for progressive disclosure. Experimental findings show models reliably use tools but tend to ignore resources (see [`docs/experimental-findings.md`](../../docs/experimental-findings.md)), making the hybrid approach more practical than resources alone. +- **`load_skill` per server — duplication trade-off**: Including `load_skill` in each skill server means clients with native `skill://` resource loading support see duplicate context — the client already parsed frontmatter from resources AND the tool description lists available skills. However, for clients *without* native support for loading skills as resources, `load_skill` is the only way to provide skills via MCP without manually selecting resources. The `--no-embed-catalog` flag mitigates description-level duplication. Long-term, `load_skill` may be better served as a well-known client-side tool (like [SkillsDotNet](https://github.com/pederhp/skillsdotnet)'s `SkillCatalog`) or a generic `load_resource` tool, avoiding per-server duplication entirely. See [PR #16 discussion](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/16#discussion_r2829745543) for context. - **URI scheme aligned with skillsdotnet**: The `skill://{name}/SKILL.md` and `skill://{name}/_manifest` URI conventions match the [SkillsDotNet](https://github.com/pederhp/skillsdotnet) C# implementation. This enables cross-implementation interoperability — a client-side `SkillCatalog` can discover skills from either implementation by scanning `resources/list` for `skill://*/SKILL.md` URIs. - **`_manifest` with SHA256 hashes**: Pre-computed at startup with file sizes and content hashes. Enables download/sync workflows and cache invalidation without re-reading files on each request. - **Listed resources for skills, template for supporting files**: Each skill's `SKILL.md` and `_manifest` are concrete resources visible in `resources/list`. Supporting files are accessed via a `ResourceTemplate` (`skill://{name}/{+path}`) and are discoverable through the `_manifest` — keeping `resources/list` clean. @@ -122,19 +124,6 @@ All resources default to both audiences. The [Agent Skills specification](https: - **0.3** — Convenience resources (prompt-xml). Optional. - **0.2** — Supporting files (template). Load on demand only. -## How This Differs from Skills as Tools - -| Aspect | Skills as Tools | Skills as Resources (this example) | -| :--- | :--- | :--- | -| Control model | Model-controlled only | Hybrid: application-controlled (resources) + model-controlled (`load_skill` tool) | -| MCP Primitive | Tools | Resources + Tools | -| Discovery | Tool description + `list_skills` call | `resources/list` scan for `skill://*/SKILL.md` + `load_skill` tool description | -| Loading | `read_skill(name)` tool call | `resources/read` on `skill://{name}/SKILL.md` or `load_skill(name)` tool call | -| File inventory | Not demonstrated | `skill://{name}/_manifest` with SHA256 hashes | -| System prompt | Via tool description embedding | Via `skill://prompt-xml` resource or `load_skill` tool description | -| Input validation | Zod schema on tool parameters | URI template matching (resources) + Zod schema (`load_skill` tool) | -| Supporting files | Not demonstrated | `ResourceTemplate` for files, `_manifest` for discovery | - ## Answers to Open Question #12 > "Why not just use resources?" @@ -156,7 +145,7 @@ This implementation shows that resources **do work** for skill delivery, and tha | **5. Server Instructions** | Uses server instructions to point to resources instead of exposing resources directly | | **6. Convention** | This example could become part of a documented convention pattern | -## Convergence with SkillsDotNet +## Borrowing From SkillsDotNet The URI scheme in this implementation is aligned with [SkillsDotNet](https://github.com/pederhp/skillsdotnet), a C# implementation of the same pattern. Both implementations use: @@ -164,8 +153,6 @@ The URI scheme in this implementation is aligned with [SkillsDotNet](https://git - `skill://{name}/_manifest` — listed resource for file inventory (with SHA256 hashes) - `skill://{name}/{+path}` — resource template for supporting files -This convergence enables a future client-side `SkillCatalog` to discover and load skills from either implementation without knowing which language the server is written in. The key client-side pattern (from skillsdotnet) is: - 1. Scan `resources/list` for URIs matching `skill://*/SKILL.md` 2. Read each SKILL.md to extract frontmatter (name + description) 3. Build compact context summaries for the system prompt (~50-100 tokens per skill) @@ -173,7 +160,7 @@ This convergence enables a future client-side `SkillCatalog` to discover and loa ## Inspirations and Attribution -This reference implementation is original code inspired by patterns from: +This reference implementation derives from: - **[skills-over-mcp](https://github.com/keithagroves/skills-over-mcp)** by [Keith Groves](https://github.com/keithagroves) — Resource-based skill exposure, `skill://` URI scheme, JSON index, XML prompt injection, document templates - **[skilljack-mcp](https://github.com/olaservo/skilljack-mcp)** by [Ola Hungerford](https://github.com/olaservo) — Resource template patterns, subscription architecture, path security From ee33c776bdd9a1d27696504496c68e6911798b26 Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 25 Feb 2026 07:20:10 -0700 Subject: [PATCH 15/21] Add comparison section to README highlighting differences with FastMCP implementation --- examples/skills-as-resources/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index f9058c8..e203e89 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -158,6 +158,26 @@ The URI scheme in this implementation is aligned with [SkillsDotNet](https://git 3. Build compact context summaries for the system prompt (~50-100 tokens per skill) 4. Expose a `load_skill` tool/function for on-demand full content loading +## Comparison to FastMCP Implementation + +[FastMCP](https://github.com/jlowin/fastmcp) includes support for the `skill://` URI scheme through its [Skills Provider](https://gofastmcp.com/servers/providers/skills). Both FastMCP and this implementation converge on: + +- Same three-tier resource model: listed `SKILL.md` + listed `_manifest` + template for supporting files +- Same manifest format: `{ skill, files: [{ path, size, hash }] }` with `sha256:` hashes +- Same discovery model: scan for `SKILL.md` frontmatter, enumerate supporting files, pre-compute hashes +- Same security model: path traversal prevention, symlink resolution, MIME type detection + +Key architectural differences: + +| Aspect | This implementation | FastMCP | +| :--- | :--- | :--- | +| Reactivity | Push-based (file watching + subscriptions) | Poll-based (optional re-scan on request) | +| Access model | Hybrid: Resources + `load_skill` server tool | Resources + client utilities | +| Architecture | Flat, single-server | Layered provider hierarchy with vendor presets (Claude, Cursor, Codex, Gemini, etc.) | +| Client utilities | Server-only | Includes `list_skills`, `download_skill`, `sync_skills` for skill distribution | +| System prompt injection | Optional `skill://prompt-xml` resource + tool description embedding | Not implemented (relies on client) | +| Resource annotations | `audience`, `priority`, `lastModified` on all resources | Not set (uses internal metadata) | + ## Inspirations and Attribution This reference implementation derives from: From 9060a517cbd8097cb0f13bb6be6f497015eab18b Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 25 Feb 2026 07:40:46 -0700 Subject: [PATCH 16/21] Add smoke test script for Skills as Resources MCP server --- .../typescript/package.json | 3 +- .../typescript/smoke-test.js | 353 ++++++++++++++++++ 2 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 examples/skills-as-resources/typescript/smoke-test.js diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index 7256150..056430b 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -7,7 +7,8 @@ "scripts": { "build": "tsc", "start": "node dist/index.js", - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "test:smoke": "node smoke-test.js" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", diff --git a/examples/skills-as-resources/typescript/smoke-test.js b/examples/skills-as-resources/typescript/smoke-test.js new file mode 100644 index 0000000..c3a1a41 --- /dev/null +++ b/examples/skills-as-resources/typescript/smoke-test.js @@ -0,0 +1,353 @@ +#!/usr/bin/env node +/** + * Smoke test for the Skills as Resources MCP server. + * + * Uses the MCP Client SDK to spawn the server as a child process, + * perform the initialization handshake, and exercise all capabilities. + * + * Usage: node smoke-test.mjs [skillsDir] + * Default skillsDir: ../sample-skills (relative to this script) + * + * Exit code 0 = all tests pass, 1 = one or more failures. + */ + +import { fileURLToPath } from "node:url"; +import * as path from "node:path"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; + +// ── Configuration ────────────────────────────────────────────────────── + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const skillsDir = + process.argv[2] || path.resolve(__dirname, "../../sample-skills"); +const serverScript = path.resolve(__dirname, "dist/index.js"); + +// ── Test harness ─────────────────────────────────────────────────────── + +const results = []; + +function assert(condition, message) { + if (!condition) throw new Error(`Assertion failed: ${message}`); +} + +async function runTest(name, fn) { + try { + await fn(); + results.push({ name, passed: true }); + console.log(` PASS ${name}`); + } catch (err) { + results.push({ name, passed: false, error: err.message }); + console.log(` FAIL ${name}`); + console.log(` ${err.message}`); + } +} + +// ── Main ─────────────────────────────────────────────────────────────── + +async function main() { + console.log("Skills as Resources MCP Server — Smoke Test"); + console.log(`Server: ${serverScript}`); + console.log(`Skills: ${skillsDir}`); + console.log(""); + + // Global timeout to prevent hangs + const TIMEOUT_MS = 30_000; + const timer = setTimeout(() => { + console.error(`Smoke test timed out after ${TIMEOUT_MS / 1000}s`); + process.exit(1); + }, TIMEOUT_MS); + timer.unref(); + + // Spawn the server and connect + const transport = new StdioClientTransport({ + command: "node", + args: [serverScript, skillsDir], + stderr: "pipe", + }); + + const client = new Client( + { name: "smoke-test-client", version: "1.0.0" }, + { capabilities: { resources: { subscribe: true } } }, + ); + + // Collect stderr for diagnostics on failure + let stderrOutput = ""; + if (transport.stderr) { + transport.stderr.on("data", (chunk) => { + stderrOutput += chunk.toString(); + }); + } + + await client.connect(transport); + + try { + // Test 1: initialization / capabilities + await runTest("Server initialization and capabilities", async () => { + const caps = client.getServerCapabilities(); + const ver = client.getServerVersion(); + assert( + ver.name === "skills-as-resources-example", + `server name: ${ver.name}`, + ); + assert(ver.version === "0.2.0", `server version: ${ver.version}`); + assert(caps.resources, "resources capability missing"); + assert(caps.resources.listChanged === true, "listChanged not true"); + assert(caps.resources.subscribe === true, "subscribe not true"); + assert(caps.tools, "tools capability missing"); + }); + + // Test 2: list resources + await runTest("List resources discovers both skills", async () => { + const { resources } = await client.listResources(); + const uris = resources.map((r) => r.uri); + assert(uris.includes("skill://prompt-xml"), "missing skill://prompt-xml"); + assert( + uris.includes("skill://code-review/SKILL.md"), + "missing code-review SKILL.md", + ); + assert( + uris.includes("skill://code-review/_manifest"), + "missing code-review _manifest", + ); + assert( + uris.includes("skill://git-commit-review/SKILL.md"), + "missing git-commit-review SKILL.md", + ); + assert( + uris.includes("skill://git-commit-review/_manifest"), + "missing git-commit-review _manifest", + ); + assert( + resources.length >= 5, + `expected >= 5 resources, got ${resources.length}`, + ); + + const cr = resources.find( + (r) => r.uri === "skill://code-review/SKILL.md", + ); + assert( + cr.mimeType === "text/markdown", + `code-review mimeType: ${cr.mimeType}`, + ); + assert( + cr.description?.toLowerCase().includes("code review"), + "code-review description missing 'code review'", + ); + assert( + cr.annotations?.audience?.includes("assistant"), + "code-review audience missing 'assistant'", + ); + assert( + cr.annotations?.priority === 1.0, + `code-review priority: ${cr.annotations?.priority}`, + ); + + const px = resources.find((r) => r.uri === "skill://prompt-xml"); + assert( + px.mimeType === "application/xml", + `prompt-xml mimeType: ${px.mimeType}`, + ); + assert( + px.annotations?.priority === 0.3, + `prompt-xml priority: ${px.annotations?.priority}`, + ); + }); + + // Test 3: list resource templates + await runTest( + "List resource templates includes supporting-file template", + async () => { + const { resourceTemplates } = await client.listResourceTemplates(); + assert(resourceTemplates.length >= 1, "no resource templates"); + const tmpl = resourceTemplates.find( + (t) => t.uriTemplate === "skill://{skillName}/{+path}", + ); + assert(tmpl, "missing skill://{skillName}/{+path} template"); + }, + ); + + // Test 4: read SKILL.md content + await runTest("Read SKILL.md content for both skills", async () => { + const cr = await client.readResource({ + uri: "skill://code-review/SKILL.md", + }); + assert( + cr.contents.length === 1, + `expected 1 content, got ${cr.contents.length}`, + ); + assert( + cr.contents[0].uri === "skill://code-review/SKILL.md", + "uri mismatch", + ); + assert(cr.contents[0].text.includes("---"), "missing frontmatter"); + assert( + cr.contents[0].text.includes("name: code-review"), + "missing name field", + ); + assert( + cr.contents[0].text.includes("# Code Review"), + "missing heading", + ); + + const gc = await client.readResource({ + uri: "skill://git-commit-review/SKILL.md", + }); + assert( + gc.contents[0].text.includes("name: git-commit-review"), + "missing git-commit name", + ); + assert( + gc.contents[0].text.includes("# Git Commit Review"), + "missing git-commit heading", + ); + }); + + // Test 5: read _manifest + await runTest("Read _manifest for code-review", async () => { + const res = await client.readResource({ + uri: "skill://code-review/_manifest", + }); + const manifest = JSON.parse(res.contents[0].text); + assert( + manifest.skill === "code-review", + `manifest skill: ${manifest.skill}`, + ); + assert(Array.isArray(manifest.files), "manifest.files not an array"); + + const skillMd = manifest.files.find((f) => f.path === "SKILL.md"); + assert(skillMd, "SKILL.md not in manifest"); + assert( + typeof skillMd.size === "number" && skillMd.size > 0, + "SKILL.md size invalid", + ); + assert( + /^sha256:[a-f0-9]{64}$/.test(skillMd.hash), + `SKILL.md hash format: ${skillMd.hash}`, + ); + + const ref = manifest.files.find( + (f) => f.path === "references/REFERENCE.md", + ); + assert(ref, "references/REFERENCE.md not in manifest"); + assert( + /^sha256:[a-f0-9]{64}$/.test(ref.hash), + `REFERENCE.md hash format: ${ref.hash}`, + ); + }); + + // Test 6: read supporting file via template + await runTest("Read supporting file via resource template", async () => { + const res = await client.readResource({ + uri: "skill://code-review/references/REFERENCE.md", + }); + assert( + res.contents[0].uri === "skill://code-review/references/REFERENCE.md", + "uri mismatch", + ); + assert( + res.contents[0].text.includes("Code Review Checklist"), + "missing checklist title", + ); + }); + + // Test 7: read prompt-xml + await runTest("Read prompt-xml resource", async () => { + const res = await client.readResource({ uri: "skill://prompt-xml" }); + const xml = res.contents[0].text; + assert(xml.includes(""), "missing "); + assert( + xml.includes("code-review"), + "missing code-review in XML", + ); + assert( + xml.includes("git-commit-review"), + "missing git-commit-review in XML", + ); + assert( + xml.includes(""), + "missing ", + ); + }); + + // Test 8: list tools and call load_skill (valid + invalid) + await runTest( + "load_skill tool: list, call valid, call invalid", + async () => { + // List + const { tools } = await client.listTools(); + assert(tools.length === 1, `expected 1 tool, got ${tools.length}`); + assert(tools[0].name === "load_skill", `tool name: ${tools[0].name}`); + assert( + tools[0].inputSchema?.properties?.skillName, + "missing skillName in schema", + ); + assert( + tools[0].annotations?.readOnlyHint === true, + "readOnlyHint not true", + ); + assert( + tools[0].annotations?.idempotentHint === true, + "idempotentHint not true", + ); + assert( + tools[0].description?.includes("code-review"), + "description missing code-review", + ); + assert( + tools[0].description?.includes("git-commit-review"), + "description missing git-commit-review", + ); + + // Valid call + const ok = await client.callTool({ + name: "load_skill", + arguments: { skillName: "code-review" }, + }); + assert(ok.content?.length === 1, "expected 1 content item"); + assert(ok.content[0].type === "text", "content not text"); + assert( + ok.content[0].text.includes("# Code Review"), + "missing heading in tool result", + ); + assert(!ok.isError, "unexpected isError on valid call"); + + // Invalid call + const err = await client.callTool({ + name: "load_skill", + arguments: { skillName: "nonexistent-skill" }, + }); + assert(err.isError === true, "expected isError on invalid call"); + assert( + err.content[0].text.toLowerCase().includes("not found"), + "missing 'not found' in error", + ); + assert( + err.content[0].text.includes("code-review"), + "missing available skill in error", + ); + }, + ); + } finally { + clearTimeout(timer); + await client.close(); + } + + // Summary + console.log(""); + const passed = results.filter((r) => r.passed).length; + const failed = results.filter((r) => !r.passed).length; + console.log(`Results: ${passed} passed, ${failed} failed, ${results.length} total`); + + if (failed > 0) { + console.log(""); + console.log("Server stderr output:"); + console.log(stderrOutput || "(empty)"); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Fatal error:", err); + process.exit(1); +}); From cd92e59699b52735489703520173bcda6009ebef Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 25 Feb 2026 08:07:39 -0700 Subject: [PATCH 17/21] Bump sdk in package lock --- examples/skills-as-resources/typescript/package-lock.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json index 7fe64a8..1c1c21a 100644 --- a/examples/skills-as-resources/typescript/package-lock.json +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -9,7 +9,7 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/sdk": "^1.27.0", + "@modelcontextprotocol/sdk": "^1.27.1", "chokidar": "^4.0.3", "yaml": "^2.7.0", "zod": "^3.23.0" @@ -478,9 +478,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.0.tgz", - "integrity": "sha512-qOdO524oPMkUsOJTrsH9vz/HN3B5pKyW+9zIW51A9kDMVe7ON70drz1ouoyoyOcfzc+oxhkQ6jWmbyKnlWmYqA==", + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", "license": "MIT", "dependencies": { "@hono/node-server": "^1.19.9", From 726b92a9109c46fe228bbf17995d415e07d36207 Mon Sep 17 00:00:00 2001 From: olaservo Date: Thu, 26 Feb 2026 21:24:46 -0700 Subject: [PATCH 18/21] Remove load_skill tool; adopt resources-only server architecture Per PR #16 review feedback from pja-ant and consensus with reviewers: servers should expose skills purely as MCP resources, and clients should provide their own read_resource tool for model-controlled access. - Remove load_skill tool, --no-embed-catalog flag, and zod dependency - Add Client Expectations section to README with recommended client behavior and SDK integration sketch - Rewrite design decisions to reflect resources-only approach - Update smoke tests (7 passing, removed tool test) Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/README.md | 76 +++++++++------ .../typescript/package-lock.json | 3 +- .../typescript/package.json | 3 +- .../typescript/smoke-test.js | 59 ------------ .../typescript/src/index.ts | 94 ++----------------- 5 files changed, 59 insertions(+), 176 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index e203e89..2e22759 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -4,9 +4,9 @@ ## Pattern Overview -This example demonstrates the **Resources approach** from [`docs/approaches.md`](../../docs/approaches.md): exposing agent skills via MCP resources using the `skill://` URI scheme, combined with a `load_skill` tool for model-controlled progressive disclosure. +This example demonstrates the **Resources approach** from [`docs/approaches.md`](../../docs/approaches.md): exposing agent skills via MCP resources using the `skill://` URI scheme. -An MCP server scans a directory for SKILL.md files and exposes them as resources and tools: +An MCP server scans a directory for SKILL.md files and exposes them as resources: | Type | Name / URI | MIME Type | Purpose | | :--- | :--- | :--- | :--- | @@ -14,11 +14,10 @@ An MCP server scans a directory for SKILL.md files and exposes them as resources | Resource | `skill://{name}/_manifest` | `application/json` | File inventory with SHA256 hashes (listed) | | Resource | `skill://{name}/{+path}` | varies | Supporting file (template, not listed); text files return UTF-8 content, binary files return base64-encoded blobs | | Resource | `skill://prompt-xml` | `application/xml` | XML for system prompt injection (optional) | -| Tool | `load_skill` | — | Model-controlled skill loading | -The URI scheme is aligned with the [SkillsDotNet](https://github.com/bradwilson/skillsdotnet) conventions, enabling interoperability between TypeScript and C# implementations. Clients can discover skills by scanning `resources/list` for URIs matching `skill://*/SKILL.md`. +The URI scheme is aligned with the [SkillsDotNet](https://github.com/pederhp/skillsdotnet) conventions, enabling interoperability between TypeScript and C# implementations. Clients can discover skills by scanning `resources/list` for URIs matching `skill://*/SKILL.md`. -This is a **hybrid** approach: resources provide **application-controlled** access (the host/client decides when to read), while the `load_skill` tool provides **model-controlled** access (the LLM decides when to invoke). See [Open Question #9](../../docs/open-questions.md) for the control model discussion. +This is a **resources-only** server. Clients are expected to provide their own `read_resource` tool so the model can load skill content on demand. See [Client Expectations](#client-expectations) and [Open Question #9](../../docs/open-questions.md) for the control model discussion. ## How It Works @@ -32,14 +31,12 @@ This is a **hybrid** approach: resources provide **application-controlled** acce 1. **Startup**: Server scans the configured skills directory for `*/SKILL.md` (or `skill.md`) files and supplementary documents; computes SHA256 hashes and builds file manifests 2. **Discovery**: Parses YAML frontmatter to extract `name` and `description` -3. **Registration**: Registers static resources (`SKILL.md` + `_manifest`) for each skill, a `ResourceTemplate` for supporting files with auto-completion hints (skill name and file path), and a `load_skill` tool -4. **Progressive disclosure** (two paths): - - **Application-controlled** (via resources): `resources/list` → scan for `skill://*/SKILL.md` → read `skill://{name}/SKILL.md` on demand → read `skill://{name}/_manifest` for file inventory → read `skill://{name}/{path}` for supporting files - - **Model-controlled** (via tool): `tools/list` → discover `load_skill` with available skill names in description → call `load_skill("code-review")` to get full content +3. **Registration**: Registers static resources (`SKILL.md` + `_manifest`) for each skill, and a `ResourceTemplate` for supporting files with auto-completion hints (skill name and file path) +4. **Progressive disclosure** (via resources): `resources/list` → scan for `skill://*/SKILL.md` → read `skill://{name}/SKILL.md` on demand → read `skill://{name}/_manifest` for file inventory → read `skill://{name}/{path}` for supporting files 5. **System prompt injection**: `skill://prompt-xml` provides XML that hosts can inject into system prompts 6. **Capability declaration**: Server declares `resources.listChanged` and `resources.subscribe` capabilities 7. **Resource subscriptions**: Clients can call `resources/subscribe` on any `skill://` URI to receive `notifications/resources/updated` when the underlying file(s) change on disk. Watchers are created on-demand via [chokidar](https://github.com/paulmillr/chokidar) and cleaned up on unsubscribe. -8. **Dynamic skill management (hot-reload)**: The server watches the skills directory for structural changes — new or removed skill subdirectories and `SKILL.md` files appearing or disappearing. When changes are detected (debounced at 500ms), the server re-scans the directory: new skills get their `SKILL.md` and `_manifest` resources registered, removed skills get their resources unregistered and subscriptions cleaned up, and the `load_skill` tool description is updated with the current skill list. A `notifications/resources/list_changed` notification is sent to connected clients so they can refresh their resource lists. +8. **Dynamic skill management (hot-reload)**: The server watches the skills directory for structural changes — new or removed skill subdirectories and `SKILL.md` files appearing or disappearing. When changes are detected (debounced at 500ms), the server re-scans the directory: new skills get their `SKILL.md` and `_manifest` resources registered, removed skills get their resources unregistered and subscriptions cleaned up. A `notifications/resources/list_changed` notification is sent to connected clients so they can refresh their resource lists. ## Implementations @@ -68,19 +65,13 @@ npm run dev -- ../../sample-skills | Flag | Default | Description | | :--- | :--- | :--- | | `[skillsDir]` | `examples/sample-skills/` | Path to the skills directory | -| `--no-embed-catalog` | off (catalog embedded) | Disable embedding `` XML in the `load_skill` tool description. Use when the client already injects skill context from resources, to avoid duplicate injection. | - -**Example** — disable catalog embedding for a client with native `skill://` support: -```bash -node dist/index.js ../../sample-skills --no-embed-catalog -``` ## Security Features The implementation includes: - **Path traversal protection** — Resolved paths are checked against the skills directory boundary using `realpathSync`. Symlink escapes are detected. -- **Skill name validation** — Resources and the `load_skill` tool look up names by key in the discovered skills map. User input is never used to construct file paths. +- **Skill name validation** — Resources look up names by key in the discovered skills map. User input is never used to construct file paths. - **File path validation** — Paths containing `..` are rejected. All paths are verified to be within the skills directory. - **File size limits** — Files larger than 1MB are skipped during discovery and rejected on read. - **Safe YAML parsing** — Uses the `yaml` package which is safe by default. @@ -97,8 +88,7 @@ Two shared sample skills are included in [`examples/sample-skills/`](../../sampl ## Key Design Decisions -- **Hybrid approach (resources + tool)**: Resources provide application-controlled access for hosts that want to manage context. The `load_skill` tool provides model-controlled access for progressive disclosure. Experimental findings show models reliably use tools but tend to ignore resources (see [`docs/experimental-findings.md`](../../docs/experimental-findings.md)), making the hybrid approach more practical than resources alone. -- **`load_skill` per server — duplication trade-off**: Including `load_skill` in each skill server means clients with native `skill://` resource loading support see duplicate context — the client already parsed frontmatter from resources AND the tool description lists available skills. However, for clients *without* native support for loading skills as resources, `load_skill` is the only way to provide skills via MCP without manually selecting resources. The `--no-embed-catalog` flag mitigates description-level duplication. Long-term, `load_skill` may be better served as a well-known client-side tool (like [SkillsDotNet](https://github.com/pederhp/skillsdotnet)'s `SkillCatalog`) or a generic `load_resource` tool, avoiding per-server duplication entirely. See [PR #16 discussion](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/16#discussion_r2829745543) for context. +- **Resources-only server**: The server exposes skills purely as MCP resources. Clients are responsible for providing model-controlled access (e.g., a `read_resource` tool). This avoids per-server duplication of tool logic and keeps the compatibility matrix clean — servers don't need to negotiate with clients about who provides skill-loading tools. See [PR #16 discussion](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/16#discussion_r2853911362) for context. - **URI scheme aligned with skillsdotnet**: The `skill://{name}/SKILL.md` and `skill://{name}/_manifest` URI conventions match the [SkillsDotNet](https://github.com/pederhp/skillsdotnet) C# implementation. This enables cross-implementation interoperability — a client-side `SkillCatalog` can discover skills from either implementation by scanning `resources/list` for `skill://*/SKILL.md` URIs. - **`_manifest` with SHA256 hashes**: Pre-computed at startup with file sizes and content hashes. Enables download/sync workflows and cache invalidation without re-reading files on each request. - **Listed resources for skills, template for supporting files**: Each skill's `SKILL.md` and `_manifest` are concrete resources visible in `resources/list`. Supporting files are accessed via a `ResourceTemplate` (`skill://{name}/{+path}`) and are discoverable through the `_manifest` — keeping `resources/list` clean. @@ -124,24 +114,52 @@ All resources default to both audiences. The [Agent Skills specification](https: - **0.3** — Convenience resources (prompt-xml). Optional. - **0.2** — Supporting files (template). Load on demand only. +## Client Expectations + +This server exposes skills as resources only — it does **not** include server-side tools for skill loading. Clients are expected to provide model-controlled access to resources. This is a small lift compared to features like elicitation or sampling, and avoids the duplication and compatibility issues of per-server tool workarounds. + +### Recommended client behavior + +1. **Enumerate skills**: Call `resources/list` and filter for URIs matching `skill://*/SKILL.md`. Each matching resource's `description` field contains the skill summary from frontmatter. +2. **Build context summaries**: Load skill metadata (name + description) into the system prompt so the model knows which skills are available (~50-100 tokens per skill). +3. **Provide a `read_resource` tool**: Expose a client-side tool that maps internally to the MCP client SDK's `readResource()` call. This lets the model load full skill content on demand. Example system prompt note: *"Use the `read_resource` tool to load MCP-based skills by their `skill://` URI."* +4. **Subscribe to changes** (optional): Call `resources/subscribe` on skill URIs to receive `notifications/resources/updated` when files change on disk. Re-enumerate on `notifications/resources/list_changed`. +5. **Use `skill://prompt-xml`** (optional): Read the `skill://prompt-xml` resource for pre-built `` XML suitable for system prompt injection, as an alternative to building your own summaries from step 1. + +### SDK integration sketch + +A client SDK might expose a helper like: + +```typescript +// Pseudocode — not a real SDK method (yet) +const skills = await client.listResources() + .then(({ resources }) => resources + .filter(r => r.uri.match(/^skill:\/\/[^/]+\/SKILL\.md$/)) + .map(r => ({ uri: r.uri, name: r.name, description: r.description })) + ); +``` + +See [PR #16 discussion](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/16#discussion_r2859600003) for the full rationale on why client-side `read_resource` is preferred over server-side `load_skill` tools. + ## Answers to Open Question #12 > "Why not just use resources?" -This implementation shows that resources **do work** for skill delivery, and that combining them with a `load_skill` tool creates a more practical system. Key findings for evaluation: +This implementation shows that resources **do work** for skill delivery. Key findings for evaluation: -- **Discovery**: Skills appear in `resources/list` as `skill://*/SKILL.md`, making them immediately visible to any MCP-aware client. The `load_skill` tool description also lists available skills for model discovery. -- **Progressive disclosure**: The URI hierarchy (`SKILL.md` → `_manifest` → `{path}`) provides layered loading. The `load_skill` tool provides an alternative on-demand loading path. +- **Discovery**: Skills appear in `resources/list` as `skill://*/SKILL.md`, making them immediately visible to any MCP-aware client +- **Progressive disclosure**: The URI hierarchy (`SKILL.md` → `_manifest` → `{path}`) provides layered loading. Clients can choose how deeply to load based on context budget. - **System prompt injection**: `skill://prompt-xml` provides a clean mechanism for hosts to inject skill awareness -- **Control model**: The hybrid approach gives both the host (via resources) and the model (via `load_skill` tool) agency over when skill content gets loaded +- **Control model**: Resources are application-controlled. Model-controlled access comes from the client providing a `read_resource` tool — this is a small lift for clients (see [Client Expectations](#client-expectations)) and avoids the duplication problems of server-side tool workarounds. - **Interoperability**: The `skill://` URI convention is shared with skillsdotnet, enabling cross-implementation discovery +- **Caching**: Using resources directly lets clients take advantage of MCP resource semantics (e.g., caching, subscriptions) rather than re-implementing them via tool wrappers ## Relationship to Other Approaches | Approach | How it differs | | :--- | :--- | | **1. Skills as Primitives** (SEP-2076) | Uses dedicated `skills/list` and `skills/get` protocol methods instead of resources | -| **3. Skills as Tools** (sibling example) | Uses MCP tools only (model-controlled) instead of the hybrid resources + tool approach | +| **3. Skills as Tools** (sibling example) | Uses MCP tools only (model-controlled) instead of resources | | **5. Server Instructions** | Uses server instructions to point to resources instead of exposing resources directly | | **6. Convention** | This example could become part of a documented convention pattern | @@ -156,7 +174,7 @@ The URI scheme in this implementation is aligned with [SkillsDotNet](https://git 1. Scan `resources/list` for URIs matching `skill://*/SKILL.md` 2. Read each SKILL.md to extract frontmatter (name + description) 3. Build compact context summaries for the system prompt (~50-100 tokens per skill) -4. Expose a `load_skill` tool/function for on-demand full content loading +4. Provide a `read_resource` tool so the model can load full skill content on demand ## Comparison to FastMCP Implementation @@ -172,10 +190,10 @@ Key architectural differences: | Aspect | This implementation | FastMCP | | :--- | :--- | :--- | | Reactivity | Push-based (file watching + subscriptions) | Poll-based (optional re-scan on request) | -| Access model | Hybrid: Resources + `load_skill` server tool | Resources + client utilities | +| Access model | Resources-only (clients provide `read_resource` tool) | Resources + client utilities | | Architecture | Flat, single-server | Layered provider hierarchy with vendor presets (Claude, Cursor, Codex, Gemini, etc.) | -| Client utilities | Server-only | Includes `list_skills`, `download_skill`, `sync_skills` for skill distribution | -| System prompt injection | Optional `skill://prompt-xml` resource + tool description embedding | Not implemented (relies on client) | +| Client utilities | Documented expectations (see [Client Expectations](#client-expectations)) | Includes `list_skills`, `download_skill`, `sync_skills` for skill distribution | +| System prompt injection | Optional `skill://prompt-xml` resource | Not implemented (relies on client) | | Resource annotations | `audience`, `priority`, `lastModified` on all resources | Not set (uses internal metadata) | ## Inspirations and Attribution @@ -184,4 +202,4 @@ This reference implementation derives from: - **[skills-over-mcp](https://github.com/keithagroves/skills-over-mcp)** by [Keith Groves](https://github.com/keithagroves) — Resource-based skill exposure, `skill://` URI scheme, JSON index, XML prompt injection, document templates - **[skilljack-mcp](https://github.com/olaservo/skilljack-mcp)** by [Ola Hungerford](https://github.com/olaservo) — Resource template patterns, subscription architecture, path security -- **[skillsdotnet](https://github.com/PederHP/skillsdotnet)** by [Peder HP](https://github.com/PederHP) — `_manifest` resource with file hashes, `load_skill` tool, `SkillCatalog` client-side pattern, URI scheme conventions +- **[skillsdotnet](https://github.com/PederHP/skillsdotnet)** by [Peder HP](https://github.com/PederHP) — `_manifest` resource with file hashes, `SkillCatalog` client-side pattern, URI scheme conventions diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json index 1c1c21a..5dd5cbc 100644 --- a/examples/skills-as-resources/typescript/package-lock.json +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -11,8 +11,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", "chokidar": "^4.0.3", - "yaml": "^2.7.0", - "zod": "^3.23.0" + "yaml": "^2.7.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index 056430b..61b6c52 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -13,8 +13,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.27.1", "chokidar": "^4.0.3", - "yaml": "^2.7.0", - "zod": "^3.23.0" + "yaml": "^2.7.0" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/examples/skills-as-resources/typescript/smoke-test.js b/examples/skills-as-resources/typescript/smoke-test.js index c3a1a41..10961ed 100644 --- a/examples/skills-as-resources/typescript/smoke-test.js +++ b/examples/skills-as-resources/typescript/smoke-test.js @@ -94,7 +94,6 @@ async function main() { assert(caps.resources, "resources capability missing"); assert(caps.resources.listChanged === true, "listChanged not true"); assert(caps.resources.subscribe === true, "subscribe not true"); - assert(caps.tools, "tools capability missing"); }); // Test 2: list resources @@ -270,64 +269,6 @@ async function main() { ); }); - // Test 8: list tools and call load_skill (valid + invalid) - await runTest( - "load_skill tool: list, call valid, call invalid", - async () => { - // List - const { tools } = await client.listTools(); - assert(tools.length === 1, `expected 1 tool, got ${tools.length}`); - assert(tools[0].name === "load_skill", `tool name: ${tools[0].name}`); - assert( - tools[0].inputSchema?.properties?.skillName, - "missing skillName in schema", - ); - assert( - tools[0].annotations?.readOnlyHint === true, - "readOnlyHint not true", - ); - assert( - tools[0].annotations?.idempotentHint === true, - "idempotentHint not true", - ); - assert( - tools[0].description?.includes("code-review"), - "description missing code-review", - ); - assert( - tools[0].description?.includes("git-commit-review"), - "description missing git-commit-review", - ); - - // Valid call - const ok = await client.callTool({ - name: "load_skill", - arguments: { skillName: "code-review" }, - }); - assert(ok.content?.length === 1, "expected 1 content item"); - assert(ok.content[0].type === "text", "content not text"); - assert( - ok.content[0].text.includes("# Code Review"), - "missing heading in tool result", - ); - assert(!ok.isError, "unexpected isError on valid call"); - - // Invalid call - const err = await client.callTool({ - name: "load_skill", - arguments: { skillName: "nonexistent-skill" }, - }); - assert(err.isError === true, "expected isError on invalid call"); - assert( - err.content[0].text.toLowerCase().includes("not found"), - "missing 'not found' in error", - ); - assert( - err.content[0].text.includes("code-review"), - "missing available skill in error", - ); - }, - ); } finally { clearTimeout(timer); await client.close(); diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 89b54f2..70502c0 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -4,8 +4,7 @@ * * A reference implementation demonstrating the Resources approach * from the Skills Over MCP Interest Group: exposing agent skills via - * MCP resources using the skill:// URI scheme, with a load_skill tool - * for model-controlled progressive disclosure. + * MCP resources using the skill:// URI scheme. * * URI scheme (aligned with skillsdotnet conventions): * - skill://{name}/SKILL.md — Skill content (listed resource) @@ -13,8 +12,10 @@ * - skill://{name}/{+path} — Supporting file (resource template, not listed) * - skill://prompt-xml — XML for system prompt injection (optional) * - * Tool: - * - load_skill — Model-controlled skill loading (progressive disclosure) + * Clients are expected to: + * - Scan resources/list for skill://{name}/SKILL.md URIs to discover skills + * - Parse frontmatter for name + description to build context summaries + * - Provide a read_resource tool so the model can load skills on demand * * Inspired by: * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) @@ -27,9 +28,8 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; -import { z } from "zod"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RegisteredResource, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js"; import { discoverSkills, loadSkillContent, loadDocument } from "./skill-discovery.js"; @@ -37,16 +37,12 @@ import { generateSkillsXML, isTextMimeType } from "./resource-helpers.js"; import { createSubscriptionManager } from "./subscriptions.js"; import { createSkillDirectoryWatcher } from "./skill-watcher.js"; -// Parse CLI arguments: [skillsDir] [--no-embed-catalog] +// Parse CLI arguments: [skillsDir] const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const { values: flags, positionals } = parseArgs({ +const { positionals } = parseArgs({ args: process.argv.slice(2), - options: { - "no-embed-catalog": { type: "boolean", default: false }, - }, allowPositionals: true, }); -const embedCatalog = !flags["no-embed-catalog"]; const skillsDir = positionals[0] ? path.resolve(positionals[0]) : path.resolve(__dirname, "../../../sample-skills"); @@ -63,18 +59,15 @@ function getSkillListStr(): string { console.error( `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${getSkillListStr()}` ); -console.error( - `[skills-as-resources] Catalog embedding: ${embedCatalog ? "on" : "off (--no-embed-catalog)"}` -); for (const [name, skill] of skillMap) { const fileCount = skill.manifest.files.length; console.error(` - ${name}: ${fileCount} file(s) in manifest`); } -// Create MCP server with resources and tools capabilities +// Create MCP server with resources capabilities const server = new McpServer( { name: "skills-as-resources-example", version: "0.2.0" }, - { capabilities: { resources: { listChanged: true, subscribe: true }, tools: {} } } + { capabilities: { resources: { listChanged: true, subscribe: true } } } ); // --- Static resources --- @@ -275,70 +268,6 @@ server.registerResource( } ); -// --- Tool for model-controlled progressive disclosure --- - -// Tool: load_skill — allows models to discover and load skills on demand. -// Description dynamically lists available skill names, mirroring -// skillsdotnet's SkillCatalog pattern but implemented server-side. -// When --no-embed-catalog is set, the description only lists skill names -// (useful when the client already injects skill context from resources). -const loadSkillToolDescription = () => { - const base = - `Load the full SKILL.md content for a named skill. ` + - `Use this when you need detailed instructions for performing a specific task.`; - return embedCatalog - ? base + `\n\n` + generateSkillsXML(skillMap) - : base + ` Available skills: ${getSkillListStr()}`; -}; - -const loadSkillTool: RegisteredTool = server.registerTool( - "load_skill", - { - description: loadSkillToolDescription(), - annotations: { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, - }, - inputSchema: { - skillName: z.string().describe("The name of the skill to load"), - }, - }, - async ({ skillName }) => { - const skill = skillMap.get(skillName); - if (!skill) { - return { - content: [ - { - type: "text" as const, - text: `Skill "${skillName}" not found. Available skills: ${getSkillListStr()}`, - }, - ], - isError: true, - }; - } - - try { - const content = loadSkillContent(skill.path, skillsDir); - return { - content: [{ type: "text" as const, text: content }], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [ - { - type: "text" as const, - text: `Failed to load skill "${skillName}": ${message}`, - }, - ], - isError: true, - }; - } - } -); - // --- Resource subscriptions --- // Watch subscribed skill files and notify on changes. @@ -390,9 +319,6 @@ const directoryWatcher = createSkillDirectoryWatcher(skillsDir, () => { ); } } - - // Update load_skill tool description with current skill list - loadSkillTool.update({ description: loadSkillToolDescription() }); }); // Clean up watchers on exit From d3691cdb37f0f84fad4b398901041f388c3f238c Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 27 Feb 2026 07:19:25 -0700 Subject: [PATCH 19/21] Add @ext-modelcontextprotocol/skills SDK; refactor example to consume it Extract shared types, discovery, MIME, XML, and registration logic from the example into a reusable SDK at typescript/sdk/. The SDK adds new client-side utilities (listSkillResources, parseSkillFrontmatter, buildSkillsSummary), URI helpers, and a registerSkillResources() function that replaces ~160 lines of manual resource registration. The example now imports from the SDK, keeping only subscription and watcher logic locally. Co-Authored-By: Claude Opus 4.6 --- .../typescript/package-lock.json | 76 +- .../typescript/package.json | 4 +- .../typescript/src/index.ts | 230 +- .../typescript/src/subscriptions.ts | 4 +- typescript/sdk/.eslintrc.json | 24 + typescript/sdk/.nvmrc | 1 + typescript/sdk/README.md | 175 + typescript/sdk/package-lock.json | 4371 +++++++++++++++++ typescript/sdk/package.json | 45 + typescript/sdk/src/client.test.ts | 223 + typescript/sdk/src/client.ts | 100 + typescript/sdk/src/index.ts | 55 + .../sdk/src/mime.ts | 52 +- typescript/sdk/src/server.test.ts | 273 + .../sdk/src/server.ts | 283 +- .../sdk}/src/types.ts | 40 +- typescript/sdk/src/uri.test.ts | 103 + typescript/sdk/src/uri.ts | 54 + typescript/sdk/src/xml.test.ts | 130 + typescript/sdk/src/xml.ts | 83 + typescript/sdk/tsconfig.json | 17 + typescript/sdk/vitest.config.js | 7 + 22 files changed, 6018 insertions(+), 332 deletions(-) create mode 100644 typescript/sdk/.eslintrc.json create mode 100644 typescript/sdk/.nvmrc create mode 100644 typescript/sdk/README.md create mode 100644 typescript/sdk/package-lock.json create mode 100644 typescript/sdk/package.json create mode 100644 typescript/sdk/src/client.test.ts create mode 100644 typescript/sdk/src/client.ts create mode 100644 typescript/sdk/src/index.ts rename examples/skills-as-resources/typescript/src/resource-helpers.ts => typescript/sdk/src/mime.ts (51%) create mode 100644 typescript/sdk/src/server.test.ts rename examples/skills-as-resources/typescript/src/skill-discovery.ts => typescript/sdk/src/server.ts (56%) rename {examples/skills-as-resources/typescript => typescript/sdk}/src/types.ts (66%) create mode 100644 typescript/sdk/src/uri.test.ts create mode 100644 typescript/sdk/src/uri.ts create mode 100644 typescript/sdk/src/xml.test.ts create mode 100644 typescript/sdk/src/xml.ts create mode 100644 typescript/sdk/tsconfig.json create mode 100644 typescript/sdk/vitest.config.js diff --git a/examples/skills-as-resources/typescript/package-lock.json b/examples/skills-as-resources/typescript/package-lock.json index 5dd5cbc..b1c19c2 100644 --- a/examples/skills-as-resources/typescript/package-lock.json +++ b/examples/skills-as-resources/typescript/package-lock.json @@ -9,9 +9,9 @@ "version": "0.2.0", "license": "Apache-2.0", "dependencies": { + "@ext-modelcontextprotocol/skills": "file:../../../typescript/sdk", "@modelcontextprotocol/sdk": "^1.27.1", - "chokidar": "^4.0.3", - "yaml": "^2.7.0" + "chokidar": "^4.0.3" }, "devDependencies": { "@types/node": "^22.0.0", @@ -22,6 +22,29 @@ "node": ">=18.0.0" } }, + "../../../typescript/sdk": { + "name": "@ext-modelcontextprotocol/skills", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "yaml": "^2.7.0" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.57.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", @@ -464,6 +487,10 @@ "node": ">=18" } }, + "node_modules/@ext-modelcontextprotocol/skills": { + "resolved": "../../../typescript/sdk", + "link": true + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -517,9 +544,9 @@ } }, "node_modules/@types/node": { - "version": "22.19.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.10.tgz", - "integrity": "sha512-tF5VOugLS/EuDlTBijk0MqABfP8UxgYazTLo3uIn3b4yJgg26QRbVYJYsDtHrjdDUIRfP70+VfhTTc+CE1yskw==", + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", "dev": true, "license": "MIT", "dependencies": { @@ -540,9 +567,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1116,9 +1143,9 @@ } }, "node_modules/hono": { - "version": "4.11.8", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.8.tgz", - "integrity": "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==", + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", "license": "MIT", "engines": { "node": ">=16.9.0" @@ -1380,9 +1407,9 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -1728,25 +1755,10 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, - "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", - "license": "ISC", - "bin": { - "yaml": "bin.mjs" - }, - "engines": { - "node": ">= 14.6" - }, - "funding": { - "url": "https://github.com/sponsors/eemeli" - } - }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/examples/skills-as-resources/typescript/package.json b/examples/skills-as-resources/typescript/package.json index 61b6c52..dcf836f 100644 --- a/examples/skills-as-resources/typescript/package.json +++ b/examples/skills-as-resources/typescript/package.json @@ -11,9 +11,9 @@ "test:smoke": "node smoke-test.js" }, "dependencies": { + "@ext-modelcontextprotocol/skills": "file:../../../typescript/sdk", "@modelcontextprotocol/sdk": "^1.27.1", - "chokidar": "^4.0.3", - "yaml": "^2.7.0" + "chokidar": "^4.0.3" }, "devDependencies": { "@types/node": "^22.0.0", diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index 70502c0..fcc8f4a 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -28,12 +28,14 @@ import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { parseArgs } from "node:util"; -import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { SubscribeRequestSchema, UnsubscribeRequestSchema } from "@modelcontextprotocol/sdk/types.js"; -import { discoverSkills, loadSkillContent, loadDocument } from "./skill-discovery.js"; -import { generateSkillsXML, isTextMimeType } from "./resource-helpers.js"; +import { + discoverSkills, + registerSkillResources, + type SkillResourceHandles, +} from "@ext-modelcontextprotocol/skills"; import { createSubscriptionManager } from "./subscriptions.js"; import { createSkillDirectoryWatcher } from "./skill-watcher.js"; @@ -50,14 +52,8 @@ const skillsDir = positionals[0] // Discover skills at startup const skillMap = discoverSkills(skillsDir); -/** Dynamic skill list string — reflects current skillMap contents. */ -function getSkillListStr(): string { - const names = Array.from(skillMap.keys()); - return names.join(", ") || "none"; -} - console.error( - `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${getSkillListStr()}` + `[skills-as-resources] Discovered ${skillMap.size} skill(s): ${Array.from(skillMap.keys()).join(", ") || "none"}` ); for (const [name, skill] of skillMap) { const fileCount = skill.manifest.files.length; @@ -70,202 +66,15 @@ const server = new McpServer( { capabilities: { resources: { listChanged: true, subscribe: true } } } ); -// --- Static resources --- - -// Resource: skill://prompt-xml — XML for system prompt injection (optional convenience) -server.registerResource( - "skills-prompt-xml", - "skill://prompt-xml", - { - description: - "XML representation of available skills for injecting into system prompts", - mimeType: "application/xml", - annotations: { - audience: ["user", "assistant"], - priority: 0.3, - }, - }, - async (uri) => ({ - contents: [ - { - uri: uri.href, - text: generateSkillsXML(skillMap), - }, - ], - }) -); - -// Track resource handles so we can remove them when skills are removed dynamically. -const resourceHandles = new Map(); - -/** - * Register the SKILL.md and _manifest resources for a single skill. - * Returns the resource handles for later removal. - */ -function registerSkillResources( - name: string, - skill: import("./types.js").SkillMetadata, -): { skill: RegisteredResource; manifest: RegisteredResource } { - const skillHandle = server.registerResource( - `skill-${name}`, - `skill://${name}/SKILL.md`, - { - description: skill.description, - mimeType: "text/markdown", - annotations: { - audience: ["user", "assistant"], - priority: 1.0, - lastModified: skill.lastModified, - }, - }, - async (uri) => { - try { - const content = loadSkillContent(skill.path, skillsDir); - return { - contents: [{ uri: uri.href, text: content }], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - contents: [ - { - uri: uri.href, - text: `# Error\n\nFailed to load skill "${name}": ${message}`, - }, - ], - }; - } - } - ); - - const manifestHandle = server.registerResource( - `skill-${name}-manifest`, - `skill://${name}/_manifest`, - { - description: `File manifest for skill '${name}' with content hashes`, - mimeType: "application/json", - annotations: { - audience: ["user", "assistant"], - priority: 0.5, - lastModified: skill.lastModified, - }, - }, - async (uri) => ({ - contents: [ - { - uri: uri.href, - text: skill.manifestJson, - }, - ], - }) - ); - - return { skill: skillHandle, manifest: manifestHandle }; -} - -// Per-skill static resources -for (const [name, skill] of skillMap) { - resourceHandles.set(name, registerSkillResources(name, skill)); -} - -// --- Resource template for supporting files --- - -// Template: skill://{skillName}/{+path} -// The {+} prefix uses RFC 6570 reserved expansion, matching paths with slashes. -// NOT listed — supporting files are discoverable via the _manifest resource. -server.registerResource( - "skill-file", - new ResourceTemplate("skill://{skillName}/{+path}", { - list: undefined, - complete: { - skillName: (value) => { - return Array.from(skillMap.values()) - .filter((s) => s.documents.length > 0) - .map((s) => s.name) - .filter((name) => name.startsWith(value)); - }, - path: (value, context) => { - const skillName = context?.arguments?.skillName; - if (!skillName) return []; - - const skill = skillMap.get(skillName); - if (!skill) return []; - - // SDK's createCompletionResult handles truncation to 100 and sets total/hasMore - return skill.documents - .map((d) => d.path) - .filter((p) => p.startsWith(value)); - }, - }, - }), - { - description: "Fetch a supporting file from a skill directory", - mimeType: "text/plain", - annotations: { - audience: ["user", "assistant"], - priority: 0.2, - }, - }, - async (uri, variables) => { - const skillName = Array.isArray(variables.skillName) - ? variables.skillName[0] - : variables.skillName; - const filePath = Array.isArray(variables.path) - ? variables.path[0] - : variables.path; - - const skill = skillMap.get(skillName); - if (!skill) { - return { - contents: [ - { - uri: uri.href, - text: `# Error\n\nSkill "${skillName}" not found. Available: ${getSkillListStr()}`, - }, - ], - }; - } - - const doc = skill.documents.find((d) => d.path === filePath); - if (!doc) { - const available = skill.documents.map((d) => `- ${d.path}`).join("\n"); - return { - contents: [ - { - uri: uri.href, - text: `# Error\n\nFile "${filePath}" not found in skill "${skillName}".\n\n## Available Files\n\n${available || "No supporting files available."}`, - }, - ], - }; - } +// --- Register all skill resources via SDK --- - try { - const isText = isTextMimeType(doc.mimeType); - const content = loadDocument(skill, filePath, skillsDir, isText); - return { - contents: [ - { - uri: uri.href, - mimeType: doc.mimeType, - ...content, - }, - ], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - contents: [ - { - uri: uri.href, - text: `# Error\n\nFailed to read file: ${message}`, - }, - ], - }; - } - } +// registerSkillResources closes over skillMap by reference, so dynamic +// mutations (add/delete) are reflected in template completions and prompt-xml. +const resourceHandles: SkillResourceHandles = registerSkillResources( + server, + skillMap, + skillsDir, + { template: true, promptXml: true }, ); // --- Resource subscriptions --- @@ -309,11 +118,16 @@ const directoryWatcher = createSkillDirectoryWatcher(skillsDir, () => { } } - // Find added skills + // Find added skills — register via SDK with single-entry map for (const [name, metadata] of newSkillMap) { if (!skillMap.has(name)) { skillMap.set(name, metadata); - resourceHandles.set(name, registerSkillResources(name, metadata)); + const singleMap = new Map([[name, metadata]]); + const newHandles = registerSkillResources(server, singleMap, skillsDir, { + template: false, // Already registered at startup + promptXml: false, // Already registered (and reads from skillMap by ref) + }); + resourceHandles.set(name, newHandles.get(name)!); console.error( `[skills-as-resources] Skill added: ${name} (${metadata.manifest.files.length} file(s))` ); diff --git a/examples/skills-as-resources/typescript/src/subscriptions.ts b/examples/skills-as-resources/typescript/src/subscriptions.ts index a75483a..5d5b6a9 100644 --- a/examples/skills-as-resources/typescript/src/subscriptions.ts +++ b/examples/skills-as-resources/typescript/src/subscriptions.ts @@ -12,8 +12,8 @@ import * as path from "node:path"; import { watch, type FSWatcher } from "chokidar"; -import { isPathWithinBase } from "./skill-discovery.js"; -import type { SkillMetadata } from "./types.js"; +import { isPathWithinBase } from "@ext-modelcontextprotocol/skills"; +import type { SkillMetadata } from "@ext-modelcontextprotocol/skills"; /** Debounce interval (ms) for coalescing rapid file changes. */ const DEBOUNCE_MS = 100; diff --git a/typescript/sdk/.eslintrc.json b/typescript/sdk/.eslintrc.json new file mode 100644 index 0000000..146f083 --- /dev/null +++ b/typescript/sdk/.eslintrc.json @@ -0,0 +1,24 @@ +{ + "root": true, + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:@typescript-eslint/recommended-requiring-type-checking" + ], + "env": { + "node": true, + "es2022": true + }, + "rules": { + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], + "@typescript-eslint/no-explicit-any": "error" + }, + "ignorePatterns": ["dist/", "*.test.ts", "vitest.config.js"] +} diff --git a/typescript/sdk/.nvmrc b/typescript/sdk/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/typescript/sdk/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/typescript/sdk/README.md b/typescript/sdk/README.md new file mode 100644 index 0000000..9e7ca71 --- /dev/null +++ b/typescript/sdk/README.md @@ -0,0 +1,175 @@ +# @ext-modelcontextprotocol/skills + +TypeScript SDK for the **Skills as Resources** MCP extension pattern — exposing agent skills via MCP resources using the `skill://` URI scheme. + +## Installation + +```bash +npm install @ext-modelcontextprotocol/skills +``` + +Requires `@modelcontextprotocol/sdk` ^1.0.0 as a peer dependency. + +## Quick Start + +### Server: Discover and register skills + +```typescript +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { discoverSkills, registerSkillResources } from "@ext-modelcontextprotocol/skills"; + +const server = new McpServer( + { name: "my-skills-server", version: "1.0.0" }, + { capabilities: { resources: {} } }, +); + +const skillsDir = "./skills"; +const skillMap = discoverSkills(skillsDir); +const handles = registerSkillResources(server, skillMap, skillsDir); + +const transport = new StdioServerTransport(); +await server.connect(transport); +``` + +### Client: List and summarize skills + +```typescript +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + listSkillResources, + buildSkillsSummary, + generateSkillsXMLFromSummaries, +} from "@ext-modelcontextprotocol/skills"; + +// After connecting to a skills server... +const skills = await listSkillResources(client); +console.log(buildSkillsSummary(skills)); + +// Or generate XML for system prompt injection: +const xml = generateSkillsXMLFromSummaries(skills); +``` + +## API + +### Types + +- `SkillMetadata` — Full server-side skill metadata (name, description, path, documents, manifest) +- `SkillSummary` — Lightweight client-side type: `{ name, uri, description?, mimeType? }` +- `SkillDocument` — Supplementary file entry (path, mimeType, size, hash) +- `SkillManifest` / `ManifestFileEntry` — File inventory with SHA256 hashes +- `RegisterSkillResourcesOptions` — Options for `registerSkillResources()` +- `SkillResourceHandles` — Return type mapping skill names to resource handles + +### URI Utilities + +```typescript +import { parseSkillUri, buildSkillUri, isSkillContentUri, isSkillManifestUri } from "@ext-modelcontextprotocol/skills"; + +parseSkillUri("skill://code-review/SKILL.md"); +// → { name: "code-review", path: "SKILL.md" } + +buildSkillUri("code-review"); +// → "skill://code-review/SKILL.md" + +buildSkillUri("code-review", "_manifest"); +// → "skill://code-review/_manifest" + +isSkillContentUri("skill://code-review/SKILL.md"); // true +isSkillManifestUri("skill://code-review/_manifest"); // true +``` + +### MIME Utilities + +```typescript +import { getMimeType, isTextMimeType } from "@ext-modelcontextprotocol/skills"; + +getMimeType("doc.md"); // "text/markdown" +getMimeType("image.png"); // "image/png" +isTextMimeType("text/markdown"); // true +isTextMimeType("application/json"); // true +isTextMimeType("image/png"); // false +``` + +### XML Generation + +```typescript +import { generateSkillsXML, generateSkillsXMLFromSummaries } from "@ext-modelcontextprotocol/skills"; + +// Server-side: from SkillMetadata map +const xml = generateSkillsXML(skillMap); + +// Client-side: from SkillSummary array +const xml = generateSkillsXMLFromSummaries(skills); +``` + +### Server + +```typescript +import { + discoverSkills, + registerSkillResources, + loadSkillContent, + loadDocument, + scanDocuments, + isPathWithinBase, +} from "@ext-modelcontextprotocol/skills"; + +// Discover all skills in a directory +const skillMap = discoverSkills("./skills"); + +// Register resources on an McpServer +const handles = registerSkillResources(server, skillMap, "./skills", { + template: true, // Register resource template for supporting files (default: true) + promptXml: false, // Register skill://prompt-xml resource (default: false) +}); + +// Load skill content with security checks +const content = loadSkillContent(skill.path, skillsDir); + +// Load supplementary documents +const doc = loadDocument(skill, "references/REFERENCE.md", skillsDir, true); +``` + +### Client + +```typescript +import { + listSkillResources, + parseSkillFrontmatter, + buildSkillsSummary, +} from "@ext-modelcontextprotocol/skills"; + +// List all skills from an MCP client (handles pagination) +const skills = await listSkillResources(client); + +// Parse frontmatter from skill content (no yaml dependency needed) +const meta = parseSkillFrontmatter(content); +// → { name: "code-review", description: "Review code" } + +// Build plain-text summary for context injection +const summary = buildSkillsSummary(skills); +``` + +## URI Scheme + +| Pattern | Description | +|---------|-------------| +| `skill://{name}/SKILL.md` | Skill content (listed resource) | +| `skill://{name}/_manifest` | File manifest with SHA256 hashes (listed resource) | +| `skill://{name}/{+path}` | Supporting file (resource template) | +| `skill://prompt-xml` | XML for system prompt injection (optional) | + +## Future Work (TODO) + +- `read_resource` tool factory for client-side model access +- Subscription manager for resource change notifications +- File watcher for dynamic skill hot-reload +- Caching layer for skill content +- Multi-server skill aggregation +- Hash verification on client side +- Extended frontmatter metadata fields beyond name/description + +## License + +Apache-2.0 diff --git a/typescript/sdk/package-lock.json b/typescript/sdk/package-lock.json new file mode 100644 index 0000000..23caa4a --- /dev/null +++ b/typescript/sdk/package-lock.json @@ -0,0 +1,4371 @@ +{ + "name": "@ext-modelcontextprotocol/skills", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@ext-modelcontextprotocol/skills", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "yaml": "^2.7.0" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.57.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.9", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", + "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.27.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz", + "integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.13.tgz", + "integrity": "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/eslint/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", + "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", + "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "10.0.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.12.3", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.3.tgz", + "integrity": "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", + "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + } + } +} diff --git a/typescript/sdk/package.json b/typescript/sdk/package.json new file mode 100644 index 0000000..a12a242 --- /dev/null +++ b/typescript/sdk/package.json @@ -0,0 +1,45 @@ +{ + "name": "@ext-modelcontextprotocol/skills", + "version": "0.1.0", + "description": "SDK for the Skills as Resources MCP extension pattern", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + }, + "files": [ + "dist", + "src", + "README.md" + ], + "scripts": { + "build": "tsc", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint src --ext .ts", + "prepublishOnly": "npm run build" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.0.0" + }, + "dependencies": { + "yaml": "^2.7.0" + }, + "devDependencies": { + "@modelcontextprotocol/sdk": "^1.27.1", + "@types/node": "^22.0.0", + "@typescript-eslint/eslint-plugin": "^8.0.0", + "@typescript-eslint/parser": "^8.0.0", + "eslint": "^8.57.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "license": "Apache-2.0" +} diff --git a/typescript/sdk/src/client.test.ts b/typescript/sdk/src/client.test.ts new file mode 100644 index 0000000..710cc54 --- /dev/null +++ b/typescript/sdk/src/client.test.ts @@ -0,0 +1,223 @@ +import { describe, it, expect, vi } from "vitest"; +import { + listSkillResources, + parseSkillFrontmatter, + buildSkillsSummary, +} from "./client.js"; +import type { SkillSummary } from "./types.js"; + +describe("parseSkillFrontmatter", () => { + it("parses name and description from frontmatter", () => { + const content = `--- +name: code-review +description: Perform structured code reviews +--- +# Code Review Skill +`; + const result = parseSkillFrontmatter(content); + expect(result).toEqual({ + name: "code-review", + description: "Perform structured code reviews", + }); + }); + + it("handles quoted values", () => { + const content = `--- +name: "my-skill" +description: 'A skill with quotes' +--- +Body +`; + const result = parseSkillFrontmatter(content); + expect(result).toEqual({ + name: "my-skill", + description: "A skill with quotes", + }); + }); + + it("returns empty description if missing", () => { + const content = `--- +name: minimal-skill +--- +Body +`; + const result = parseSkillFrontmatter(content); + expect(result).toEqual({ + name: "minimal-skill", + description: "", + }); + }); + + it("returns null if no frontmatter delimiter", () => { + expect(parseSkillFrontmatter("# Just a heading")).toBeNull(); + }); + + it("returns null if frontmatter not closed", () => { + expect(parseSkillFrontmatter("---\nname: test\n")).toBeNull(); + }); + + it("returns null if name is missing", () => { + const content = `--- +description: no name here +--- +Body +`; + expect(parseSkillFrontmatter(content)).toBeNull(); + }); + + it("handles additional frontmatter fields", () => { + const content = `--- +name: extended +description: Extended skill +version: 1.0 +author: Test +--- +Body +`; + const result = parseSkillFrontmatter(content); + expect(result).toEqual({ + name: "extended", + description: "Extended skill", + }); + }); +}); + +describe("buildSkillsSummary", () => { + it("returns message for empty array", () => { + expect(buildSkillsSummary([])).toBe("No skills available."); + }); + + it("builds summary with descriptions", () => { + const skills: SkillSummary[] = [ + { + name: "code-review", + uri: "skill://code-review/SKILL.md", + description: "Review code", + }, + { + name: "test-writer", + uri: "skill://test-writer/SKILL.md", + description: "Write tests", + }, + ]; + + const result = buildSkillsSummary(skills); + expect(result).toContain("Available skills:"); + expect(result).toContain( + "- code-review (skill://code-review/SKILL.md): Review code", + ); + expect(result).toContain( + "- test-writer (skill://test-writer/SKILL.md): Write tests", + ); + }); + + it("builds summary without descriptions", () => { + const skills: SkillSummary[] = [ + { name: "basic", uri: "skill://basic/SKILL.md" }, + ]; + + const result = buildSkillsSummary(skills); + const lines = result.split("\n"); + // The skill line should NOT end with ": description" — just the URI + expect(lines[1]).toBe("- basic (skill://basic/SKILL.md)"); + }); +}); + +describe("listSkillResources", () => { + it("lists skill resources from a mock client", async () => { + const mockClient = { + listResources: vi.fn().mockResolvedValue({ + resources: [ + { + uri: "skill://code-review/SKILL.md", + name: "code-review", + description: "Review code", + mimeType: "text/markdown", + }, + { + uri: "skill://code-review/_manifest", + name: "code-review-manifest", + mimeType: "application/json", + }, + { + uri: "skill://test-writer/SKILL.md", + name: "test-writer", + description: "Write tests", + }, + { + uri: "https://example.com/other", + name: "not-a-skill", + }, + ], + }), + }; + + const skills = await listSkillResources( + mockClient as unknown as Parameters[0], + ); + + expect(skills).toHaveLength(2); + expect(skills[0]).toEqual({ + name: "code-review", + uri: "skill://code-review/SKILL.md", + description: "Review code", + mimeType: "text/markdown", + }); + expect(skills[1]).toEqual({ + name: "test-writer", + uri: "skill://test-writer/SKILL.md", + description: "Write tests", + mimeType: undefined, + }); + }); + + it("handles pagination", async () => { + const mockClient = { + listResources: vi + .fn() + .mockResolvedValueOnce({ + resources: [ + { + uri: "skill://skill-a/SKILL.md", + name: "skill-a", + }, + ], + nextCursor: "page2", + }) + .mockResolvedValueOnce({ + resources: [ + { + uri: "skill://skill-b/SKILL.md", + name: "skill-b", + }, + ], + }), + }; + + const skills = await listSkillResources( + mockClient as unknown as Parameters[0], + ); + + expect(skills).toHaveLength(2); + expect(skills[0].name).toBe("skill-a"); + expect(skills[1].name).toBe("skill-b"); + expect(mockClient.listResources).toHaveBeenCalledTimes(2); + expect(mockClient.listResources).toHaveBeenCalledWith({ cursor: "page2" }); + }); + + it("returns empty array when no skills found", async () => { + const mockClient = { + listResources: vi.fn().mockResolvedValue({ + resources: [ + { uri: "https://example.com/other", name: "not-a-skill" }, + ], + }), + }; + + const skills = await listSkillResources( + mockClient as unknown as Parameters[0], + ); + + expect(skills).toHaveLength(0); + }); +}); diff --git a/typescript/sdk/src/client.ts b/typescript/sdk/src/client.ts new file mode 100644 index 0000000..acaf371 --- /dev/null +++ b/typescript/sdk/src/client.ts @@ -0,0 +1,100 @@ +/** + * Client-side utilities for discovering and summarizing skills + * exposed as MCP resources by a skills server. + * + * These functions help MCP clients enumerate available skills, + * parse frontmatter from skill content, and build context summaries + * for injection into system prompts or model context. + */ + +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import type { SkillSummary } from "./types.js"; +import { parseSkillUri, SKILL_FILENAME } from "./uri.js"; + +/** + * List all skill resources available from an MCP client. + * + * Calls resources/list, filters for skill://{name}/SKILL.md URIs, + * and returns lightweight SkillSummary objects. Handles pagination + * automatically if the server returns a nextCursor. + */ +export async function listSkillResources( + client: Client, +): Promise { + const skills: SkillSummary[] = []; + let cursor: string | undefined; + + do { + const result = await client.listResources( + cursor ? { cursor } : undefined, + ); + + for (const resource of result.resources) { + const parsed = parseSkillUri(resource.uri); + if (!parsed || parsed.path !== SKILL_FILENAME) continue; + + skills.push({ + name: parsed.name, + uri: resource.uri, + description: resource.description, + mimeType: resource.mimeType, + }); + } + + cursor = result.nextCursor; + } while (cursor); + + return skills; +} + +/** + * Parse name and description from SKILL.md YAML frontmatter content. + * + * Uses a simple regex approach — no yaml dependency required on the client side. + * Handles the common case of `name: value` and `description: value` in frontmatter. + * + * Returns null if the content doesn't contain valid frontmatter. + */ +export function parseSkillFrontmatter( + content: string, +): { name: string; description: string } | null { + if (!content.startsWith("---")) return null; + + const endIndex = content.indexOf("---", 3); + if (endIndex === -1) return null; + + const frontmatter = content.slice(3, endIndex); + + const nameMatch = frontmatter.match(/^name:\s*(.+)$/m); + const descMatch = frontmatter.match(/^description:\s*(.+)$/m); + + if (!nameMatch) return null; + + const name = nameMatch[1].trim().replace(/^["']|["']$/g, ""); + const description = descMatch + ? descMatch[1].trim().replace(/^["']|["']$/g, "") + : ""; + + return { name, description }; +} + +/** + * Build a plain-text summary of available skills for context injection. + * + * Format: + * ``` + * Available skills: + * - code-review (skill://code-review/SKILL.md): Perform structured code reviews + * - test-writer (skill://test-writer/SKILL.md): Generate unit tests + * ``` + */ +export function buildSkillsSummary(skills: SkillSummary[]): string { + if (skills.length === 0) return "No skills available."; + + const lines = ["Available skills:"]; + for (const skill of skills) { + const desc = skill.description ? `: ${skill.description}` : ""; + lines.push(`- ${skill.name} (${skill.uri})${desc}`); + } + return lines.join("\n"); +} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts new file mode 100644 index 0000000..baebaac --- /dev/null +++ b/typescript/sdk/src/index.ts @@ -0,0 +1,55 @@ +/** + * @ext-modelcontextprotocol/skills + * + * SDK for the Skills as Resources MCP extension pattern. + * + * @license Apache-2.0 + */ + +// Types +export type { + ManifestFileEntry, + SkillManifest, + SkillDocument, + SkillMetadata, + SkillSummary, + RegisterSkillResourcesOptions, + SkillResourceHandles, +} from "./types.js"; + +// URI utilities +export { + SKILL_FILENAME, + MANIFEST_PATH, + parseSkillUri, + buildSkillUri, + isSkillContentUri, + isSkillManifestUri, +} from "./uri.js"; + +// MIME utilities +export { getMimeType, isTextMimeType } from "./mime.js"; + +// XML generation +export { + escapeXml, + generateSkillsXML, + generateSkillsXMLFromSummaries, +} from "./xml.js"; + +// Server-side +export { + discoverSkills, + loadSkillContent, + loadDocument, + isPathWithinBase, + scanDocuments, + registerSkillResources, +} from "./server.js"; + +// Client-side +export { + listSkillResources, + parseSkillFrontmatter, + buildSkillsSummary, +} from "./client.js"; diff --git a/examples/skills-as-resources/typescript/src/resource-helpers.ts b/typescript/sdk/src/mime.ts similarity index 51% rename from examples/skills-as-resources/typescript/src/resource-helpers.ts rename to typescript/sdk/src/mime.ts index 4056b3e..83ab807 100644 --- a/examples/skills-as-resources/typescript/src/resource-helpers.ts +++ b/typescript/sdk/src/mime.ts @@ -1,15 +1,8 @@ /** - * Resource helper utilities for the Skills as Resources implementation. - * - * Provides XML generation for system prompt injection and MIME type mapping - * for skill documents. - * - * Inspired by: - * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + * MIME type utilities for skill documents. */ import * as path from "node:path"; -import type { SkillMetadata } from "./types.js"; /** Map file extensions to MIME types. */ const MIME_TYPES: Record = { @@ -57,46 +50,3 @@ export function isTextMimeType(mimeType: string): boolean { if (mimeType.endsWith("+xml")) return true; return false; } - -/** - * Escape XML special characters. - */ -function escapeXml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** - * Generate XML for injecting into system prompts. - * - * Format: - * ```xml - * - * - * code-review - * Perform structured code reviews... - * skill://code-review - * - * - * ``` - */ -export function generateSkillsXML( - skillMap: Map -): string { - const lines: string[] = [""]; - - for (const skill of skillMap.values()) { - lines.push(" "); - lines.push(` ${escapeXml(skill.name)}`); - lines.push(` ${escapeXml(skill.description)}`); - lines.push(` skill://${escapeXml(skill.name)}/SKILL.md`); - lines.push(" "); - } - - lines.push(""); - return lines.join("\n"); -} diff --git a/typescript/sdk/src/server.test.ts b/typescript/sdk/src/server.test.ts new file mode 100644 index 0000000..143116b --- /dev/null +++ b/typescript/sdk/src/server.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + discoverSkills, + loadSkillContent, + loadDocument, + isPathWithinBase, + scanDocuments, +} from "./server.js"; + +let tmpDir: string; + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-sdk-test-")); +}); + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +function createSkill( + name: string, + description: string, + extraContent = "", +): string { + const skillDir = path.join(tmpDir, name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n${extraContent}`, + ); + return skillDir; +} + +describe("discoverSkills", () => { + it("discovers skills in a directory", () => { + createSkill("code-review", "Review code"); + createSkill("test-writer", "Write tests"); + + const skills = discoverSkills(tmpDir); + + expect(skills.size).toBe(2); + expect(skills.has("code-review")).toBe(true); + expect(skills.has("test-writer")).toBe(true); + + const codeReview = skills.get("code-review")!; + expect(codeReview.name).toBe("code-review"); + expect(codeReview.description).toBe("Review code"); + expect(codeReview.manifest.skill).toBe("code-review"); + expect(codeReview.manifest.files.length).toBeGreaterThanOrEqual(1); + expect(codeReview.manifest.files[0].path).toBe("SKILL.md"); + expect(codeReview.manifest.files[0].hash).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + + it("returns empty map for non-existent directory", () => { + const skills = discoverSkills("/nonexistent/path"); + expect(skills.size).toBe(0); + }); + + it("returns empty map for empty directory", () => { + const skills = discoverSkills(tmpDir); + expect(skills.size).toBe(0); + }); + + it("skips directories without SKILL.md", () => { + fs.mkdirSync(path.join(tmpDir, "no-skill")); + fs.writeFileSync(path.join(tmpDir, "no-skill", "README.md"), "# Hello"); + + const skills = discoverSkills(tmpDir); + expect(skills.size).toBe(0); + }); + + it("skips skills with missing name", () => { + const skillDir = path.join(tmpDir, "bad-skill"); + fs.mkdirSync(skillDir); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\ndescription: No name\n---\n# Bad", + ); + + const skills = discoverSkills(tmpDir); + expect(skills.size).toBe(0); + }); + + it("skips skills with missing description", () => { + const skillDir = path.join(tmpDir, "bad-skill"); + fs.mkdirSync(skillDir); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: bad\n---\n# Bad", + ); + + const skills = discoverSkills(tmpDir); + expect(skills.size).toBe(0); + }); + + it("accepts lowercase skill.md", () => { + const skillDir = path.join(tmpDir, "lower"); + fs.mkdirSync(skillDir); + fs.writeFileSync( + path.join(skillDir, "skill.md"), + "---\nname: lower\ndescription: Lowercase skill file\n---\n# Lower", + ); + + const skills = discoverSkills(tmpDir); + expect(skills.size).toBe(1); + expect(skills.has("lower")).toBe(true); + }); + + it("discovers supplementary documents", () => { + const skillDir = createSkill("with-docs", "Has docs"); + + // Create supplementary files + const refsDir = path.join(skillDir, "references"); + fs.mkdirSync(refsDir); + fs.writeFileSync(path.join(refsDir, "REFERENCE.md"), "# Reference"); + fs.writeFileSync(path.join(skillDir, "config.json"), '{"key": "value"}'); + + const skills = discoverSkills(tmpDir); + const skill = skills.get("with-docs")!; + + expect(skill.documents.length).toBe(2); + const docPaths = skill.documents.map((d) => d.path).sort(); + expect(docPaths).toContain("config.json"); + expect(docPaths).toContain("references/REFERENCE.md"); + + // Manifest should include SKILL.md + docs + expect(skill.manifest.files.length).toBe(3); + }); + + it("extracts optional metadata", () => { + const skillDir = path.join(tmpDir, "meta-skill"); + fs.mkdirSync(skillDir); + fs.writeFileSync( + path.join(skillDir, "SKILL.md"), + "---\nname: meta-skill\ndescription: Has metadata\nmetadata:\n author: test\n version: '1.0'\n---\n# Meta", + ); + + const skills = discoverSkills(tmpDir); + const skill = skills.get("meta-skill")!; + expect(skill.metadata).toEqual({ author: "test", version: "1.0" }); + }); +}); + +describe("loadSkillContent", () => { + it("loads a SKILL.md file", () => { + createSkill("test-skill", "Test"); + const skillPath = path.join(tmpDir, "test-skill", "SKILL.md"); + + const content = loadSkillContent(skillPath, tmpDir); + expect(content).toContain("name: test-skill"); + expect(content).toContain("# test-skill"); + }); + + it("rejects non-.md files", () => { + fs.writeFileSync(path.join(tmpDir, "test.txt"), "hello"); + + expect(() => loadSkillContent(path.join(tmpDir, "test.txt"), tmpDir)).toThrow( + "Only .md files can be read", + ); + }); +}); + +describe("loadDocument", () => { + it("loads a text document", () => { + const skillDir = createSkill("doc-skill", "Has docs"); + fs.writeFileSync(path.join(skillDir, "notes.txt"), "Some notes"); + + const skills = discoverSkills(tmpDir); + const skill = skills.get("doc-skill")!; + + const result = loadDocument(skill, "notes.txt", tmpDir, true); + expect(result).toHaveProperty("text", "Some notes"); + }); + + it("loads a binary document as base64", () => { + const skillDir = createSkill("bin-skill", "Has binary"); + fs.writeFileSync(path.join(skillDir, "data.bin"), Buffer.from([1, 2, 3])); + + const skills = discoverSkills(tmpDir); + const skill = skills.get("bin-skill")!; + + const result = loadDocument(skill, "data.bin", tmpDir, false); + expect(result).toHaveProperty("blob"); + expect(Buffer.from((result as { blob: string }).blob, "base64")).toEqual( + Buffer.from([1, 2, 3]), + ); + }); + + it("rejects path traversal", () => { + createSkill("safe-skill", "Safe"); + const skills = discoverSkills(tmpDir); + const skill = skills.get("safe-skill")!; + + expect(() => loadDocument(skill, "../../../etc/passwd", tmpDir, true)).toThrow( + "Path traversal not allowed", + ); + }); + + it("rejects absolute paths", () => { + createSkill("safe-skill", "Safe"); + const skills = discoverSkills(tmpDir); + const skill = skills.get("safe-skill")!; + + expect(() => loadDocument(skill, "/etc/passwd", tmpDir, true)).toThrow( + "Absolute paths not allowed", + ); + }); +}); + +describe("isPathWithinBase", () => { + it("returns true for paths within base", () => { + const base = tmpDir; + const target = path.join(tmpDir, "subdir", "file.txt"); + fs.mkdirSync(path.join(tmpDir, "subdir"), { recursive: true }); + fs.writeFileSync(target, "test"); + + expect(isPathWithinBase(target, base)).toBe(true); + }); + + it("returns true for the base directory itself", () => { + expect(isPathWithinBase(tmpDir, tmpDir)).toBe(true); + }); + + it("handles non-existent paths with fallback", () => { + const base = tmpDir; + const target = path.join(tmpDir, "nonexistent", "file.txt"); + + // Falls back to resolve-based check + expect(isPathWithinBase(target, base)).toBe(true); + }); +}); + +describe("scanDocuments", () => { + it("scans files excluding SKILL.md", () => { + const skillDir = path.join(tmpDir, "scan-test"); + fs.mkdirSync(skillDir); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: x\ndescription: x\n---\n"); + fs.writeFileSync(path.join(skillDir, "extra.md"), "# Extra"); + fs.writeFileSync(path.join(skillDir, "data.json"), "{}"); + + const docs = scanDocuments(skillDir, tmpDir); + const paths = docs.map((d) => d.path).sort(); + + expect(paths).toEqual(["data.json", "extra.md"]); + expect(docs.find((d) => d.path === "data.json")!.mimeType).toBe( + "application/json", + ); + }); + + it("scans subdirectories recursively", () => { + const skillDir = path.join(tmpDir, "recursive-test"); + fs.mkdirSync(path.join(skillDir, "sub", "deep"), { recursive: true }); + fs.writeFileSync(path.join(skillDir, "SKILL.md"), "---\nname: x\ndescription: x\n---\n"); + fs.writeFileSync(path.join(skillDir, "sub", "file.txt"), "hello"); + fs.writeFileSync( + path.join(skillDir, "sub", "deep", "nested.md"), + "# Nested", + ); + + const docs = scanDocuments(skillDir, tmpDir); + const paths = docs.map((d) => d.path).sort(); + + expect(paths).toEqual(["sub/deep/nested.md", "sub/file.txt"]); + }); + + it("returns empty for non-existent directory", () => { + const docs = scanDocuments("/nonexistent", tmpDir); + expect(docs).toEqual([]); + }); +}); diff --git a/examples/skills-as-resources/typescript/src/skill-discovery.ts b/typescript/sdk/src/server.ts similarity index 56% rename from examples/skills-as-resources/typescript/src/skill-discovery.ts rename to typescript/sdk/src/server.ts index 97ce7ce..d507a31 100644 --- a/examples/skills-as-resources/typescript/src/skill-discovery.ts +++ b/typescript/sdk/src/server.ts @@ -1,5 +1,5 @@ /** - * Skill discovery, content loading, and document scanning module. + * Server-side skill discovery, content loading, and MCP resource registration. * * Discovers Agent Skills by scanning a directory for subdirectories * containing SKILL.md files, parses YAML frontmatter for metadata, @@ -14,8 +14,17 @@ import * as fs from "node:fs"; import * as path from "node:path"; import * as crypto from "node:crypto"; import { parse as parseYaml } from "yaml"; -import type { SkillMetadata, SkillDocument, SkillManifest } from "./types.js"; -import { getMimeType } from "./resource-helpers.js"; +import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + SkillMetadata, + SkillDocument, + SkillManifest, + RegisterSkillResourcesOptions, + SkillResourceHandles, +} from "./types.js"; +import { getMimeType, isTextMimeType } from "./mime.js"; +import { generateSkillsXML } from "./xml.js"; /** Maximum file size for skill files (1MB). */ const MAX_FILE_SIZE = 1 * 1024 * 1024; @@ -62,7 +71,7 @@ function parseFrontmatter(content: string): { */ export function isPathWithinBase( targetPath: string, - baseDir: string + baseDir: string, ): boolean { try { const realBase = fs.realpathSync(baseDir); @@ -84,7 +93,7 @@ export function isPathWithinBase( function scanDir( dirPath: string, relativeTo: string, - baseDir: string + baseDir: string, ): SkillDocument[] { const documents: SkillDocument[] = []; @@ -108,7 +117,9 @@ function scanDir( const stat = fs.statSync(fullPath); if (stat.size > MAX_FILE_SIZE) continue; - const relativePath = path.relative(relativeTo, fullPath).replace(/\\/g, "/"); + const relativePath = path + .relative(relativeTo, fullPath) + .replace(/\\/g, "/"); documents.push({ path: relativePath, mimeType: getMimeType(entry.name), @@ -131,11 +142,10 @@ function scanDir( * Scan a skill directory for all supplementary files. * Finds all files in the skill directory (including root-level files * and subdirectories), excluding SKILL.md / skill.md itself. - * This matches skillsdotnet's behavior of including all files recursively. */ export function scanDocuments( skillDir: string, - baseDir: string + baseDir: string, ): SkillDocument[] { const documents: SkillDocument[] = []; @@ -146,24 +156,23 @@ export function scanDocuments( return documents; } - // Skip the main skill file names const skipFiles = new Set(["SKILL.md", "skill.md"]); for (const entry of entries) { const fullPath = path.join(skillDir, entry.name); if (entry.isDirectory()) { - // Recurse into subdirectories documents.push(...scanDir(fullPath, skillDir, baseDir)); } else if (entry.isFile() && !skipFiles.has(entry.name)) { - // Include root-level files (excluding SKILL.md) if (!isPathWithinBase(fullPath, baseDir)) continue; try { const stat = fs.statSync(fullPath); if (stat.size > MAX_FILE_SIZE) continue; - const relativePath = path.relative(skillDir, fullPath).replace(/\\/g, "/"); + const relativePath = path + .relative(skillDir, fullPath) + .replace(/\\/g, "/"); documents.push({ path: relativePath, mimeType: getMimeType(entry.name), @@ -186,7 +195,9 @@ export function scanDocuments( * * Security: Skips files larger than MAX_FILE_SIZE, validates frontmatter fields. */ -export function discoverSkills(skillsDir: string): Map { +export function discoverSkills( + skillsDir: string, +): Map { const skillMap = new Map(); const resolvedDir = path.resolve(skillsDir); @@ -218,7 +229,7 @@ export function discoverSkills(skillsDir: string): Map { const stat = fs.statSync(skillMdPath); if (stat.size > MAX_FILE_SIZE) { console.error( - `Skipping ${skillMdPath}: file size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds limit` + `Skipping ${skillMdPath}: file size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds limit`, ); continue; } @@ -242,19 +253,16 @@ export function discoverSkills(skillsDir: string): Map { } if (typeof description !== "string" || !description.trim()) { console.error( - `Skill at ${skillDir}: missing or invalid 'description' field` + `Skill at ${skillDir}: missing or invalid 'description' field`, ); continue; } // Extract optional metadata fields const metadata: Record = {}; - if ( - frontmatter.metadata && - typeof frontmatter.metadata === "object" - ) { + if (frontmatter.metadata && typeof frontmatter.metadata === "object") { for (const [k, v] of Object.entries( - frontmatter.metadata as Record + frontmatter.metadata as Record, )) { if (typeof v === "string") { metadata[k] = v; @@ -265,7 +273,7 @@ export function discoverSkills(skillsDir: string): Map { const trimmedName = name.trim(); if (skillMap.has(trimmedName)) { console.error( - `Warning: Duplicate skill name "${trimmedName}" at ${skillMdPath} — keeping first` + `Warning: Duplicate skill name "${trimmedName}" at ${skillMdPath} — keeping first`, ); continue; } @@ -315,23 +323,20 @@ export function discoverSkills(skillsDir: string): Map { */ export function loadSkillContent( skillPath: string, - skillsDir: string + skillsDir: string, ): string { - // Security: only allow .md files if (!skillPath.endsWith(".md")) { throw new Error("Only .md files can be read"); } - // Security: verify path is within skills directory if (!isPathWithinBase(skillPath, skillsDir)) { throw new Error("Path escapes the skills directory"); } - // Security: check file size const stat = fs.statSync(skillPath); if (stat.size > MAX_FILE_SIZE) { throw new Error( - `File size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB limit` + `File size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB limit`, ); } @@ -340,40 +345,35 @@ export function loadSkillContent( /** * Load a supplementary document from a skill directory. - * Returns text content (string) for text MIME types and - * base64-encoded content for binary MIME types. + * Returns text content for text MIME types and base64-encoded content for binary. * - * Security: Validates that the path is within the skills directory, - * rejects path traversal attempts, and enforces a file size limit. + * Security: Validates path within skills directory, rejects path traversal, + * enforces file size limit. */ export function loadDocument( skill: SkillMetadata, documentPath: string, skillsDir: string, - isText: boolean + isText: boolean, ): { text: string } | { blob: string } { - // Security: reject path traversal attempts if (documentPath.includes("..")) { throw new Error("Path traversal not allowed"); } - // Security: reject absolute paths if (path.isAbsolute(documentPath)) { throw new Error("Absolute paths not allowed"); } const fullPath = path.join(skill.skillDir, documentPath); - // Security: verify path is within skills directory if (!isPathWithinBase(fullPath, skillsDir)) { throw new Error("Path escapes the skills directory"); } - // Security: check file size const stat = fs.statSync(fullPath); if (stat.size > MAX_FILE_SIZE) { throw new Error( - `File size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB limit` + `File size ${(stat.size / 1024 / 1024).toFixed(2)}MB exceeds ${(MAX_FILE_SIZE / 1024 / 1024).toFixed(0)}MB limit`, ); } @@ -383,3 +383,214 @@ export function loadDocument( return { blob: fs.readFileSync(fullPath).toString("base64") }; } } + +/** + * Register MCP resources for all discovered skills on an McpServer. + * + * Registers per-skill: + * - skill://{name}/SKILL.md — skill content (listed resource) + * - skill://{name}/_manifest — file manifest (listed resource) + * + * Optionally registers: + * - skill://{name}/{+path} — resource template for supporting files + * - skill://prompt-xml — XML for system prompt injection + * + * Returns a map of skill name → resource handles for later removal. + */ +export function registerSkillResources( + server: McpServer, + skillMap: Map, + skillsDir: string, + options?: RegisterSkillResourcesOptions, +): SkillResourceHandles { + const handles: SkillResourceHandles = new Map(); + const { template = true, promptXml = false } = options ?? {}; + + // Register per-skill resources + for (const [name, skill] of skillMap) { + const skillHandle = server.registerResource( + `skill-${name}`, + `skill://${name}/SKILL.md`, + { + description: skill.description, + mimeType: "text/markdown", + annotations: { + audience: ["user", "assistant"], + priority: 1.0, + lastModified: skill.lastModified, + }, + }, + // eslint-disable-next-line @typescript-eslint/require-await + async (uri) => { + try { + const content = loadSkillContent(skill.path, skillsDir); + return { + contents: [{ uri: uri.href, text: content }], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nFailed to load skill "${name}": ${message}`, + }, + ], + }; + } + }, + ); + + const manifestHandle = server.registerResource( + `skill-${name}-manifest`, + `skill://${name}/_manifest`, + { + description: `File manifest for skill '${name}' with content hashes`, + mimeType: "application/json", + annotations: { + audience: ["user", "assistant"], + priority: 0.5, + lastModified: skill.lastModified, + }, + }, + // eslint-disable-next-line @typescript-eslint/require-await + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: skill.manifestJson, + }, + ], + }), + ); + + handles.set(name, { skill: skillHandle, manifest: manifestHandle }); + } + + // Resource template for supporting files + if (template) { + server.registerResource( + "skill-file", + new ResourceTemplate("skill://{skillName}/{+path}", { + list: undefined, + complete: { + skillName: (value) => { + return Array.from(skillMap.values()) + .filter((s) => s.documents.length > 0) + .map((s) => s.name) + .filter((n) => n.startsWith(value)); + }, + path: (value, context) => { + const skillName = context?.arguments?.skillName; + if (!skillName) return []; + + const skill = skillMap.get(skillName); + if (!skill) return []; + + return skill.documents + .map((d) => d.path) + .filter((p) => p.startsWith(value)); + }, + }, + }), + { + description: "Fetch a supporting file from a skill directory", + mimeType: "text/plain", + annotations: { + audience: ["user", "assistant"], + priority: 0.2, + }, + }, + // eslint-disable-next-line @typescript-eslint/require-await + async (uri, variables) => { + const skillName = Array.isArray(variables.skillName) + ? variables.skillName[0] + : variables.skillName; + const filePath = Array.isArray(variables.path) + ? variables.path[0] + : variables.path; + + const skill = skillMap.get(skillName); + if (!skill) { + const names = Array.from(skillMap.keys()).join(", ") || "none"; + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nSkill "${skillName}" not found. Available: ${names}`, + }, + ], + }; + } + + const doc = skill.documents.find((d) => d.path === filePath); + if (!doc) { + const available = + skill.documents.map((d) => `- ${d.path}`).join("\n"); + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nFile "${filePath}" not found in skill "${skillName}".\n\n## Available Files\n\n${available || "No supporting files available."}`, + }, + ], + }; + } + + try { + const isText = isTextMimeType(doc.mimeType); + const content = loadDocument(skill, filePath, skillsDir, isText); + return { + contents: [ + { + uri: uri.href, + mimeType: doc.mimeType, + ...content, + }, + ], + }; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + return { + contents: [ + { + uri: uri.href, + text: `# Error\n\nFailed to read file: ${message}`, + }, + ], + }; + } + }, + ); + } + + // Optional prompt-xml convenience resource + if (promptXml) { + server.registerResource( + "skills-prompt-xml", + "skill://prompt-xml", + { + description: + "XML representation of available skills for injecting into system prompts", + mimeType: "application/xml", + annotations: { + audience: ["user", "assistant"], + priority: 0.3, + }, + }, + // eslint-disable-next-line @typescript-eslint/require-await + async (uri) => ({ + contents: [ + { + uri: uri.href, + text: generateSkillsXML(skillMap), + }, + ], + }), + ); + } + + return handles; +} diff --git a/examples/skills-as-resources/typescript/src/types.ts b/typescript/sdk/src/types.ts similarity index 66% rename from examples/skills-as-resources/typescript/src/types.ts rename to typescript/sdk/src/types.ts index 32395d9..6aa2328 100644 --- a/examples/skills-as-resources/typescript/src/types.ts +++ b/typescript/sdk/src/types.ts @@ -1,5 +1,5 @@ /** - * Type definitions for the Skills as Resources reference implementation. + * Type definitions for the Skills as Resources SDK. * * URI scheme aligned with skillsdotnet conventions: * - skill://{name}/SKILL.md — listed resource for skill content @@ -12,6 +12,8 @@ * - SkillsDotNet by Brad Wilson (https://github.com/bradwilson/skillsdotnet) */ +import type { RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; + /** * A file entry in the skill manifest, including content hash. * Used in the skill://{name}/_manifest resource. @@ -65,3 +67,39 @@ export interface SkillMetadata { manifestJson: string; // Pre-serialized manifest JSON (avoids I/O on request) lastModified: string; // ISO 8601 timestamp from SKILL.md file mtime } + +/** + * Lightweight client-side summary of a discovered skill. + * Built from resources/list results and optional frontmatter parsing. + */ +export interface SkillSummary { + /** Skill name (parsed from URI or frontmatter) */ + name: string; + /** Full skill:// URI for the SKILL.md resource */ + uri: string; + /** Skill description (from resource metadata or frontmatter) */ + description?: string; + /** MIME type of the resource */ + mimeType?: string; +} + +/** + * Options for registerSkillResources(). + */ +export interface RegisterSkillResourcesOptions { + /** Register the resource template for supporting files (skill://{name}/{+path}). Default: true */ + template?: boolean; + /** Register the skill://prompt-xml convenience resource. Default: false */ + promptXml?: boolean; +} + +/** + * Return type for registerSkillResources() — maps skill name to resource handles. + */ +export type SkillResourceHandles = Map< + string, + { + skill: RegisteredResource; + manifest: RegisteredResource; + } +>; diff --git a/typescript/sdk/src/uri.test.ts b/typescript/sdk/src/uri.test.ts new file mode 100644 index 0000000..024e8a5 --- /dev/null +++ b/typescript/sdk/src/uri.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from "vitest"; +import { + parseSkillUri, + buildSkillUri, + isSkillContentUri, + isSkillManifestUri, + SKILL_FILENAME, + MANIFEST_PATH, +} from "./uri.js"; + +describe("parseSkillUri", () => { + it("parses a SKILL.md URI", () => { + const result = parseSkillUri("skill://code-review/SKILL.md"); + expect(result).toEqual({ name: "code-review", path: "SKILL.md" }); + }); + + it("parses a manifest URI", () => { + const result = parseSkillUri("skill://test-writer/_manifest"); + expect(result).toEqual({ name: "test-writer", path: "_manifest" }); + }); + + it("parses a nested path URI", () => { + const result = parseSkillUri( + "skill://my-skill/references/REFERENCE.md", + ); + expect(result).toEqual({ + name: "my-skill", + path: "references/REFERENCE.md", + }); + }); + + it("returns null for non-skill URIs", () => { + expect(parseSkillUri("https://example.com")).toBeNull(); + expect(parseSkillUri("file:///tmp/test")).toBeNull(); + expect(parseSkillUri("")).toBeNull(); + }); + + it("returns null for skill:// without a path", () => { + expect(parseSkillUri("skill://code-review")).toBeNull(); + }); + + it("returns null for skill://prompt-xml (no path segment)", () => { + expect(parseSkillUri("skill://prompt-xml")).toBeNull(); + }); +}); + +describe("buildSkillUri", () => { + it("builds a SKILL.md URI by default", () => { + expect(buildSkillUri("code-review")).toBe( + "skill://code-review/SKILL.md", + ); + }); + + it("builds a URI with custom path", () => { + expect(buildSkillUri("my-skill", "_manifest")).toBe( + "skill://my-skill/_manifest", + ); + }); + + it("builds a URI with nested path", () => { + expect(buildSkillUri("my-skill", "refs/doc.md")).toBe( + "skill://my-skill/refs/doc.md", + ); + }); +}); + +describe("isSkillContentUri", () => { + it("returns true for SKILL.md URIs", () => { + expect(isSkillContentUri("skill://code-review/SKILL.md")).toBe(true); + }); + + it("returns false for manifest URIs", () => { + expect(isSkillContentUri("skill://code-review/_manifest")).toBe(false); + }); + + it("returns false for non-skill URIs", () => { + expect(isSkillContentUri("https://example.com")).toBe(false); + }); +}); + +describe("isSkillManifestUri", () => { + it("returns true for manifest URIs", () => { + expect(isSkillManifestUri("skill://code-review/_manifest")).toBe(true); + }); + + it("returns false for SKILL.md URIs", () => { + expect(isSkillManifestUri("skill://code-review/SKILL.md")).toBe(false); + }); + + it("returns false for non-skill URIs", () => { + expect(isSkillManifestUri("https://example.com")).toBe(false); + }); +}); + +describe("constants", () => { + it("exports SKILL_FILENAME", () => { + expect(SKILL_FILENAME).toBe("SKILL.md"); + }); + + it("exports MANIFEST_PATH", () => { + expect(MANIFEST_PATH).toBe("_manifest"); + }); +}); diff --git a/typescript/sdk/src/uri.ts b/typescript/sdk/src/uri.ts new file mode 100644 index 0000000..4e237e5 --- /dev/null +++ b/typescript/sdk/src/uri.ts @@ -0,0 +1,54 @@ +/** + * URI parsing and building utilities for skill:// URIs. + * + * URI scheme: + * - skill://{name}/SKILL.md — skill content + * - skill://{name}/_manifest — file manifest + * - skill://{name}/{+path} — supporting files + * - skill://prompt-xml — system prompt XML (optional) + */ + +/** Default skill content filename. */ +export const SKILL_FILENAME = "SKILL.md"; + +/** Manifest pseudo-path. */ +export const MANIFEST_PATH = "_manifest"; + +/** Regex to parse skill:// URIs into name and path components. */ +const SKILL_URI_REGEX = /^skill:\/\/([^/]+)\/(.+)$/; + +/** + * Parse a skill:// URI into its name and path components. + * Returns null if the URI doesn't match the skill:// scheme. + */ +export function parseSkillUri( + uri: string, +): { name: string; path: string } | null { + const match = uri.match(SKILL_URI_REGEX); + if (!match) return null; + return { name: match[1], path: match[2] }; +} + +/** + * Build a skill:// URI from a skill name and optional path. + * Defaults to SKILL.md if no path is provided. + */ +export function buildSkillUri(name: string, path?: string): string { + return `skill://${name}/${path ?? SKILL_FILENAME}`; +} + +/** + * Check if a URI points to a skill's SKILL.md content. + */ +export function isSkillContentUri(uri: string): boolean { + const parsed = parseSkillUri(uri); + return parsed !== null && parsed.path === SKILL_FILENAME; +} + +/** + * Check if a URI points to a skill's _manifest. + */ +export function isSkillManifestUri(uri: string): boolean { + const parsed = parseSkillUri(uri); + return parsed !== null && parsed.path === MANIFEST_PATH; +} diff --git a/typescript/sdk/src/xml.test.ts b/typescript/sdk/src/xml.test.ts new file mode 100644 index 0000000..d4a61d5 --- /dev/null +++ b/typescript/sdk/src/xml.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect } from "vitest"; +import { + escapeXml, + generateSkillsXML, + generateSkillsXMLFromSummaries, +} from "./xml.js"; +import type { SkillMetadata, SkillSummary } from "./types.js"; + +describe("escapeXml", () => { + it("escapes ampersand", () => { + expect(escapeXml("A & B")).toBe("A & B"); + }); + + it("escapes angle brackets", () => { + expect(escapeXml("")).toBe("<tag>"); + }); + + it("escapes quotes", () => { + expect(escapeXml('"hello" & \'world\'')).toBe( + ""hello" & 'world'", + ); + }); + + it("returns plain text unchanged", () => { + expect(escapeXml("hello world")).toBe("hello world"); + }); +}); + +function makeSkillMetadata( + name: string, + description: string, +): SkillMetadata { + return { + name, + description, + path: `/skills/${name}/SKILL.md`, + skillDir: `/skills/${name}`, + documents: [], + manifest: { skill: name, files: [] }, + manifestJson: "{}", + lastModified: "2025-01-01T00:00:00.000Z", + }; +} + +describe("generateSkillsXML", () => { + it("generates XML for an empty map", () => { + const result = generateSkillsXML(new Map()); + expect(result).toBe( + "\n", + ); + }); + + it("generates XML for a single skill", () => { + const map = new Map(); + map.set("code-review", makeSkillMetadata("code-review", "Review code")); + + const result = generateSkillsXML(map); + expect(result).toContain("code-review"); + expect(result).toContain("Review code"); + expect(result).toContain( + "skill://code-review/SKILL.md", + ); + expect(result).toMatch( + /^\n.*<\/available_skills>$/s, + ); + }); + + it("generates XML for multiple skills", () => { + const map = new Map(); + map.set("skill-a", makeSkillMetadata("skill-a", "Description A")); + map.set("skill-b", makeSkillMetadata("skill-b", "Description B")); + + const result = generateSkillsXML(map); + expect(result).toContain("skill-a"); + expect(result).toContain("skill-b"); + }); + + it("escapes special characters in skill data", () => { + const map = new Map(); + map.set( + "special", + makeSkillMetadata("special", 'A "skill" with & more'), + ); + + const result = generateSkillsXML(map); + expect(result).toContain( + "A "skill" with <tags> & more", + ); + }); +}); + +describe("generateSkillsXMLFromSummaries", () => { + it("generates XML for an empty array", () => { + const result = generateSkillsXMLFromSummaries([]); + expect(result).toBe( + "\n", + ); + }); + + it("generates XML with description", () => { + const skills: SkillSummary[] = [ + { + name: "test-writer", + uri: "skill://test-writer/SKILL.md", + description: "Write tests", + }, + ]; + + const result = generateSkillsXMLFromSummaries(skills); + expect(result).toContain("test-writer"); + expect(result).toContain("Write tests"); + expect(result).toContain( + "skill://test-writer/SKILL.md", + ); + }); + + it("generates XML without description", () => { + const skills: SkillSummary[] = [ + { + name: "basic", + uri: "skill://basic/SKILL.md", + }, + ]; + + const result = generateSkillsXMLFromSummaries(skills); + expect(result).toContain("basic"); + expect(result).not.toContain(""); + expect(result).toContain("skill://basic/SKILL.md"); + }); +}); diff --git a/typescript/sdk/src/xml.ts b/typescript/sdk/src/xml.ts new file mode 100644 index 0000000..e01b419 --- /dev/null +++ b/typescript/sdk/src/xml.ts @@ -0,0 +1,83 @@ +/** + * XML generation utilities for system prompt injection. + * + * Provides functions to generate XML from both + * server-side SkillMetadata maps and client-side SkillSummary arrays. + * + * Inspired by: + * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) + */ + +import type { SkillMetadata, SkillSummary } from "./types.js"; + +/** + * Escape XML special characters. + */ +export function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +/** + * Generate XML from a server-side skill map. + * + * Format: + * ```xml + * + * + * code-review + * Perform structured code reviews... + * skill://code-review/SKILL.md + * + * + * ``` + */ +export function generateSkillsXML( + skillMap: Map, +): string { + const lines: string[] = [""]; + + for (const skill of skillMap.values()) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + lines.push( + ` ${escapeXml(skill.description)}`, + ); + lines.push(` skill://${escapeXml(skill.name)}/SKILL.md`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} + +/** + * Generate XML from client-side SkillSummary array. + * + * Same format as generateSkillsXML but works with the lightweight + * SkillSummary type used on the client side. + */ +export function generateSkillsXMLFromSummaries( + skills: SkillSummary[], +): string { + const lines: string[] = [""]; + + for (const skill of skills) { + lines.push(" "); + lines.push(` ${escapeXml(skill.name)}`); + if (skill.description) { + lines.push( + ` ${escapeXml(skill.description)}`, + ); + } + lines.push(` ${escapeXml(skill.uri)}`); + lines.push(" "); + } + + lines.push(""); + return lines.join("\n"); +} diff --git a/typescript/sdk/tsconfig.json b/typescript/sdk/tsconfig.json new file mode 100644 index 0000000..466fb0f --- /dev/null +++ b/typescript/sdk/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/typescript/sdk/vitest.config.js b/typescript/sdk/vitest.config.js new file mode 100644 index 0000000..ae847ff --- /dev/null +++ b/typescript/sdk/vitest.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); From 825cf6bcba0fbbfc83760e367909fd47941873cd Mon Sep 17 00:00:00 2001 From: olaservo Date: Fri, 27 Feb 2026 07:26:09 -0700 Subject: [PATCH 20/21] Update example README to reference SDK instead of pseudocode sketch The SDK integration section now shows real imports from @ext-modelcontextprotocol/skills and documents the build order (SDK first, then example). Co-Authored-By: Claude Opus 4.6 --- examples/skills-as-resources/README.md | 38 ++++++++++++++++++++------ 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 2e22759..926cd9c 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -44,8 +44,18 @@ This is a **resources-only** server. Clients are expected to provide their own ` **Prerequisites**: Node.js >= 18, npm +The example depends on the [`@ext-modelcontextprotocol/skills`](../../../typescript/sdk/) SDK (linked via `file:` reference). Build the SDK first: + +```bash +cd ../../../typescript/sdk +npm install +npm run build +``` + +Then build the example: + ```bash -cd typescript +cd examples/skills-as-resources/typescript npm install npm run build ``` @@ -126,17 +136,27 @@ This server exposes skills as resources only — it does **not** include server- 4. **Subscribe to changes** (optional): Call `resources/subscribe` on skill URIs to receive `notifications/resources/updated` when files change on disk. Re-enumerate on `notifications/resources/list_changed`. 5. **Use `skill://prompt-xml`** (optional): Read the `skill://prompt-xml` resource for pre-built `` XML suitable for system prompt injection, as an alternative to building your own summaries from step 1. -### SDK integration sketch +### SDK integration + +The [`@ext-modelcontextprotocol/skills`](../../../typescript/sdk/) SDK provides ready-made helpers for both server and client sides. This example uses the SDK — see [`src/index.ts`](typescript/src/index.ts) for the server-side usage. + +**Server-side** (skill discovery + resource registration): +```typescript +import { discoverSkills, registerSkillResources } from "@ext-modelcontextprotocol/skills"; -A client SDK might expose a helper like: +const skillMap = discoverSkills("./skills"); +const handles = registerSkillResources(server, skillMap, "./skills", { + template: true, + promptXml: true, +}); +``` +**Client-side** (skill enumeration + context building): ```typescript -// Pseudocode — not a real SDK method (yet) -const skills = await client.listResources() - .then(({ resources }) => resources - .filter(r => r.uri.match(/^skill:\/\/[^/]+\/SKILL\.md$/)) - .map(r => ({ uri: r.uri, name: r.name, description: r.description })) - ); +import { listSkillResources, buildSkillsSummary } from "@ext-modelcontextprotocol/skills"; + +const skills = await listSkillResources(client); +console.log(buildSkillsSummary(skills)); ``` See [PR #16 discussion](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/16#discussion_r2859600003) for the full rationale on why client-side `read_resource` is preferred over server-side `load_skill` tools. From 04a88a87943a9f1738ba0221b5475e1dff33d51f Mon Sep 17 00:00:00 2001 From: olaservo Date: Wed, 11 Mar 2026 07:24:00 -0700 Subject: [PATCH 21/21] Refactor SDK to enhance resource handling and update documentation - Introduced `READ_RESOURCE_TOOL` for client-side resource access. - Updated README and example files to reflect changes in resource handling. - Removed unnecessary manifest JSON serialization from skill discovery. - Cleaned up code comments and documentation for clarity. --- docs/open-questions.md | 2 + examples/sample-skills/code-review/SKILL.md | 2 +- examples/skills-as-resources/README.md | 29 ++++- .../typescript/src/index.ts | 5 - typescript/sdk/README.md | 43 +++--- typescript/sdk/src/client.ts | 123 +++++++++++++++++- typescript/sdk/src/index.ts | 10 +- typescript/sdk/src/server.ts | 9 +- typescript/sdk/src/types.ts | 8 +- typescript/sdk/src/xml.test.ts | 1 - typescript/sdk/src/xml.ts | 3 - 11 files changed, 179 insertions(+), 56 deletions(-) diff --git a/docs/open-questions.md b/docs/open-questions.md index 29d2687..71a0b26 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -106,6 +106,8 @@ Note: Some apps like Claude Code have started to indicate in the skill frontmatt See also [Approaches](approaches.md) for more notes on using resources. +**URI scheme:** The `skill://{name}/SKILL.md` URI convention was proposed in [PR #53](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/53). + ## 13. What is the optimal relationship between skills and MCP? Skills already work as simple files that agents load directly. Adding MCP to the process should provide clear value beyond what standalone skills already offer. diff --git a/examples/sample-skills/code-review/SKILL.md b/examples/sample-skills/code-review/SKILL.md index 123dc3a..953b927 100644 --- a/examples/sample-skills/code-review/SKILL.md +++ b/examples/sample-skills/code-review/SKILL.md @@ -34,7 +34,7 @@ Perform structured code reviews using a consistent methodology. ## Reference -For a detailed checklist, see `references/REFERENCE.md` in this skill's directory. +For a detailed checklist, see `references/REFERENCE.md`. ## Output Format diff --git a/examples/skills-as-resources/README.md b/examples/skills-as-resources/README.md index 926cd9c..fbf1940 100644 --- a/examples/skills-as-resources/README.md +++ b/examples/skills-as-resources/README.md @@ -131,8 +131,8 @@ This server exposes skills as resources only — it does **not** include server- ### Recommended client behavior 1. **Enumerate skills**: Call `resources/list` and filter for URIs matching `skill://*/SKILL.md`. Each matching resource's `description` field contains the skill summary from frontmatter. -2. **Build context summaries**: Load skill metadata (name + description) into the system prompt so the model knows which skills are available (~50-100 tokens per skill). -3. **Provide a `read_resource` tool**: Expose a client-side tool that maps internally to the MCP client SDK's `readResource()` call. This lets the model load full skill content on demand. Example system prompt note: *"Use the `read_resource` tool to load MCP-based skills by their `skill://` URI."* +2. **Build context summaries**: Load skill metadata (name + description) into the system prompt so the model knows which skills are available. +3. **Provide a `read_resource` tool**: Expose a client-side tool that lets the model load skill content on demand. Some clients (such as Claude Code) provide a built-in `read_resource` tool taking `(uri, server_name)` parameters, routing each call to the correct MCP server. For other clients, the SDK exports a `READ_RESOURCE_TOOL` schema matching this pattern — register and wire the handler to the MCP client SDK's `readResource()` call. This SDK extension also provides typed helpers: `readSkillContent()`, `readSkillManifest()`, and `readSkillDocument()`. 4. **Subscribe to changes** (optional): Call `resources/subscribe` on skill URIs to receive `notifications/resources/updated` when files change on disk. Re-enumerate on `notifications/resources/list_changed`. 5. **Use `skill://prompt-xml`** (optional): Read the `skill://prompt-xml` resource for pre-built `` XML suitable for system prompt injection, as an alternative to building your own summaries from step 1. @@ -151,12 +151,31 @@ const handles = registerSkillResources(server, skillMap, "./skills", { }); ``` -**Client-side** (skill enumeration + context building): +**Client-side** (skill enumeration, context building, and reading): ```typescript -import { listSkillResources, buildSkillsSummary } from "@ext-modelcontextprotocol/skills"; - +import { + READ_RESOURCE_TOOL, + listSkillResources, + readSkillContent, + readSkillManifest, + readSkillDocument, + buildSkillsSummary, +} from "@ext-modelcontextprotocol/skills"; + +// Enumerate and summarize skills const skills = await listSkillResources(client); console.log(buildSkillsSummary(skills)); + +// Register read_resource tool +registerTool(READ_RESOURCE_TOOL, async (params) => { + const client = getClientForServer(params.server_name); + return client.readResource({ uri: params.uri }); +}); + +// Or use typed helpers directly +const content = await readSkillContent(client, "code-review"); +const manifest = await readSkillManifest(client, "code-review"); +const doc = await readSkillDocument(client, "code-review", "references/REFERENCE.md"); ``` See [PR #16 discussion](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/16#discussion_r2859600003) for the full rationale on why client-side `read_resource` is preferred over server-side `load_skill` tools. diff --git a/examples/skills-as-resources/typescript/src/index.ts b/examples/skills-as-resources/typescript/src/index.ts index fcc8f4a..377b268 100644 --- a/examples/skills-as-resources/typescript/src/index.ts +++ b/examples/skills-as-resources/typescript/src/index.ts @@ -17,11 +17,6 @@ * - Parse frontmatter for name + description to build context summaries * - Provide a read_resource tool so the model can load skills on demand * - * Inspired by: - * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) - * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) - * - SkillsDotNet by Peder Holdgaard Pedersen (https://github.com/pederhp/skillsdotnet) - * * @license Apache-2.0 */ diff --git a/typescript/sdk/README.md b/typescript/sdk/README.md index 9e7ca71..0abc593 100644 --- a/typescript/sdk/README.md +++ b/typescript/sdk/README.md @@ -106,14 +106,7 @@ const xml = generateSkillsXMLFromSummaries(skills); ### Server ```typescript -import { - discoverSkills, - registerSkillResources, - loadSkillContent, - loadDocument, - scanDocuments, - isPathWithinBase, -} from "@ext-modelcontextprotocol/skills"; +import { discoverSkills, registerSkillResources } from "@ext-modelcontextprotocol/skills"; // Discover all skills in a directory const skillMap = discoverSkills("./skills"); @@ -123,19 +116,17 @@ const handles = registerSkillResources(server, skillMap, "./skills", { template: true, // Register resource template for supporting files (default: true) promptXml: false, // Register skill://prompt-xml resource (default: false) }); - -// Load skill content with security checks -const content = loadSkillContent(skill.path, skillsDir); - -// Load supplementary documents -const doc = loadDocument(skill, "references/REFERENCE.md", skillsDir, true); ``` ### Client ```typescript import { + READ_RESOURCE_TOOL, listSkillResources, + readSkillContent, + readSkillManifest, + readSkillDocument, parseSkillFrontmatter, buildSkillsSummary, } from "@ext-modelcontextprotocol/skills"; @@ -149,8 +140,31 @@ const meta = parseSkillFrontmatter(content); // Build plain-text summary for context injection const summary = buildSkillsSummary(skills); + +// Read skill content, manifest, and supporting files +const skillContent = await readSkillContent(client, "code-review"); +const manifest = await readSkillManifest(client, "code-review"); +const doc = await readSkillDocument(client, "code-review", "references/REFERENCE.md"); ``` +### Tool Schema + +The SDK exports a `READ_RESOURCE_TOOL` constant — an MCP `Tool` definition matching the pattern used by Claude Code's built-in `read_resource` tool: `(uri, server_name)`. + +Some clients provide this tool natively. For other clients, register the schema and wire the handler to route calls to the appropriate MCP Client: + +```typescript +import { READ_RESOURCE_TOOL } from "@ext-modelcontextprotocol/skills"; + +// Register with your AI provider (pseudocode) +registerTool(READ_RESOURCE_TOOL, async (params) => { + const client = getClientForServer(params.server_name); + return client.readResource({ uri: params.uri }); +}); +``` + +See [PR #53](https://github.com/modelcontextprotocol/experimental-ext-skills/pull/53) for the URI scheme discussion and rationale. + ## URI Scheme | Pattern | Description | @@ -162,7 +176,6 @@ const summary = buildSkillsSummary(skills); ## Future Work (TODO) -- `read_resource` tool factory for client-side model access - Subscription manager for resource change notifications - File watcher for dynamic skill hot-reload - Caching layer for skill content diff --git a/typescript/sdk/src/client.ts b/typescript/sdk/src/client.ts index acaf371..5fc9d07 100644 --- a/typescript/sdk/src/client.ts +++ b/typescript/sdk/src/client.ts @@ -1,15 +1,69 @@ /** - * Client-side utilities for discovering and summarizing skills + * Client-side utilities for discovering, reading, and summarizing skills * exposed as MCP resources by a skills server. * - * These functions help MCP clients enumerate available skills, - * parse frontmatter from skill content, and build context summaries - * for injection into system prompts or model context. + * Each MCP Client instance is inherently server-scoped — it represents a + * connection to a single MCP server. This is the architectural basis for + * excluding server names from skill:// URIs: disambiguation happens at + * the call site, not in the URI. Claude Code's built-in read_resource + * tool follows this pattern with (uri, server_name) parameters, routing + * each call to the correct Client instance. + * + * See: https://github.com/modelcontextprotocol/experimental-ext-skills/pull/53 */ import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import type { SkillSummary } from "./types.js"; -import { parseSkillUri, SKILL_FILENAME } from "./uri.js"; +import type { Tool } from "@modelcontextprotocol/sdk/types.js"; +import type { SkillManifest, SkillSummary } from "./types.js"; +import { buildSkillUri, MANIFEST_PATH, parseSkillUri, SKILL_FILENAME } from "./uri.js"; + +/** + * MCP Tool definition for a generic read_resource tool. + * + * The model calls read_resource(uri, server_name) and the host routes + * to the correct MCP Client instance based on server_name. + * + * Clients should register this tool with their AI provider and wire the + * handler to route calls to the appropriate Client's readResource() method. + * + * Example wiring (pseudocode): + * ```typescript + * registerTool(READ_RESOURCE_TOOL, async (params) => { + * const client = getClientForServer(params.server_name); + * return client.readResource({ uri: params.uri }); + * }); + * ``` + * + * Note: Some clients this tool natively — this schema is for + * other clients that need to expose read_resource to the model. + * + * See: https://github.com/modelcontextprotocol/experimental-ext-skills/pull/53 + */ +export const READ_RESOURCE_TOOL: Tool = { + name: "read_resource", + description: + "Read a resource from an MCP server by its URI. " + + "Use this to load skill content, manifests, and supporting files.", + inputSchema: { + type: "object", + properties: { + uri: { + type: "string", + description: "The resource URI (e.g., skill://code-review/SKILL.md)", + }, + server_name: { + type: "string", + description: "The name of the MCP server that provides this resource", + }, + }, + required: ["uri", "server_name"], + }, + annotations: { + readOnlyHint: true, + idempotentHint: true, + openWorldHint: false, + }, +}; /** * List all skill resources available from an MCP client. @@ -98,3 +152,60 @@ export function buildSkillsSummary(skills: SkillSummary[]): string { } return lines.join("\n"); } + +/** + * Read a skill's SKILL.md content from an MCP server. + * + * Constructs the skill:// URI and calls client.readResource(). + * Returns the full SKILL.md text including YAML frontmatter. + */ +export async function readSkillContent( + client: Client, + skillName: string, +): Promise { + const uri = buildSkillUri(skillName); + const result = await client.readResource({ uri }); + const content = result.contents[0]; + if (content && "text" in content) return content.text; + throw new Error(`Expected text content for ${uri}`); +} + +/** + * Read a skill's file manifest from an MCP server. + * + * Returns the parsed SkillManifest with file paths, sizes, and SHA256 hashes. + * Useful for discovering supporting files and verifying content integrity. + */ +export async function readSkillManifest( + client: Client, + skillName: string, +): Promise { + const uri = buildSkillUri(skillName, MANIFEST_PATH); + const result = await client.readResource({ uri }); + const content = result.contents[0]; + if (content && "text" in content) + return JSON.parse(content.text) as SkillManifest; + throw new Error(`Expected JSON content for ${uri}`); +} + +/** + * Read a supporting file from a skill directory. + * + * The documentPath is relative to the skill root (e.g., "references/REFERENCE.md"). + * Returns text content for text MIME types and base64-encoded blob for binary files. + */ +export async function readSkillDocument( + client: Client, + skillName: string, + documentPath: string, +): Promise<{ text?: string; blob?: string; mimeType?: string }> { + const uri = buildSkillUri(skillName, documentPath); + const result = await client.readResource({ uri }); + const content = result.contents[0]; + if (!content) throw new Error(`No content returned for ${uri}`); + return { + text: "text" in content ? content.text : undefined, + blob: "blob" in content ? content.blob : undefined, + mimeType: content.mimeType, + }; +} diff --git a/typescript/sdk/src/index.ts b/typescript/sdk/src/index.ts index baebaac..c70904e 100644 --- a/typescript/sdk/src/index.ts +++ b/typescript/sdk/src/index.ts @@ -32,7 +32,6 @@ export { getMimeType, isTextMimeType } from "./mime.js"; // XML generation export { - escapeXml, generateSkillsXML, generateSkillsXMLFromSummaries, } from "./xml.js"; @@ -40,16 +39,17 @@ export { // Server-side export { discoverSkills, - loadSkillContent, - loadDocument, - isPathWithinBase, - scanDocuments, registerSkillResources, + isPathWithinBase, } from "./server.js"; // Client-side export { + READ_RESOURCE_TOOL, listSkillResources, + readSkillContent, + readSkillManifest, + readSkillDocument, parseSkillFrontmatter, buildSkillsSummary, } from "./client.js"; diff --git a/typescript/sdk/src/server.ts b/typescript/sdk/src/server.ts index d507a31..ba23d4e 100644 --- a/typescript/sdk/src/server.ts +++ b/typescript/sdk/src/server.ts @@ -4,10 +4,6 @@ * Discovers Agent Skills by scanning a directory for subdirectories * containing SKILL.md files, parses YAML frontmatter for metadata, * scans for supplementary documents, and provides secure content loading. - * - * Inspired by: - * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) - * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) */ import * as fs from "node:fs"; @@ -294,8 +290,6 @@ export function discoverSkills( })), ], }; - const manifestJson = JSON.stringify(manifest); - skillMap.set(trimmedName, { name: trimmedName, description: description.trim(), @@ -304,7 +298,6 @@ export function discoverSkills( metadata: Object.keys(metadata).length > 0 ? metadata : undefined, documents, manifest, - manifestJson, lastModified: stat.mtime.toISOString(), }); } catch (error) { @@ -459,7 +452,7 @@ export function registerSkillResources( contents: [ { uri: uri.href, - text: skill.manifestJson, + text: JSON.stringify(skill.manifest), }, ], }), diff --git a/typescript/sdk/src/types.ts b/typescript/sdk/src/types.ts index 6aa2328..855291b 100644 --- a/typescript/sdk/src/types.ts +++ b/typescript/sdk/src/types.ts @@ -1,15 +1,10 @@ /** * Type definitions for the Skills as Resources SDK. * - * URI scheme aligned with skillsdotnet conventions: + * URI scheme: * - skill://{name}/SKILL.md — listed resource for skill content * - skill://{name}/_manifest — listed resource for file inventory * - skill://{name}/{+path} — template for supporting files - * - * Inspired by: - * - skilljack-mcp by Ola Hungerford (https://github.com/olaservo/skilljack-mcp) - * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) - * - SkillsDotNet by Brad Wilson (https://github.com/bradwilson/skillsdotnet) */ import type { RegisteredResource } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -64,7 +59,6 @@ export interface SkillMetadata { metadata?: Record; // Optional extra frontmatter fields documents: SkillDocument[]; // Supplementary files found in subdirectories manifest: SkillManifest; // Pre-computed file manifest - manifestJson: string; // Pre-serialized manifest JSON (avoids I/O on request) lastModified: string; // ISO 8601 timestamp from SKILL.md file mtime } diff --git a/typescript/sdk/src/xml.test.ts b/typescript/sdk/src/xml.test.ts index d4a61d5..e4aac6a 100644 --- a/typescript/sdk/src/xml.test.ts +++ b/typescript/sdk/src/xml.test.ts @@ -37,7 +37,6 @@ function makeSkillMetadata( skillDir: `/skills/${name}`, documents: [], manifest: { skill: name, files: [] }, - manifestJson: "{}", lastModified: "2025-01-01T00:00:00.000Z", }; } diff --git a/typescript/sdk/src/xml.ts b/typescript/sdk/src/xml.ts index e01b419..3e3d35e 100644 --- a/typescript/sdk/src/xml.ts +++ b/typescript/sdk/src/xml.ts @@ -3,9 +3,6 @@ * * Provides functions to generate XML from both * server-side SkillMetadata maps and client-side SkillSummary arrays. - * - * Inspired by: - * - skills-over-mcp by Keith Groves (https://github.com/keithagroves/skills-over-mcp) */ import type { SkillMetadata, SkillSummary } from "./types.js";