diff --git a/pyproject.toml b/pyproject.toml index 96887add0..23a734e37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -128,6 +128,9 @@ adcp = [ # ``adcp.validation.schema_loader``. "_schemas/**/*.json", ] +# Named schemas bundled directly with the SDK (committed, not generated). +# These live in src/adcp/schemas/ and are loaded via adcp.schemas.load_schema. +"adcp.schemas" = ["*.json"] [tool.black] line-length = 100 diff --git a/src/adcp/schemas/__init__.py b/src/adcp/schemas/__init__.py new file mode 100644 index 000000000..787586360 --- /dev/null +++ b/src/adcp/schemas/__init__.py @@ -0,0 +1,81 @@ +"""Access bundled AdCP JSON schemas by name. + +Schemas that ship with the SDK live as data files in this package +(``src/adcp/schemas/``). They are committed to the repo and included +in the wheel via the ``adcp.schemas`` ``package-data`` entry in +``pyproject.toml``, so ``importlib.resources`` resolves them correctly +in both editable installs and installed wheels. + +For per-tool request/response validation validators, see +:mod:`adcp.validation.schema_loader`. + +Usage:: + + from adcp.schemas import load_schema + + schema = load_schema("adcp-agents.json") + jsonschema.validate(manifest, schema) +""" + +from __future__ import annotations + +import json +from importlib.resources import as_file, files +from typing import Any, cast + +__all__ = ["load_schema", "ADCP_AGENTS"] + +#: Known schema filenames shipped with the SDK. +ADCP_AGENTS = "adcp-agents.json" + + +def load_schema(name: str) -> dict[str, Any]: + """Return the named AdCP JSON schema as a dict. + + Raises :class:`FileNotFoundError` if the schema is not bundled with + the SDK. Pass one of the ``adcp.schemas.`` string constants to + avoid typos (e.g. :data:`ADCP_AGENTS`). + + :param name: Filename of the schema, e.g. ``"adcp-agents.json"``. + + .. note:: + Returns the raw JSON Schema dict; pass it to + ``jsonschema.validate(instance, schema)`` to validate a document. + ``jsonschema`` is a required dependency of ``adcp``. + """ + # Guard against path traversal: reject names that could escape the package. + # Intentionally conservative — any name containing ".." is rejected even if + # it wouldn't actually traverse (e.g. "..future.json"); prefer a clear + # "invalid" error over a subtle escape. + if "/" in name or "\\" in name or ".." in name: + raise FileNotFoundError( + f"AdCP schema name {name!r} is invalid. " + "Use a bare filename, e.g. 'adcp-agents.json'." + ) + + try: + pkg = files("adcp.schemas") + with as_file(pkg / name) as p: + # p.is_file() guards the read — as_file() hands back a path even + # when the member is absent in the package. + if p.is_file(): + return cast(dict[str, Any], json.loads(p.read_text(encoding="utf-8"))) + except (ModuleNotFoundError, FileNotFoundError, OSError, ValueError): + # ValueError covers json.JSONDecodeError (a subclass) so a corrupted + # bundled schema surfaces as FileNotFoundError rather than a raw parse + # error. + pass + + try: + available = ", ".join( + sorted(f.name for f in files("adcp.schemas").iterdir() if f.name.endswith(".json")) + ) + except Exception: # noqa: BLE001 — intentional: don't mask the real FileNotFoundError + available = ADCP_AGENTS + + raise FileNotFoundError( + f"AdCP schema {name!r} not bundled with this SDK release. " + f"Available schemas: {available}. " + "If you are developing against a source checkout, ensure " + "`src/adcp/schemas/` contains the schema file." + ) diff --git a/src/adcp/schemas/adcp-agents.json b/src/adcp/schemas/adcp-agents.json new file mode 100644 index 000000000..b9efbc750 --- /dev/null +++ b/src/adcp/schemas/adcp-agents.json @@ -0,0 +1,77 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/adcp-agents.json", + "title": "AdCP Multi-Agent Topology Manifest", + "description": "Origin-scoped manifest served at /.well-known/adcp-agents.json. Enumerates the AdCP agents a host exposes so buyers can discover the full topology in a single request. Defined in adcontextprotocol/adcp PR #3903 (commit 5c3e3e626).", + "type": "object", + "properties": { + "$schema": {"type": "string"}, + "version": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+(\\.[0-9]+)?$" + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "pattern": "^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$", + "minLength": 1, + "maxLength": 64 + }, + "url": { + "type": "string", + "format": "uri", + "minLength": 1, + "pattern": "^https://" + }, + "transport": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "specialisms": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "minItems": 1, + "maxItems": 64, + "uniqueItems": true + }, + "auth_hint": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "description": { + "type": "string", + "minLength": 1, + "maxLength": 500 + } + }, + "required": ["agent_id", "url", "transport", "specialisms"], + "additionalProperties": true + }, + "minItems": 1, + "maxItems": 256 + }, + "contact": { + "type": "object", + "properties": { + "name": {"type": "string", "minLength": 1, "maxLength": 255}, + "email": {"type": "string", "format": "email"}, + "url": {"type": "string", "format": "uri"} + }, + "required": ["name"], + "additionalProperties": true + }, + "last_updated": {"type": "string", "format": "date-time"} + }, + "required": ["version", "agents"], + "additionalProperties": true +} diff --git a/tests/test_discovery_endpoint.py b/tests/test_discovery_endpoint.py index 5ba14358c..507d7218e 100644 --- a/tests/test_discovery_endpoint.py +++ b/tests/test_discovery_endpoint.py @@ -26,6 +26,7 @@ from starlette.applications import Starlette from starlette.testclient import TestClient +from adcp.schemas import ADCP_AGENTS, load_schema from adcp.server import ADCPHandler, ToolContext from adcp.server.discovery import ( DISCOVERY_PATH, @@ -39,86 +40,8 @@ _wrap_with_discovery, ) -# Inline copy of the AdCP discovery schema (PR #3903 / spec -# adcontextprotocol/adcp@5c3e3e626). Inlined rather than fetched so -# tests stay deterministic and offline. Update when the upstream -# schema bumps. -_DISCOVERY_SCHEMA: dict = { - "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "/schemas/adcp-agents.json", - "title": "AdCP Multi-Agent Topology Manifest", - "type": "object", - "properties": { - "$schema": {"type": "string"}, - "version": { - "type": "string", - "pattern": r"^[0-9]+\.[0-9]+(\.[0-9]+)?$", - }, - "agents": { - "type": "array", - "items": { - "type": "object", - "properties": { - "agent_id": { - "type": "string", - "pattern": r"^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$", - "minLength": 1, - "maxLength": 64, - }, - "url": { - "type": "string", - "format": "uri", - "minLength": 1, - "pattern": r"^https://", - }, - "transport": { - "type": "string", - "minLength": 1, - "maxLength": 64, - }, - "specialisms": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 128, - }, - "minItems": 1, - "maxItems": 64, - "uniqueItems": True, - }, - "auth_hint": { - "type": "string", - "minLength": 1, - "maxLength": 64, - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 500, - }, - }, - "required": ["agent_id", "url", "transport", "specialisms"], - "additionalProperties": True, - }, - "minItems": 1, - "maxItems": 256, - }, - "contact": { - "type": "object", - "properties": { - "name": {"type": "string", "minLength": 1, "maxLength": 255}, - "email": {"type": "string", "format": "email"}, - "url": {"type": "string", "format": "uri"}, - }, - "required": ["name"], - "additionalProperties": True, - }, - "last_updated": {"type": "string", "format": "date-time"}, - }, - "required": ["version", "agents"], - "additionalProperties": True, -} +# Schema sourced from adcp.schemas (adcontextprotocol/adcp PR #3903, commit 5c3e3e626). +_DISCOVERY_SCHEMA: dict = load_schema(ADCP_AGENTS) def _validate_manifest(payload: dict) -> None: diff --git a/tests/test_schemas_module.py b/tests/test_schemas_module.py new file mode 100644 index 000000000..36f77f0a4 --- /dev/null +++ b/tests/test_schemas_module.py @@ -0,0 +1,64 @@ +"""Unit tests for :mod:`adcp.schemas`.""" + +from __future__ import annotations + +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +import pytest + +from adcp.schemas import ADCP_AGENTS, load_schema + + +def test_load_schema_adcp_agents_returns_dict() -> None: + schema = load_schema(ADCP_AGENTS) + assert isinstance(schema, dict) + assert schema["title"] == "AdCP Multi-Agent Topology Manifest" + assert "version" in schema.get("required", []) + assert "agents" in schema.get("required", []) + + +def test_load_schema_adcp_agents_has_https_pattern() -> None: + schema = load_schema(ADCP_AGENTS) + url_prop = schema["properties"]["agents"]["items"]["properties"]["url"] + assert url_prop["pattern"] == "^https://" + + +def test_load_schema_unknown_raises_file_not_found() -> None: + with pytest.raises(FileNotFoundError, match="not bundled"): + load_schema("nonexistent-schema.json") + + +def test_load_schema_error_message_is_actionable() -> None: + with pytest.raises(FileNotFoundError) as exc_info: + load_schema("typo-schema.json") + msg = str(exc_info.value) + assert "adcp-agents.json" in msg + + +@pytest.mark.parametrize("traversal", [ + "../../schemas/cache/adagents.json", + "../validation/__init__.py", + "/etc/passwd", + "a\\b.json", + "..json", +]) +def test_load_schema_rejects_path_traversal(traversal: str) -> None: + with pytest.raises(FileNotFoundError, match="invalid"): + load_schema(traversal) + + +def test_load_schema_corrupted_schema_raises_file_not_found(tmp_path: Path) -> None: + """A corrupted bundled file should surface as FileNotFoundError, not JSONDecodeError.""" + + @contextmanager + def _bad_as_file(resource: object) -> Iterator[Path]: + bad = tmp_path / "bad.json" + bad.write_text("not json", encoding="utf-8") + yield bad + + with patch("adcp.schemas.as_file", _bad_as_file): + with pytest.raises(FileNotFoundError): + load_schema(ADCP_AGENTS)