From 1714f0ad4dd5b0dc95b9ce464c13bab009386646 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 02:57:54 +0000 Subject: [PATCH 1/3] feat(schemas): bundle adcp-agents.json as adcp.schemas package, eliminate inlined test copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #420 Adds adcp.schemas — a small Python package that ships JSON schemas as committed data files so any module can import them by name rather than maintaining inline copies that drift. The topology-manifest schema (adcp-agents.json, used by /.well-known/adcp-agents.json) was inlined in test_discovery_endpoint.py after PR #406; this commit moves it into src/adcp/schemas/ where it is bundled into the wheel via the new adcp.schemas package-data entry and loaded via importlib.resources. Storing it in src/adcp/schemas/ (not schemas/cache/) makes it immune to the shutil.rmtree wipe in sync_schemas.py, so it survives make regenerate-schemas runs without any preservation logic. https://claude.ai/code/session_01TfYXsZysMjHdZBpJqkVW7P --- pyproject.toml | 3 ++ src/adcp/schemas/__init__.py | 60 ++++++++++++++++++++++ src/adcp/schemas/adcp-agents.json | 77 ++++++++++++++++++++++++++++ tests/test_discovery_endpoint.py | 83 ++----------------------------- tests/test_schemas_module.py | 33 ++++++++++++ 5 files changed, 176 insertions(+), 80 deletions(-) create mode 100644 src/adcp/schemas/__init__.py create mode 100644 src/adcp/schemas/adcp-agents.json create mode 100644 tests/test_schemas_module.py 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..c3e49fa36 --- /dev/null +++ b/src/adcp/schemas/__init__.py @@ -0,0 +1,60 @@ +"""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"] + +#: 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``. + """ + try: + pkg = files("adcp.schemas") + with as_file(pkg / name) as p: + # as_file() does not raise when the file is absent; is_file() guards the read. + if p.is_file(): + return cast(dict[str, Any], json.loads(p.read_text())) + except (ModuleNotFoundError, FileNotFoundError, OSError): + pass + + raise FileNotFoundError( + f"AdCP schema {name!r} not bundled with this SDK release. " + "Available schemas: adcp-agents.json. " + "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..ff803b3f5 --- /dev/null +++ b/tests/test_schemas_module.py @@ -0,0 +1,33 @@ +"""Unit tests for :mod:`adcp.schemas`.""" + +from __future__ import annotations + +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 From ea2d6bc8fd394c71e57729978f873aba18408852 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 03:07:43 +0000 Subject: [PATCH 2/3] fix(schemas): path-traversal guard, JSONDecodeError handling, __all__ completeness - Reject load_schema names containing '/', '\\', or '..' to prevent escaping the adcp.schemas package directory - Add ValueError (covers json.JSONDecodeError) to the except clause so a corrupted bundled schema surfaces as FileNotFoundError, not a raw parse error - Add ADCP_AGENTS to __all__ so it appears in wildcard imports and docs - Enumerate available schemas dynamically in the error message - Add tests: path-traversal parametrize, corrupted-schema guard https://claude.ai/code/session_01TfYXsZysMjHdZBpJqkVW7P --- src/adcp/schemas/__init__.py | 28 +++++++++++++++++++++++----- tests/test_schemas_module.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/adcp/schemas/__init__.py b/src/adcp/schemas/__init__.py index c3e49fa36..f6f50a124 100644 --- a/src/adcp/schemas/__init__.py +++ b/src/adcp/schemas/__init__.py @@ -23,7 +23,7 @@ from importlib.resources import as_file, files from typing import Any, cast -__all__ = ["load_schema"] +__all__ = ["load_schema", "ADCP_AGENTS"] #: Known schema filenames shipped with the SDK. ADCP_AGENTS = "adcp-agents.json" @@ -43,18 +43,36 @@ def load_schema(name: str) -> dict[str, Any]: ``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. + 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: - # as_file() does not raise when the file is absent; is_file() guards the read. + # 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())) - except (ModuleNotFoundError, FileNotFoundError, OSError): + 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: + available = ADCP_AGENTS + raise FileNotFoundError( f"AdCP schema {name!r} not bundled with this SDK release. " - "Available schemas: adcp-agents.json. " + f"Available schemas: {available}. " "If you are developing against a source checkout, ensure " "`src/adcp/schemas/` contains the schema file." ) diff --git a/tests/test_schemas_module.py b/tests/test_schemas_module.py index ff803b3f5..cfc773e7c 100644 --- a/tests/test_schemas_module.py +++ b/tests/test_schemas_module.py @@ -31,3 +31,31 @@ def test_load_schema_error_message_is_actionable() -> None: 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) -> None: + """A corrupted bundled file should surface as FileNotFoundError, not JSONDecodeError.""" + from contextlib import contextmanager + from unittest.mock import patch + + @contextmanager # type: ignore[misc] + def _bad_as_file(resource) -> None: # type: ignore[misc] + 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) From 0cdbc9e509d44947b108b087df3e704d54e95dda Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 3 May 2026 03:14:58 +0000 Subject: [PATCH 3/3] fix(schemas): correct generator annotation in test, add clarifying comments - Fix _bad_as_file return type to Iterator[Path] (mypy rejects -> None on a generator under @contextmanager) - Move test imports to module level per project convention - Add comment on intentional over-conservative '..' path-traversal guard - Add noqa comment on broad except in dynamic schema listing https://claude.ai/code/session_01TfYXsZysMjHdZBpJqkVW7P --- src/adcp/schemas/__init__.py | 5 ++++- tests/test_schemas_module.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/adcp/schemas/__init__.py b/src/adcp/schemas/__init__.py index f6f50a124..787586360 100644 --- a/src/adcp/schemas/__init__.py +++ b/src/adcp/schemas/__init__.py @@ -44,6 +44,9 @@ def load_schema(name: str) -> dict[str, Any]: ``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. " @@ -67,7 +70,7 @@ def load_schema(name: str) -> dict[str, Any]: available = ", ".join( sorted(f.name for f in files("adcp.schemas").iterdir() if f.name.endswith(".json")) ) - except Exception: + except Exception: # noqa: BLE001 — intentional: don't mask the real FileNotFoundError available = ADCP_AGENTS raise FileNotFoundError( diff --git a/tests/test_schemas_module.py b/tests/test_schemas_module.py index cfc773e7c..36f77f0a4 100644 --- a/tests/test_schemas_module.py +++ b/tests/test_schemas_module.py @@ -2,6 +2,11 @@ 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 @@ -45,13 +50,11 @@ def test_load_schema_rejects_path_traversal(traversal: str) -> None: load_schema(traversal) -def test_load_schema_corrupted_schema_raises_file_not_found(tmp_path) -> None: +def test_load_schema_corrupted_schema_raises_file_not_found(tmp_path: Path) -> None: """A corrupted bundled file should surface as FileNotFoundError, not JSONDecodeError.""" - from contextlib import contextmanager - from unittest.mock import patch - @contextmanager # type: ignore[misc] - def _bad_as_file(resource) -> None: # type: ignore[misc] + @contextmanager + def _bad_as_file(resource: object) -> Iterator[Path]: bad = tmp_path / "bad.json" bad.write_text("not json", encoding="utf-8") yield bad