Skip to content
Draft
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
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions src/adcp/schemas/__init__.py
Original file line number Diff line number Diff line change
@@ -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.<NAME>`` 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."
)
77 changes: 77 additions & 0 deletions src/adcp/schemas/adcp-agents.json
Original file line number Diff line number Diff line change
@@ -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
}
83 changes: 3 additions & 80 deletions tests/test_discovery_endpoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
64 changes: 64 additions & 0 deletions tests/test_schemas_module.py
Original file line number Diff line number Diff line change
@@ -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)
Loading