From 8bc23882d162c9b8259299aa169b93b881a2f221 Mon Sep 17 00:00:00 2001 From: adit chandra Date: Fri, 9 Oct 2026 16:43:25 -0700 Subject: [PATCH] Expose plugin-prefixed skill loading tools over MCP --- README.md | 5 +- docs/mcp-distribution.md | 44 +++++++-- pyproject.toml | 2 +- .../mcp_distribution/catalog.py | 24 +++++ .../mcp_distribution/compiler.py | 2 + .../mcp_distribution/server.py | 21 +++- .../mcp_distribution/skill_tools.py | 62 ++++++++++++ .../mcp_distribution/tool_definitions.py | 20 ++++ .../instruction_hub/test_mcp_distribution.py | 97 ++++++++++++++++++- uv.lock | 2 +- 10 files changed, 260 insertions(+), 19 deletions(-) create mode 100644 src/promptless_instruction_hub/mcp_distribution/skill_tools.py create mode 100644 src/promptless_instruction_hub/mcp_distribution/tool_definitions.py diff --git a/README.md b/README.md index c1ad6a3..d44e264 100644 --- a/README.md +++ b/README.md @@ -239,8 +239,9 @@ Install the toolchain with its `mcp` extra to serve it: pig serve-mcp --bundle dist/mcp --plugin dev ``` -This defaults to stdio. Skills use the MCP skills extension; all six authored -asset types have resources and retrieval tools. Commands also have portable +This defaults to stdio. Each skill has a plugin-prefixed loading tool such as +`dev__load_skill_review-change`, alongside the MCP skills extension. All six +authored asset types have resources and retrieval tools. Commands also have portable prompt projections, and agents have delegation skill projections. Execution, hook registration, and upstream MCP connections remain client responsibilities. See [MCP distribution](docs/mcp-distribution.md) for HTTP serving, authentication, diff --git a/docs/mcp-distribution.md b/docs/mcp-distribution.md index d7a3fe1..7e6d9b6 100644 --- a/docs/mcp-distribution.md +++ b/docs/mcp-distribution.md @@ -36,8 +36,9 @@ For an installed toolchain, install it with the `mcp` extra, for example `pig serve-mcp`. The server defaults to stdio and keeps protocol output on stdout. Repeat `--plugin` to select several plugins; omit it to expose all stable plugins. Unknown plugin IDs fail startup. Every protocol surface uses the same selection, -including direct reads of guessed URIs. A shared asset appears once and records -only its selected plugin memberships. +including direct reads of guessed URIs. A shared asset appears once in the asset +catalog and records only its selected plugin memberships. Skill-loading tools +have one alias per selected plugin membership. For Streamable HTTP: @@ -65,9 +66,9 @@ support supplying this credential, or a later authentication adapter is needed. | Asset | Delivered representation | Behavior the client must supply | | --- | --- | --- | -| Skill | Skills extension entry plus all supporting files | Load instructions, materialize relative files when needed, provide dependencies and authorized execution. | +| Skill | Plugin-prefixed loading tool, skills extension entry, and all supporting files | Load instructions, materialize relative files when needed, provide dependencies and authorized execution. | | Command | MCP prompt plus original source | Explicit user invocation and execution. Host macros or argument syntax outside the portable subset remain resources-only. | -| Agent | Delegation skill plus original source | Spawn a child agent. Tool restrictions are advisory; model overrides are omitted. The generated skill stops if delegation is unavailable. | +| Agent | Plugin-prefixed loading tool, delegation skill, and original source | Spawn a child agent. Tool restrictions are advisory; model overrides are omitted. The generated skill stops if delegation is unavailable. | | Rule | Original resource, including authored scope metadata | Scope matching, precedence, and activation. Delivery does not provide always-on enforcement. | | Hook | Source files and structured hook declaration | Host lifecycle events, registration, and execution. The server cannot fire hooks. | | MCP configuration | Original connection definition | Connect to upstream servers and supply their credentials. The distributor does not proxy their tools or interpolate secrets. | @@ -108,13 +109,34 @@ different assets without rewriting their frontmatter. `resources/list` exposes all selected files and a selected `catalog.json`. `prompts/list` and `prompts/get` expose portable commands without arguments. -Two read-only tools provide a fallback for clients exposing only MCP tools: +Every projected skill is also an ordinary read-only MCP tool. A skill with asset +ID `review-change` in plugin `dev` is named `dev__load_skill_review-change`. +Delegation skill tools use `dev__load_agent_`. Names use stable plugin +and asset IDs; editing frontmatter does not rename a tool. A skill shared by +several plugins has an alias for each selected plugin. + +Names are limited to 64 characters for tool-client compatibility. Long asset IDs +are shortened with an eight-character SHA-256 suffix; the complete plugin prefix +is preserved. Shortening requires room for an asset-ID character, separator, and +digest; an overlong plugin prefix fails compilation and catalog loading with a +request to shorten the plugin ID. Duplicate generated names also fail. + +Tool descriptions preserve the authored skill trigger and identify the operation +as loading instructions. These tools accept no arguments and return the complete +`SKILL.md`, compatibility information, plugin and bundle identity, and supporting +files with relative paths and exact `read_asset` arguments. This operation does +not run scripts or carry out the workflow. The client follows the instructions +with its available capabilities and authorization. Supporting-file calls include +`bundle_id` to prevent reads from a different release after a server replacement. + +Two additional read-only tools provide general catalog access: - `search_assets`: discover by words, optional asset kind, and cursor; an empty query lists assets. Returns metadata, compatibility requirements, and paths. - `read_asset`: retrieve a discovered asset by `ref` and optional exact file - path. The default reads the skill entrypoint or first source file. Binary - content uses base64; text uses UTF-8. No source code is executed. + path. Pass the returned `bundle_id` to bind the read to that release; a different + bundle ID is rejected. The default reads the skill entrypoint or first source + file. Binary content uses base64; text uses UTF-8. No source code is executed. List methods paginate. Cursors are bound to the bundle, selected plugins, and query. Skills list/get return `resultType: complete`, `ttlMs: 0`, and @@ -161,10 +183,12 @@ SDK tests exercise modern skills discovery and legacy tool retrieval, including stdio and authenticated HTTP. They do not establish Dots compatibility. Use a small plugin selection and a deployed test endpoint to verify: -1. Connection and authentication, discovery of retrieval tools, and whether Dots +1. Connection and authentication, discovery of plugin-prefixed skill tools across + every `tools/list` page, and whether Dots also exposes resources, prompts, and the skills extension. -2. Discovery from a task matching a skill description, followed by retrieval of - the complete skill and a supporting file; confirm the expected bundle ID. +2. A natural task matching a skill description selects the corresponding loading + tool without a prompt to search the hub. Confirm retrieval of the complete + skill and a supporting file from the same bundle ID, then workflow execution. 3. Relative-file materialization and an explicitly authorized harmless script, if Dots provides execution. Missing dependencies must surface as limitations. 4. Explicit command invocation and child-agent delegation, if supported. Verify diff --git a/pyproject.toml b/pyproject.toml index a8aac9d..bc3c6a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "promptless-instruction-hub" -version = "0.3.0" +version = "0.4.0" description = "Promptless Instruction Hub scanner and distribution compiler" requires-python = ">=3.11" dependencies = [ diff --git a/src/promptless_instruction_hub/mcp_distribution/catalog.py b/src/promptless_instruction_hub/mcp_distribution/catalog.py index 5427605..3b11e42 100644 --- a/src/promptless_instruction_hub/mcp_distribution/catalog.py +++ b/src/promptless_instruction_hub/mcp_distribution/catalog.py @@ -28,6 +28,7 @@ SkillEntry, SkillResource, ) +from promptless_instruction_hub.mcp_distribution.skill_tools import project_skill_tools from promptless_instruction_hub.models import validate_identifier from promptless_instruction_hub.release.hashing import stable_hash @@ -107,6 +108,7 @@ def __init__(self, root: Path, plugins: set[str] | None = None) -> None: if asset.prompt.name in self.prompts: raise InstructionHubError("MCP bundle has duplicate prompt names") self.prompts[asset.prompt.name] = selected + self.skill_tools = project_skill_tools(self.assets.values()) self.catalog_uri = f"hub://{self.manifest.marketplace.id}/{self.manifest.bundle_id}/catalog.json" catalog = self.manifest.model_dump(mode="json", exclude={"assets", "plugins"}) catalog["plugins"] = [p.model_dump(mode="json") for p in self.manifest.plugins if p.id in self.plugins] @@ -142,6 +144,28 @@ def read(self, uri: str) -> dict[str, str]: pass # The binary representation preserves bytes even for mislabeled text. return dict(result, blob=base64.b64encode(data).decode("ascii")) + def load_skill_tool(self, name: str) -> dict[str, JsonValue]: + """Load instructions and exact supporting-file reads from this immutable release.""" + tool = self.skill_tools.get(name) + if tool is None or tool.asset.skill is None: + raise ValueError("skill tool not found in this bundle and plugin selection") + asset, skill = tool.asset, tool.asset.skill + root = Path(skill.entrypoint).parent + return { + "bundle_id": self.manifest.bundle_id, + "plugin": tool.plugin, + "asset": self.describe(asset), + "content": self.read(self.file_uris[skill.entrypoint]), + "supporting_files": [ + { + "path": Path(path).relative_to(root).as_posix(), + "read_asset": {"bundle_id": self.manifest.bundle_id, "ref": asset.ref, "path": path}, + } + for path in skill.files + if path != skill.entrypoint + ], + } + def page(self, items: list[T], cursor: str | None, key: str, limit: int = 50) -> tuple[list[T], str | None]: scope = stable_hash( {"bundle": self.manifest.bundle_id, "plugins": sorted(self.plugins), "key": key, "limit": limit} diff --git a/src/promptless_instruction_hub/mcp_distribution/compiler.py b/src/promptless_instruction_hub/mcp_distribution/compiler.py index 46bdcd3..91c9c37 100644 --- a/src/promptless_instruction_hub/mcp_distribution/compiler.py +++ b/src/promptless_instruction_hub/mcp_distribution/compiler.py @@ -22,6 +22,7 @@ BundleSkill, Compatibility, ) +from promptless_instruction_hub.mcp_distribution.skill_tools import project_skill_tools from promptless_instruction_hub.models import LoadedAsset, ResolvedExternalPluginDefinition, ResolvedHubPluginDefinition from promptless_instruction_hub.release.hashing import stable_hash from promptless_instruction_hub.validate.hub import ValidationResult @@ -85,6 +86,7 @@ def render_mcp_bundle(output_root: Path, validation: ValidationResult[ResolvedHu for asset in plugin.assets: memberships.setdefault(asset.ref, []).append(definition.id) assets = [_compile_asset(root, asset, sorted(memberships[asset.ref])) for asset in validation.stable_assets] + project_skill_tools(assets) files = [file for asset in assets for file in asset.files] if len(files) > MAX_BUNDLE_FILES or sum(file.size for file in files) > MAX_BUNDLE_BYTES: raise InstructionHubError("MCP bundle exceeds 10,000 files or 256 MiB") diff --git a/src/promptless_instruction_hub/mcp_distribution/server.py b/src/promptless_instruction_hub/mcp_distribution/server.py index 5b4e161..250d27b 100644 --- a/src/promptless_instruction_hub/mcp_distribution/server.py +++ b/src/promptless_instruction_hub/mcp_distribution/server.py @@ -21,7 +21,8 @@ from starlette.types import ASGIApp, Receive, Scope, Send from promptless_instruction_hub.mcp_distribution.catalog import Catalog -from promptless_instruction_hub.mcp_distribution.models import SkillEntry +from promptless_instruction_hub.mcp_distribution.models import Sha256, SkillEntry +from promptless_instruction_hub.mcp_distribution.tool_definitions import skill_tool_definitions from promptless_instruction_hub.models import AssetKind SKILLS_EXTENSION = "io.modelcontextprotocol/skills" @@ -57,6 +58,7 @@ class SearchArguments(BaseModel): class ReadArguments(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) + bundle_id: Sha256 | None = None ref: str = Field(min_length=1, max_length=256) path: str | None = Field(default=None, max_length=4096) @@ -162,7 +164,7 @@ async def get_prompt(ctx: ServerRequestContext, params: types.GetPromptRequestPa input_schema=ReadArguments.model_json_schema(), annotations=annotations, ), - ] + ] + skill_tool_definitions(catalog) async def list_tools( ctx: ServerRequestContext, params: types.PaginatedRequestParams | None @@ -207,13 +209,23 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara } elif params.name == "read_asset": read_args = ReadArguments.model_validate(params.arguments or {}) + if read_args.bundle_id is not None and read_args.bundle_id != catalog.manifest.bundle_id: + raise ValueError("bundle is not served by this instance; discover the current catalog again") asset = catalog.assets.get(read_args.ref) if asset is None: raise ValueError("asset not found in this bundle and plugin selection") path = read_args.path or (asset.skill.entrypoint if asset.skill else asset.files[0].path) if path not in {file.path for file in asset.files}: raise ValueError("file not found in this asset") - result = {"asset": catalog.describe(asset), "content": catalog.read(catalog.file_uris[path])} + result = { + "bundle_id": catalog.manifest.bundle_id, + "asset": catalog.describe(asset), + "content": catalog.read(catalog.file_uris[path]), + } + elif params.name in catalog.skill_tools: + if params.arguments: + raise ValueError("skill loading tools take no arguments") + result = catalog.load_skill_tool(params.name) else: raise MCPError(types.INVALID_PARAMS, "unknown tool") except ValueError as exc: @@ -227,7 +239,8 @@ async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestPara version=catalog.manifest.version, instructions=( f"Instruction Hub: {catalog.manifest.marketplace.name}. " - "Use skills/list and resources/read, or search_assets and read_asset, to retrieve selected assets. " + "Use plugin-prefixed loading tools to retrieve skill workflows, then read_asset for supporting files. " + "Native skills/list and resources/read, plus search_assets, expose the same selected assets. " "Read each asset's compatibility requirements before applying it. Retrieval grants no permission " "to execute scripts, activate commands, spawn agents, register hooks, or connect upstream MCPs. " f"Full selected catalog: {catalog.catalog_uri}" diff --git a/src/promptless_instruction_hub/mcp_distribution/skill_tools.py b/src/promptless_instruction_hub/mcp_distribution/skill_tools.py new file mode 100644 index 0000000..2691077 --- /dev/null +++ b/src/promptless_instruction_hub/mcp_distribution/skill_tools.py @@ -0,0 +1,62 @@ +"""Stable plugin-prefixed skill loading tools, independent of the MCP SDK.""" + +from __future__ import annotations + +import hashlib +from collections.abc import Iterable +from dataclasses import dataclass + +from promptless_instruction_hub.errors import InstructionHubError +from promptless_instruction_hub.mcp_distribution.models import BundleAsset + +MAX_TOOL_NAME_LENGTH = 64 + + +@dataclass(frozen=True) +class SkillTool: + """One plugin's discoverable alias for a skill or delegation skill.""" + + name: str + plugin: str + asset: BundleAsset + + @property + def description(self) -> str: + """Preserve the authored trigger and explain the loading operation.""" + skill = self.asset.skill + if skill is None: + raise ValueError("skill tool requires a skill projection") + return ( + f"{skill.frontmatter['description']} " + "Loads the workflow instructions and supporting-file references. " + "Follow the returned instructions using your available capabilities; this tool only retrieves content." + ) + + +def project_skill_tools(assets: Iterable[BundleAsset]) -> dict[str, SkillTool]: + """Project every selected skill membership, rejecting ambiguous tool names.""" + result: dict[str, SkillTool] = {} + for asset in sorted(assets, key=lambda item: item.ref): + if asset.skill is None: + continue + for plugin in sorted(set(asset.plugins)): + name = _tool_name(plugin, asset) + if name in result: + raise InstructionHubError(f"MCP skill tool name collision: {name}") + result[name] = SkillTool(name=name, plugin=plugin, asset=asset) + return dict(sorted(result.items())) + + +def _tool_name(plugin: str, asset: BundleAsset) -> str: + """Keep the entire plugin prefix and shorten only long asset IDs with a digest.""" + prefix = f"{plugin}__load_{asset.kind}_" + available = MAX_TOOL_NAME_LENGTH - len(prefix) + if len(asset.id) <= available: + return prefix + asset.id + if available < 10: + raise InstructionHubError( + f"{asset.ref}: plugin id {plugin!r} is too long for a 64-character MCP skill tool name; " + "shorten the plugin id" + ) + digest = hashlib.sha256(asset.ref.encode()).hexdigest()[:8] + return f"{prefix}{asset.id[: available - 9]}_{digest}" diff --git a/src/promptless_instruction_hub/mcp_distribution/tool_definitions.py b/src/promptless_instruction_hub/mcp_distribution/tool_definitions.py new file mode 100644 index 0000000..1af8f39 --- /dev/null +++ b/src/promptless_instruction_hub/mcp_distribution/tool_definitions.py @@ -0,0 +1,20 @@ +"""MCP SDK definitions shared by local and hosted skill distribution servers.""" + +from mcp import types + +from promptless_instruction_hub.mcp_distribution.catalog import Catalog + + +def skill_tool_definitions(catalog: Catalog) -> list[types.Tool]: + """Advertise each plugin skill as a read-only tool accepting no arguments.""" + return [ + types.Tool( + name=tool.name, + description=tool.description, + input_schema={"type": "object", "properties": {}, "additionalProperties": False}, + annotations=types.ToolAnnotations( + read_only_hint=True, destructive_hint=False, idempotent_hint=True, open_world_hint=False + ), + ) + for tool in catalog.skill_tools.values() + ] diff --git a/tests/instruction_hub/test_mcp_distribution.py b/tests/instruction_hub/test_mcp_distribution.py index 4b9fea8..0c26073 100644 --- a/tests/instruction_hub/test_mcp_distribution.py +++ b/tests/instruction_hub/test_mcp_distribution.py @@ -21,6 +21,7 @@ from promptless_instruction_hub.errors import BuildCheckFailedError, InstructionHubError from promptless_instruction_hub.mcp_distribution.catalog import Catalog from promptless_instruction_hub.mcp_distribution.compiler import read_frontmatter +from promptless_instruction_hub.mcp_distribution.skill_tools import project_skill_tools from promptless_instruction_hub.mcp_distribution.server import ( GetSkillParams, GetSkillResult, @@ -380,7 +381,13 @@ async def run() -> None: prompt = await client.get_prompt("command:release") assert prompt.messages[0].content == types.TextContent(type="text", text="Review then publish.\n") tools = await client.list_tools() - assert [tool.name for tool in tools.tools] == ["search_assets", "read_asset"] + assert [tool.name for tool in tools.tools] == [ + "search_assets", + "read_asset", + "dev__load_agent_reviewer", + "dev__load_skill_review", + "ops__load_skill_review", + ] result = await client.call_tool("search_assets", {"query": "review changes", "kind": "skill"}) assert not result.is_error assert result.structured_content["assets"][0]["ref"] == "skill:review" @@ -409,6 +416,94 @@ async def run() -> None: anyio.run(run) +def test_skill_tools_load_instructions_and_supporting_files(hub: Path) -> None: + async def run() -> None: + catalog = Catalog(hub / "dist/mcp", {"ops"}) + async with Client(create_server(catalog)) as client: + listed = await client.list_tools() + assert [tool.name for tool in listed.tools] == ["search_assets", "read_asset", "ops__load_skill_review"] + loader = listed.tools[-1] + jsonschema.validate({}, loader.input_schema) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"task": "run it"}, loader.input_schema) + assert loader.annotations is not None and loader.annotations.read_only_hint + loaded = await client.call_tool(loader.name, {}) + assert not loaded.is_error + result = loaded.structured_content + assert result is not None + assert result["bundle_id"] == catalog.manifest.bundle_id + assert result["plugin"] == "ops" + assert result["content"]["text"] == (hub / "assets/skills/review/SKILL.md").read_text() + assert result["asset"]["plugins"] == ["ops"] + references = {file["path"]: file["read_asset"] for file in result["supporting_files"]} + assert set(references) == {"sample.bin", "scripts/check.py"} + for path, arguments in references.items(): + read = await client.call_tool("read_asset", arguments) + content = read.structured_content["content"] + data = content["text"].encode() if "text" in content else base64.b64decode(content["blob"]) + assert data == (hub / "assets/skills/review" / path).read_bytes() + assert read.structured_content["bundle_id"] == result["bundle_id"] + assert (await client.call_tool(loader.name, {"task": "run it"})).is_error + assert (await client.call_tool("read_asset", {"bundle_id": "0" * 64, "ref": "skill:review"})).is_error + with pytest.raises(MCPError): + await client.call_tool("dev__load_skill_review", {}) + + anyio.run(run) + + +def test_skill_tool_names_are_stable_bounded_and_unambiguous(hub: Path) -> None: + catalog = Catalog(hub / "dist/mcp") + skill = catalog.assets["skill:review"] + names = set(project_skill_tools([skill])) + assert names == {"dev__load_skill_review", "ops__load_skill_review"} + changed = skill.model_copy(deep=True) + assert changed.skill is not None + changed.skill.frontmatter.update(name="new-name", description="A different trigger.") + assert set(project_skill_tools([changed])) == names + long_assets = [ + skill.model_copy(update={"id": "review-" * 20 + suffix, "ref": "skill:" + "review-" * 20 + suffix}) + for suffix in ("one", "two") + ] + tools = project_skill_tools(long_assets) + assert len(tools) == 4 + assert all(len(name) == 64 and name.startswith(f"{tool.plugin}__load_skill_") for name, tool in tools.items()) + assert list(project_skill_tools(reversed(long_assets))) == list(tools) + exact = skill.model_copy(update={"plugins": ["p" * 45]}) + assert list(project_skill_tools([exact])) == ["p" * 45 + "__load_skill_review"] + with pytest.raises(InstructionHubError, match="collision"): + project_skill_tools([skill, skill]) + with pytest.raises(InstructionHubError, match="shorten the plugin id"): + project_skill_tools([skill.model_copy(update={"plugins": ["long-" * 12 + "plugin"]})]) + + +def test_skill_tools_paginate_all_plugin_memberships(hub: Path) -> None: + config_path = hub / "hub.yaml" + config = yaml.safe_load(config_path.read_text()) + for index in range(55): + plugin = f"team-{index:02}" + config["stable_plugins"].append(plugin) + (hub / "plugins" / f"{plugin}.yaml").write_text(f"id: {plugin}\nname: {plugin}\nincludes: [skill:review]\n") + config_path.write_text(yaml.safe_dump(config)) + build_hub(hub) + catalog = Catalog(hub / "dist/mcp") + + async def run() -> None: + async with Client(create_server(catalog)) as client: + first = await client.list_tools() + assert len(first.tools) == 50 and first.next_cursor is not None + second = await client.list_tools(cursor=first.next_cursor) + assert second.next_cursor is None + assert {tool.name for tool in first.tools + second.tools} == { + "search_assets", + "read_asset", + *catalog.skill_tools, + } + result = await client.call_tool(second.tools[-1].name, {}) + assert not result.is_error and result.structured_content["plugin"] == "team-54" + + anyio.run(run) + + def test_stdio_cli_real_wire_roundtrip(hub: Path) -> None: async def run() -> None: process = StdioServerParameters( diff --git a/uv.lock b/uv.lock index 376b788..82081fb 100644 --- a/uv.lock +++ b/uv.lock @@ -395,7 +395,7 @@ wheels = [ [[package]] name = "promptless-instruction-hub" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "pydantic" },