Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 34 additions & 10 deletions docs/mcp-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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_<asset-id>`. 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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
24 changes: 24 additions & 0 deletions src/promptless_instruction_hub/mcp_distribution/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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}
Expand Down
2 changes: 2 additions & 0 deletions src/promptless_instruction_hub/mcp_distribution/compiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
21 changes: 17 additions & 4 deletions src/promptless_instruction_hub/mcp_distribution/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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}"
Expand Down
62 changes: 62 additions & 0 deletions src/promptless_instruction_hub/mcp_distribution/skill_tools.py
Original file line number Diff line number Diff line change
@@ -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}"
Original file line number Diff line number Diff line change
@@ -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()
]
Loading
Loading