From f5a13744b072450c602d08a2c471874b647e10fa Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 00:40:46 +0700 Subject: [PATCH 01/21] docs: pin SDK result model fields --- ...-08-27-agentcommunity-python-sdk-design.md | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md index e99b2fd..78e1624 100644 --- a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md +++ b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md @@ -93,19 +93,38 @@ All exported result models are frozen Pydantic models with strict validation and ### `CommunityStats` -Represents the complete structured result of `get_community_stats` using the field names and types from the immutable MCP contract fixture. +Represents the complete structured result of `get_community_stats`: + +- `member_count: int` +- `note: str` ### `MemberMatch` -Represents one member match from `lookup_member` using the field names and types from the immutable MCP contract fixture. +Represents one member match from `lookup_member`: + +- `display_name: str` +- `member_since: datetime.date | None` +- `profile_url: pydantic.AnyUrl` ### `MemberLookup` -Represents the complete lookup response and contains the normalized query/result metadata and a tuple of `MemberMatch` records, according to the immutable contract fixture. +Represents the complete lookup response: + +- `status: Literal["member", "not_found", "ambiguous"]` +- `matches: tuple[MemberMatch, ...]`, limited to at most five items ### `CertificateVerification` -Represents all four public certificate outcomes. Model validation enforces these cross-field invariants: +Represents all four public certificate outcomes with these fields: + +- `certificate_id: str` +- `status: Literal["invalid_format", "not_found", "issued", "unavailable"]` +- `valid_format: bool` +- `issued: bool | None` +- `agent_name: str | None` +- `certificate_url: pydantic.AnyUrl | None` + +Model validation enforces these cross-field invariants: | Status | `valid_format` | `issued` | Agent and URL fields | |---|---:|---:|---| @@ -114,7 +133,7 @@ Represents all four public certificate outcomes. Model validation enforces these | `issued` | `true` | `true` | present | | `unavailable` | `true` | `null` | absent | -The exact field names and URL representation come from the pinned MCP contract, not an inferred example response. +The field names and URL/date representations come from the pinned MCP contract, not an inferred example response. ## Error model @@ -234,4 +253,3 @@ Version 0.1 is ready for publication when: - The pinned MCP contract fixture has the approved digest. - Release metadata points to the official domain and source repository. - No secrets, publication credentials, registration calls, or website advertisement are included. - From a99467f278ca13c2d7588af392f14ecb0debdf64 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 00:42:31 +0700 Subject: [PATCH 02/21] docs: plan Python SDK implementation --- .../2026-08-27-agentcommunity-python-sdk.md | 425 ++++++++++++++++++ 1 file changed, 425 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md diff --git a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md new file mode 100644 index 0000000..f2aa0a3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md @@ -0,0 +1,425 @@ +# Agent Community Python SDK Implementation Plan + +> **For Codex:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` to execute this plan task by task. Every implementation task follows `superpowers:test-driven-development`, then receives spec-compliance and code-quality reviews before the next task. + +**Goal:** Build and release-prepare a small, typed, asynchronous Python SDK for the three read-only tools at `https://agentcommunity.org/mcp`. + +**Architecture:** `AgentCommunityClient` is an async context-manager wrapper around the official high-level `mcp.Client`. A private transport adapter owns MCP lifecycle and exception translation; public methods validate arguments and turn required `structured_content` into strict, frozen Pydantic models. Tests pin the immutable 1.5.0 MCP contract and exercise the real MCP client stack locally, while production smoke checks stay optional and read-only. + +**Tech Stack:** Python 3.10+, MCP Python SDK 2.x, Pydantic 2.x, Hatchling, pytest/AnyIO, Ruff, mypy, GitHub Actions, PyPI Trusted Publishing. + +**Design reference:** `docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md` + +--- + +## Task 1: Scaffold packaging and pin the immutable MCP contract + +**Files:** + +- Create: `pyproject.toml` +- Create: `src/agentcommunity/__init__.py` +- Create: `src/agentcommunity/py.typed` +- Create: `tests/contracts/1.5.0/mcp.json` +- Create: `tests/test_contract_fixture.py` +- Create: `tests/conftest.py` + +### Step 1: Write the failing fixture tests + +Create `tests/test_contract_fixture.py` with tests that: + +- Read `tests/contracts/1.5.0/mcp.json` as bytes and assert SHA-256 `53c6c703f1dcf2bb28a9d31287f001d9644d64900ed39edf027f77bf01640e87`. +- Parse JSON and assert `advertised_revision == "2026-07-28"`. +- Index `product_tools` by name and assert the three supported tools exist with their exact input/output schemas relevant to this SDK: + - `get_community_stats`: empty object input; required `member_count`, `note`. + - `lookup_member`: required `query`, length 1..200; output status enum, maximum five matches, required match fields. + - `verify_certificate`: required `certificate_id`; exact four-value status enum and required output fields. +- Assert `register_agent` exists in the fixture but is deliberately absent from the package export surface. + +Do not fetch production in a test. + +### Step 2: Run the test to verify it fails + +Run: `python -m pytest tests/test_contract_fixture.py -q` + +Expected: FAIL because package/test dependencies and the fixture do not exist yet. + +### Step 3: Add package metadata and the exact fixture + +Create a PEP 621 `pyproject.toml` using Hatchling with: + +- Distribution `agentcommunity`, version `0.1.0`, Python `>=3.10`, MIT metadata. +- Homepage `https://agentcommunity.org`, documentation `https://agentcommunity.org/mcp/docs`, source `https://github.com/agentcommunity/agentcommunity-python`, issues URL, and typed-package classifier. +- Dependencies exactly bounded as `mcp>=2.1.1,<3` and `pydantic>=2.12,<3`. +- Dev dependency groups sufficient for pytest, AnyIO, Ruff, mypy, build, and Twine. +- Ruff target Python 3.10 and a focused rule set; mypy strict mode for `src` and `tests`; pytest test path/configuration. +- Hatch wheel package path `src/agentcommunity` and include `py.typed`. + +Create a minimal `src/agentcommunity/__init__.py` exposing only `__version__ = "0.1.0"` for now. Add the empty `py.typed` marker. + +Copy the already verified immutable bytes from: + +`https://agentcommunity.org/.well-known/agentcommunity-contracts/1.5.0/mcp.json` + +into `tests/contracts/1.5.0/mcp.json`. Immediately verify its local SHA-256. The file is test evidence and must not be reformatted. + +Use `tests/conftest.py` only for focused reusable fixture loading; do not create global magic or network fixtures. + +### Step 4: Install and run focused checks + +Create a local virtual environment and install the package with development dependencies using the project metadata. Run: + +```bash +python -m pytest tests/test_contract_fixture.py -q +python -m ruff check . +python -m ruff format --check . +python -m mypy src tests +python -m build +python -m twine check dist/* +``` + +Expected: all pass. Remove generated `dist/` after verifying it is ignored. + +### Step 5: Commit + +```bash +git add pyproject.toml src/agentcommunity tests +git commit -m "build: scaffold Python SDK package" +``` + +## Task 2: Implement strict public models and exception types + +**Files:** + +- Create: `src/agentcommunity/errors.py` +- Create: `src/agentcommunity/models.py` +- Modify: `src/agentcommunity/__init__.py` +- Create: `tests/test_errors.py` +- Create: `tests/test_models.py` + +### Step 1: Write failing model and exception tests + +Test the exact public hierarchy and model behavior: + +- Four exception classes exist and inherit as specified in the design. +- `CommunityStats(member_count=..., note=...)` accepts exact valid data; rejects bool-as-int, string-as-int, mutation, and extra keys. +- `MemberMatch` parses an ISO date into `datetime.date`, accepts `None`, parses a valid absolute profile URL, and rejects invalid dates/URLs/extras. +- `MemberLookup` supports statuses `member`, `not_found`, `ambiguous`, stores matches immutably, rejects a sixth match, invalid status, list mutation, and extras. +- `CertificateVerification` accepts each of the four valid state combinations and rejects every inconsistent combination of status, `valid_format`, `issued`, and nullable agent/URL fields. +- Strict validation rejects coercion and unknown fields throughout nested models. +- `agentcommunity.__all__` exposes only the approved version, four result models, client placeholder only once it exists, and four exceptions; it never exports `register_agent` or internals. + +Use table-driven tests for certificate states and invalid cross-field cases. + +### Step 2: Run tests to verify failure + +Run: `python -m pytest tests/test_models.py tests/test_errors.py -q` + +Expected: FAIL with missing modules/types. + +### Step 3: Implement the minimum public data layer + +In `errors.py`, define: + +- `AgentCommunityError` +- `AgentCommunityTransportError` +- `AgentCommunityProtocolError` +- `AgentCommunityToolError` + +All specific errors inherit from `AgentCommunityError`. Keep constructors ordinary unless later transport code needs safe structured context. + +In `models.py`: + +- Use a shared private strict/frozen/extra-forbid Pydantic base model. +- Implement the exact fields from the design. +- Use `datetime.date`, `pydantic.AnyUrl`, `Literal`, and immutable tuples. +- Apply a maximum length of five to `MemberLookup.matches`. +- Use an after-model validator for certificate state invariants with a clear error message. + +Update package exports. Do not add client behavior, helper factories, serialization wrappers, or extra models. + +### Step 4: Verify and refactor + +Run: + +```bash +python -m pytest tests/test_models.py tests/test_errors.py tests/test_contract_fixture.py -q +python -m ruff check src tests +python -m ruff format --check src tests +python -m mypy src tests +``` + +Expected: all pass. + +### Step 5: Commit + +```bash +git add src/agentcommunity tests/test_models.py tests/test_errors.py +git commit -m "feat: add strict SDK result models" +``` + +## Task 3: Implement client lifecycle and error translation + +**Files:** + +- Create: `src/agentcommunity/_transport.py` +- Create: `src/agentcommunity/client.py` +- Modify: `src/agentcommunity/__init__.py` +- Create: `tests/fakes.py` +- Create: `tests/test_client_lifecycle.py` +- Create: `tests/test_client_errors.py` + +### Step 1: Write failing lifecycle tests + +Use a small injected factory/fake at the private transport seam. Test: + +- Default endpoint is `https://agentcommunity.org/mcp` and default timeout is 15 seconds. +- Endpoint and positive finite timeout can be overridden; invalid timeout or endpoint types/values fail locally. +- `async with AgentCommunityClient()` opens one underlying `mcp.Client` and closes it once. +- A connected client supports multiple sequential calls without reconnecting. +- Calls before enter and after exit raise `AgentCommunityProtocolError`. +- Re-entering an active instance fails predictably. +- `close()` is safe and idempotent, including after context exit. +- Cancellation during connect or call propagates as `asyncio.CancelledError` (or the active cancellation class), not a package error. + +Keep the injection mechanism private. Tests may import a private constructor/factory seam directly, but it must not appear in `agentcommunity.__all__` or user documentation. + +### Step 2: Write failing error-translation tests + +Test: + +- Connection and generic MCP/HTTP failures become `AgentCommunityTransportError` with the original error as `__cause__`. +- Timeout during connection or tool call becomes `AgentCommunityTransportError` naming the operation and configured timeout, preserving the timeout cause. +- A tool result with `is_error=True` becomes `AgentCommunityToolError`, retaining bounded safe text from result content. +- A successful result without `structured_content` becomes `AgentCommunityProtocolError`. +- A model validation failure becomes `AgentCommunityProtocolError` and preserves the Pydantic validation error as cause. +- Cancellation is explicitly excluded from translation. + +### Step 3: Run tests to verify failure + +Run: `python -m pytest tests/test_client_lifecycle.py tests/test_client_errors.py -q` + +Expected: FAIL because client/transport do not exist. + +### Step 4: Implement lifecycle and one private typed-call primitive + +In `_transport.py`, implement the minimum adapter around `mcp.Client(endpoint)`: + +- Own async enter/exit and a single active high-level MCP client. +- Apply timeout to connect, close, and tool operations using cancellation-safe AnyIO/Python primitives compatible with Python 3.10. +- Translate transport failures while never catching `BaseException` or caller cancellation. +- Call tools using the public MCP SDK API. + +In `client.py`, implement: + +- Constructor validation and immutable configuration properties if needed for documentation/tests. +- Context management and idempotent `close()`. +- A private `_call_typed(tool_name, arguments, model_type)` that checks `is_error`, requires `structured_content`, and validates with the provided model. +- No public capability methods yet beyond private hooks needed for lifecycle tests. + +Bound diagnostic text to a conservative maximum and do not include raw internal objects. Preserve causes with `raise ... from error`. + +Update exports to include `AgentCommunityClient`. Do not expose the raw client/session or private transport. + +### Step 5: Verify and commit + +Run: + +```bash +python -m pytest tests/test_client_lifecycle.py tests/test_client_errors.py -q +python -m pytest -q +python -m ruff check src tests +python -m ruff format --check src tests +python -m mypy src tests +``` + +Expected: all pass. + +```bash +git add src/agentcommunity tests/fakes.py tests/test_client_lifecycle.py tests/test_client_errors.py +git commit -m "feat: add asynchronous MCP client lifecycle" +``` + +## Task 4: Add the three typed methods and real MCP-stack integration tests + +**Files:** + +- Modify: `src/agentcommunity/client.py` +- Modify: `tests/fakes.py` +- Create: `tests/test_client_methods.py` +- Create: `tests/test_mcp_integration.py` +- Create: `tests/test_contract_compatibility.py` + +### Step 1: Write failing public-method tests + +Test exact tool calls and typed returns: + +- `community_stats()` calls `get_community_stats` with `{}` and returns `CommunityStats`. +- `lookup_member(query)` trims input, calls `lookup_member` with the trimmed value, and returns `MemberLookup`. +- Lookup accepts exactly 1 and 200 trimmed characters; empty/whitespace, 201 characters, non-string values, and malformed local input fail before transport. +- `verify_certificate(certificate_id)` passes strings through unchanged—including malformed identifiers—and returns `CertificateVerification` for all four states. +- Non-string certificate IDs fail locally, but no certificate-format regex exists. +- Malformed structured output for each method becomes `AgentCommunityProtocolError`. +- An extra/unrelated tool in server discovery does not affect any method. +- `AgentCommunityClient` has no `register_agent`, raw client, session, one-shot, or sync method/property. + +### Step 2: Write failing contract-compatibility tests + +Read the pinned fixture and verify: + +- Each method's emitted argument dictionary satisfies its input schema constraints. +- Every fixture-defined valid output shape maps directly to the corresponding public model fields. +- Model required fields, status literals, match maximum, date/URL types, and certificate nullable fields remain aligned with the fixture. +- The package wraps only the three approved tool names even though `register_agent` exists in the contract. + +Avoid a general JSON Schema code generator. The contract tests should clearly identify drift while keeping handwritten public models readable. + +### Step 3: Write a failing official-stack integration test + +Use the official MCP SDK's supported in-process/local server and client facilities to exercise a deterministic local endpoint through the actual `mcp.Client` code path. Register read-only test implementations of the three supported tools with structured outputs, then prove: + +- One client connection performs all three calls. +- Results become the public typed models. +- Lifecycle closes cleanly. + +Do not call production and do not register the unsafe `register_agent` tool. + +If the SDK's supported in-memory transport cannot be passed through the public high-level client used in production, use a loopback Streamable HTTP server bound to an ephemeral port with reliable startup/cleanup. Do not replace the integration with a mocked fake. + +### Step 4: Implement the exact public methods + +Add only: + +- `community_stats` +- `lookup_member` +- `verify_certificate` + +Each delegates to `_call_typed`. Keep validation small and explicit. Do not list tools as a prerequisite for calls; runtime tolerance of additional tools follows from calling only known names. + +### Step 5: Verify and commit + +Run: + +```bash +python -m pytest tests/test_client_methods.py tests/test_contract_compatibility.py tests/test_mcp_integration.py -q +python -m pytest -q +python -m ruff check . +python -m ruff format --check . +python -m mypy src tests +``` + +Expected: all pass with no network access. + +```bash +git add src/agentcommunity/client.py tests +git commit -m "feat: wrap read-only Agent Community tools" +``` + +## Task 5: Complete documentation, CI, release, and artifact verification + +**Files:** + +- Create: `README.md` +- Create: `SECURITY.md` +- Create: `CONTRIBUTING.md` +- Create: `CHANGELOG.md` +- Create: `LICENSE` +- Create: `.github/workflows/ci.yml` +- Create: `.github/workflows/production-smoke.yml` +- Create: `.github/workflows/publish.yml` +- Create: `tests/test_public_api.py` +- Create: `tests/test_readme_examples.py` +- Create: `tests/smoke/test_production.py` +- Create: `scripts/verify-artifacts.sh` +- Modify: `pyproject.toml` + +### Step 1: Write failing public-surface and README tests + +Add tests that: + +- Assert `agentcommunity.__all__` exactly contains `__version__`, `AgentCommunityClient`, four result models, and four exceptions. +- Assert no `register_agent`, sync facade, one-shot helper, raw MCP client/session, or CLI entry point is public. +- Extract or import the README's main async example and execute it against the deterministic private test transport/server without production network access. +- Validate project metadata includes the official homepage/source/docs/issues URLs, Python floor, dependency bounds, license, and typed marker. + +Keep production smoke tests excluded by default with an explicit marker/configuration. + +### Step 2: Create user and contributor documentation + +Write a concise README covering installation, a complete async example, lifecycle reuse, all three methods/models, exception handling, endpoint/timeout overrides, support policy, and links. State clearly that registration is intentionally omitted because it requires explicit user authorization. + +Add: + +- MIT `LICENSE`. +- `SECURITY.md` with private disclosure instructions and supported-version policy. +- `CONTRIBUTING.md` with environment setup and the full local gate. +- `CHANGELOG.md` with an unreleased/0.1.0 entry describing the initial read-only SDK. + +Do not claim the package is already available on PyPI or that the website already advertises it. + +### Step 3: Add deterministic CI and optional production smoke + +`ci.yml` must run on pull requests and pushes, with separate coverage for: + +- Python 3.10 and latest stable Python. +- Lowest-direct-dependency bounds and newest allowed dependencies. +- Ruff check/format, strict mypy, full pytest, build, and Twine check. +- Clean wheel installation/import and source-distribution installation. +- Running the artifact verifier against the exact built files. + +`production-smoke.yml` must be scheduled and manually dispatchable only. It calls read-only production methods, has a short timeout, no secrets, no registration, and is not referenced as a required pull-request check. + +`publish.yml` must: + +- Trigger on version tags or an appropriately protected manual flow. +- Build wheel/sdist once. +- Verify those exact files in a separate clean job/environment. +- Publish the unchanged downloaded artifacts with PyPI Trusted Publishing (`id-token: write`) and provenance. +- Store no token and contain no TestPyPI/PyPI credential placeholder. + +### Step 4: Implement and exercise artifact verification + +Create `scripts/verify-artifacts.sh` as a strict, portable Bash script that accepts an artifact directory, creates temporary isolated virtual environments, then: + +- Runs `twine check` on wheel and source distribution. +- Installs the wheel without the source tree on `PYTHONPATH`, imports the exact public surface, checks version, and runs a minimal deterministic fake/local example. +- Installs the source distribution in another clean environment and performs the same import/public-surface check. +- Always cleans temporary directories. + +Do not publish or contact package registries. + +### Step 5: Run the full local release gate + +Run from a clean working tree after deleting old artifacts: + +```bash +python -m ruff check . +python -m ruff format --check . +python -m mypy src tests +python -m pytest -q +python -m build +python -m twine check dist/* +bash scripts/verify-artifacts.sh dist +``` + +Also inspect wheel and source distribution contents and verify no secrets, caches, tests-only helpers, or unexpected files ship. The pinned contract fixture may remain test-only and should not ship in the wheel. + +Expected: all pass. + +### Step 6: Commit + +```bash +git add README.md SECURITY.md CONTRIBUTING.md CHANGELOG.md LICENSE .github scripts tests pyproject.toml +git commit -m "chore: prepare SDK release pipeline" +``` + +## Final verification and review + +After every task has passed its specification and code-quality reviews: + +1. Run the full local release gate again from current HEAD. +2. Run `git diff --check main...HEAD` and inspect `git diff --stat main...HEAD` plus the complete diff. +3. Dispatch `superpowers:requesting-code-review` using base SHA from `main` and current HEAD. +4. Resolve every Critical and Important finding, rerun affected and full checks, and request re-review when necessary. +5. Apply `superpowers:verification-before-completion` before claiming success. +6. Apply `superpowers:finishing-a-development-branch` and present the four integration choices. Do not create a remote, push, open a pull request, configure PyPI, publish, or modify the website without explicit user authorization. + From 6e80d908be39d3613124f87f49a9797ea858cc29 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 00:47:41 +0700 Subject: [PATCH 03/21] build: scaffold Python SDK package --- pyproject.toml | 63 ++ src/agentcommunity/__init__.py | 3 + src/agentcommunity/py.typed | 0 tests/conftest.py | 18 + tests/contracts/1.5.0/mcp.json | 1286 ++++++++++++++++++++++++++++++++ tests/test_contract_fixture.py | 132 ++++ 6 files changed, 1502 insertions(+) create mode 100644 pyproject.toml create mode 100644 src/agentcommunity/__init__.py create mode 100644 src/agentcommunity/py.typed create mode 100644 tests/conftest.py create mode 100644 tests/contracts/1.5.0/mcp.json create mode 100644 tests/test_contract_fixture.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b0f770a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["hatchling>=1.27,<1.28"] +build-backend = "hatchling.build" + +[project] +name = "agentcommunity" +version = "0.1.0" +description = "Typed Python client for the Agent Community MCP endpoint" +readme = { text = "Official typed Python client for the Agent Community MCP endpoint.", content-type = "text/markdown" } +requires-python = ">=3.10" +license = "MIT" +classifiers = [ + "Development Status :: 3 - Alpha", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] +dependencies = [ + "mcp>=2.1.1,<3", + "pydantic>=2.12,<3", +] + +[project.urls] +Homepage = "https://agentcommunity.org" +Documentation = "https://agentcommunity.org/mcp/docs" +Source = "https://github.com/agentcommunity/agentcommunity-python" +Issues = "https://github.com/agentcommunity/agentcommunity-python/issues" + +[project.optional-dependencies] +dev = [ + "anyio>=4.10,<5", + "build>=1.3,<2", + "mypy>=1.17,<2", + "pytest>=8.4,<9", + "ruff>=0.12,<1", + "twine>=6.1,<7", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/agentcommunity"] + +[tool.pytest.ini_options] +addopts = "-ra" +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.ruff] +target-version = "py310" +line-length = 88 + +[tool.ruff.lint] +select = ["B", "E", "F", "I", "RUF", "SIM", "UP"] + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src", "tests"] +warn_unused_configs = true diff --git a/src/agentcommunity/__init__.py b/src/agentcommunity/__init__.py new file mode 100644 index 0000000..de48b44 --- /dev/null +++ b/src/agentcommunity/__init__.py @@ -0,0 +1,3 @@ +__version__ = "0.1.0" + +__all__ = ["__version__"] diff --git a/src/agentcommunity/py.typed b/src/agentcommunity/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2a178ec --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,18 @@ +import json +from pathlib import Path +from typing import Any + +import pytest + + +@pytest.fixture +def contract_bytes() -> bytes: + contract_path = Path(__file__).parent / "contracts" / "1.5.0" / "mcp.json" + return contract_path.read_bytes() + + +@pytest.fixture +def contract(contract_bytes: bytes) -> dict[str, Any]: + parsed = json.loads(contract_bytes) + assert isinstance(parsed, dict) + return parsed diff --git a/tests/contracts/1.5.0/mcp.json b/tests/contracts/1.5.0/mcp.json new file mode 100644 index 0000000..c3fb8ab --- /dev/null +++ b/tests/contracts/1.5.0/mcp.json @@ -0,0 +1,1286 @@ +{ + "advertised_revision": "2026-07-28", + "docs_tools": [ + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Search only published Agent Community documentation. Does not search members, registrations, private lists, or authenticated data.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 5, + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "query": { + "maxLength": 300, + "minLength": 2, + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_docs", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "count": { + "maximum": 10, + "minimum": 0, + "type": "integer" + }, + "results": { + "items": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "excerpt": { + "maxLength": 320, + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "title", + "description", + "path", + "url", + "excerpt" + ], + "type": "object" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "results", + "count" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "invalid_arguments", + "unknown_tool", + "document_not_found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "type": "object" + }, + "title": "Search Agent Community documentation" + }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Read one published Agent Community documentation path as Markdown. Allowed paths are /developers, /docs, and /docs/*.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "path": { + "maxLength": 300, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "name": "get_doc", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "markdown": { + "maxLength": 100000, + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "title", + "url", + "markdown" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "invalid_arguments", + "unknown_tool", + "document_not_found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "type": "object" + }, + "title": "Read an Agent Community document" + } + ], + "fixtures": { + "legacy_parameter_exceptions": [ + { + "behavior": "initialize with an empty params object is invalid", + "direction": "rejects_previously_accepted", + "endpoint": "/mcp", + "request": { + "body": { + "id": "fixture-product-legacy", + "jsonrpc": "2.0", + "method": "initialize", + "params": {} + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + } + }, + "response": { + "body": { + "error": { + "code": -32602, + "message": "Invalid params" + }, + "id": "fixture-product-legacy", + "jsonrpc": "2.0" + }, + "http_status": 200 + } + }, + { + "behavior": "legacy tools/list normalizes an array params value to an empty object", + "direction": "accepts_previously_rejected", + "endpoint": "/mcp/docs", + "request": { + "body": { + "id": "fixture-docs-legacy", + "jsonrpc": "2.0", + "method": "tools/list", + "params": [] + }, + "headers": { + "Accept": "application/json", + "Content-Type": "application/json" + } + }, + "response": { + "body": { + "id": "fixture-docs-legacy", + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Search only published Agent Community documentation. Does not search members, registrations, private lists, or authenticated data.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "limit": { + "default": 5, + "maximum": 10, + "minimum": 1, + "type": "integer" + }, + "query": { + "maxLength": 300, + "minLength": 2, + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_docs", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "count": { + "maximum": 10, + "minimum": 0, + "type": "integer" + }, + "results": { + "items": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "excerpt": { + "maxLength": 320, + "type": "string" + }, + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "title", + "description", + "path", + "url", + "excerpt" + ], + "type": "object" + }, + "maxItems": 10, + "type": "array" + } + }, + "required": [ + "results", + "count" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "invalid_arguments", + "unknown_tool", + "document_not_found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "type": "object" + }, + "title": "Search Agent Community documentation" + }, + { + "annotations": { + "destructiveHint": false, + "idempotentHint": true, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Read one published Agent Community documentation path as Markdown. Allowed paths are /developers, /docs, and /docs/*.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "path": { + "maxLength": 300, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "path" + ], + "type": "object" + }, + "name": "get_doc", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "markdown": { + "maxLength": 100000, + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "title", + "url", + "markdown" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "code": { + "enum": [ + "invalid_arguments", + "unknown_tool", + "document_not_found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + } + }, + "required": [ + "error" + ], + "type": "object" + } + ], + "type": "object" + }, + "title": "Read an Agent Community document" + } + ] + } + }, + "http_status": 200 + } + } + ], + "modern": { + "tool_calls": [ + { + "request": { + "body": { + "id": "fixture-member-call", + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "@agentcommunity/cli", + "version": "0.0.0-fixture" + }, + "io.modelcontextprotocol/protocolVersion": "2026-07-28" + }, + "arguments": { + "query": "fixture-member" + }, + "name": "lookup_member" + } + }, + "endpoint": "/mcp", + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "lookup_member" + } + }, + "response": { + "body": { + "id": "fixture-member-call", + "jsonrpc": "2.0", + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "icons": [ + { + "mimeType": "image/png", + "sizes": [ + "180x180" + ], + "src": "https://agentcommunity.org/apple-touch-icon.png" + } + ], + "name": "agentcommunity", + "title": "Agent Community", + "version": "1.0.0", + "websiteUrl": "https://agentcommunity.org" + } + }, + "content": [ + { + "text": "{\"status\":\"member\",\"matches\":[{\"display_name\":\"[fixture] Member\",\"member_since\":null,\"profile_url\":\"https://agentcommunity.org/m/fixture-member\"}]}", + "type": "text" + } + ], + "resultType": "complete", + "structuredContent": { + "matches": [ + { + "display_name": "[fixture] Member", + "member_since": null, + "profile_url": "https://agentcommunity.org/m/fixture-member" + } + ], + "status": "member" + } + } + }, + "http_status": 200 + }, + "tool": "lookup_member" + }, + { + "request": { + "body": { + "id": "fixture-stats-call", + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "@agentcommunity/cli", + "version": "0.0.0-fixture" + }, + "io.modelcontextprotocol/protocolVersion": "2026-07-28" + }, + "arguments": {}, + "name": "get_community_stats" + } + }, + "endpoint": "/mcp", + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "get_community_stats" + } + }, + "response": { + "body": { + "id": "fixture-stats-call", + "jsonrpc": "2.0", + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "icons": [ + { + "mimeType": "image/png", + "sizes": [ + "180x180" + ], + "src": "https://agentcommunity.org/apple-touch-icon.png" + } + ], + "name": "agentcommunity", + "title": "Agent Community", + "version": "1.0.0", + "websiteUrl": "https://agentcommunity.org" + } + }, + "content": [ + { + "text": "{\"member_count\":0,\"note\":\"Fixture-only value; not production member-count evidence.\"}", + "type": "text" + } + ], + "resultType": "complete", + "structuredContent": { + "member_count": 0, + "note": "Fixture-only value; not production member-count evidence." + } + } + }, + "http_status": 200 + }, + "tool": "get_community_stats" + }, + { + "request": { + "body": { + "id": "fixture-certificate-call", + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "_meta": { + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "@agentcommunity/cli", + "version": "0.0.0-fixture" + }, + "io.modelcontextprotocol/protocolVersion": "2026-07-28" + }, + "arguments": { + "certificate_id": "FIXTURE-NOT-A-CERTIFICATE" + }, + "name": "verify_certificate" + } + }, + "endpoint": "/mcp", + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/call", + "Mcp-Name": "verify_certificate" + } + }, + "response": { + "body": { + "id": "fixture-certificate-call", + "jsonrpc": "2.0", + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "icons": [ + { + "mimeType": "image/png", + "sizes": [ + "180x180" + ], + "src": "https://agentcommunity.org/apple-touch-icon.png" + } + ], + "name": "agentcommunity", + "title": "Agent Community", + "version": "1.0.0", + "websiteUrl": "https://agentcommunity.org" + } + }, + "content": [ + { + "text": "{\"certificate_id\":\"FIXTURE-NOT-A-CERTIFICATE\",\"status\":\"invalid_format\",\"valid_format\":false,\"issued\":false,\"agent_name\":null,\"certificate_url\":null}", + "type": "text" + } + ], + "resultType": "complete", + "structuredContent": { + "agent_name": null, + "certificate_id": "FIXTURE-NOT-A-CERTIFICATE", + "certificate_url": null, + "issued": false, + "status": "invalid_format", + "valid_format": false + } + } + }, + "http_status": 200 + }, + "tool": "verify_certificate" + } + ], + "tools_list": { + "request": { + "body": { + "id": "fixture-tools-list", + "jsonrpc": "2.0", + "method": "tools/list", + "params": { + "_meta": { + "io.modelcontextprotocol/clientCapabilities": {}, + "io.modelcontextprotocol/clientInfo": { + "name": "@agentcommunity/cli", + "version": "0.0.0-fixture" + }, + "io.modelcontextprotocol/protocolVersion": "2026-07-28" + } + } + }, + "endpoint": "/mcp", + "headers": { + "Accept": "application/json", + "Content-Type": "application/json", + "MCP-Protocol-Version": "2026-07-28", + "Mcp-Method": "tools/list" + } + }, + "response": { + "body": { + "id": "fixture-tools-list", + "jsonrpc": "2.0", + "result": { + "_meta": { + "io.modelcontextprotocol/serverInfo": { + "icons": [ + { + "mimeType": "image/png", + "sizes": [ + "180x180" + ], + "src": "https://agentcommunity.org/apple-touch-icon.png" + } + ], + "name": "agentcommunity", + "title": "Agent Community", + "version": "1.0.0", + "websiteUrl": "https://agentcommunity.org" + } + }, + "cacheScope": "public", + "resultType": "complete", + "tools": [ + { + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Look up one public member by exact display name or slug. This is not free-text directory search and returns only minimal public information.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "query": { + "description": "Exact display name or slug; not free-text search.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "lookup_member", + "outputSchema": { + "additionalProperties": false, + "properties": { + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string" + }, + "member_since": { + "format": "date", + "type": [ + "string", + "null" + ] + }, + "profile_url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "display_name", + "member_since", + "profile_url" + ], + "type": "object" + }, + "maxItems": 5, + "type": "array" + }, + "status": { + "enum": [ + "member", + "not_found", + "ambiguous" + ], + "type": "string" + } + }, + "required": [ + "status", + "matches" + ], + "type": "object" + }, + "title": "Look up member" + }, + { + "_meta": { + "ui": { + "resourceUri": "ui://agentcommunity/community-stats.html" + }, + "ui/resourceUri": "ui://agentcommunity/community-stats.html" + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Get the roughly-live Agent Community member count. The count is cached for up to 20 minutes.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "get_community_stats", + "outputSchema": { + "additionalProperties": false, + "properties": { + "member_count": { + "type": "integer" + }, + "note": { + "type": "string" + } + }, + "required": [ + "member_count", + "note" + ], + "type": "object" + }, + "title": "Get community stats" + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "description": "Pre-register a .agent identity through the DMV. This is free and non-binding, does not confer domain ownership, and should run only after an explicit user request.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent_name": { + "description": "Desired name without the .agent suffix.", + "maxLength": 63, + "minLength": 3, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", + "type": "string" + }, + "description": { + "description": "Optional description.", + "maxLength": 500, + "type": "string" + }, + "email": { + "description": "Email address for confirmation.", + "format": "email", + "type": "string" + }, + "operator_name": { + "description": "Person or organization operating the agent.", + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "agent_name", + "email", + "operator_name" + ], + "type": "object" + }, + "name": "register_agent", + "outputSchema": { + "additionalProperties": false, + "properties": { + "agent_name": { + "type": "string" + }, + "already_recorded": { + "type": "boolean" + }, + "badge_card_url": { + "format": "uri", + "type": "string" + }, + "badge_url": { + "format": "uri", + "type": "string" + }, + "certificate_id": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "message": { + "type": "string" + }, + "permalink_url": { + "format": "uri", + "type": "string" + }, + "registration_type": { + "type": "string" + } + }, + "required": [ + "certificate_id", + "agent_name", + "domain", + "registration_type", + "permalink_url", + "badge_url", + "badge_card_url", + "message" + ], + "type": "object" + }, + "title": "Pre-register .agent name" + }, + { + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Verify the live DMV issuance state of a canonical certificate ID. This does not confer domain ownership or mean the .agent TLD exists in DNS.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "certificate_id": { + "description": "Certificate ID, e.g. MESA-DD6-660J.", + "type": "string" + } + }, + "required": [ + "certificate_id" + ], + "type": "object" + }, + "name": "verify_certificate", + "outputSchema": { + "additionalProperties": false, + "properties": { + "agent_name": { + "type": [ + "string", + "null" + ] + }, + "certificate_id": { + "type": "string" + }, + "certificate_url": { + "format": "uri", + "type": [ + "string", + "null" + ] + }, + "issued": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "invalid_format", + "not_found", + "issued", + "unavailable" + ], + "type": "string" + }, + "valid_format": { + "type": "boolean" + } + }, + "required": [ + "certificate_id", + "status", + "valid_format", + "issued", + "agent_name", + "certificate_url" + ], + "type": "object" + }, + "title": "Verify DMV certificate" + } + ], + "ttlMs": 300000 + } + }, + "http_status": 200 + } + } + } + }, + "product_tools": [ + { + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Look up one public member by exact display name or slug. This is not free-text directory search and returns only minimal public information.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "query": { + "description": "Exact display name or slug; not free-text search.", + "maxLength": 200, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "lookup_member", + "outputSchema": { + "additionalProperties": false, + "properties": { + "matches": { + "items": { + "additionalProperties": false, + "properties": { + "display_name": { + "type": "string" + }, + "member_since": { + "format": "date", + "type": [ + "string", + "null" + ] + }, + "profile_url": { + "format": "uri", + "type": "string" + } + }, + "required": [ + "display_name", + "member_since", + "profile_url" + ], + "type": "object" + }, + "maxItems": 5, + "type": "array" + }, + "status": { + "enum": [ + "member", + "not_found", + "ambiguous" + ], + "type": "string" + } + }, + "required": [ + "status", + "matches" + ], + "type": "object" + }, + "title": "Look up member" + }, + { + "_meta": { + "ui": { + "resourceUri": "ui://agentcommunity/community-stats.html" + }, + "ui/resourceUri": "ui://agentcommunity/community-stats.html" + }, + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Get the roughly-live Agent Community member count. The count is cached for up to 20 minutes.", + "inputSchema": { + "additionalProperties": false, + "properties": {}, + "type": "object" + }, + "name": "get_community_stats", + "outputSchema": { + "additionalProperties": false, + "properties": { + "member_count": { + "type": "integer" + }, + "note": { + "type": "string" + } + }, + "required": [ + "member_count", + "note" + ], + "type": "object" + }, + "title": "Get community stats" + }, + { + "annotations": { + "destructiveHint": true, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false + }, + "description": "Pre-register a .agent identity through the DMV. This is free and non-binding, does not confer domain ownership, and should run only after an explicit user request.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "agent_name": { + "description": "Desired name without the .agent suffix.", + "maxLength": 63, + "minLength": 3, + "pattern": "^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$", + "type": "string" + }, + "description": { + "description": "Optional description.", + "maxLength": 500, + "type": "string" + }, + "email": { + "description": "Email address for confirmation.", + "format": "email", + "type": "string" + }, + "operator_name": { + "description": "Person or organization operating the agent.", + "maxLength": 100, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "agent_name", + "email", + "operator_name" + ], + "type": "object" + }, + "name": "register_agent", + "outputSchema": { + "additionalProperties": false, + "properties": { + "agent_name": { + "type": "string" + }, + "already_recorded": { + "type": "boolean" + }, + "badge_card_url": { + "format": "uri", + "type": "string" + }, + "badge_url": { + "format": "uri", + "type": "string" + }, + "certificate_id": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "message": { + "type": "string" + }, + "permalink_url": { + "format": "uri", + "type": "string" + }, + "registration_type": { + "type": "string" + } + }, + "required": [ + "certificate_id", + "agent_name", + "domain", + "registration_type", + "permalink_url", + "badge_url", + "badge_card_url", + "message" + ], + "type": "object" + }, + "title": "Pre-register .agent name" + }, + { + "annotations": { + "destructiveHint": false, + "openWorldHint": false, + "readOnlyHint": true + }, + "description": "Verify the live DMV issuance state of a canonical certificate ID. This does not confer domain ownership or mean the .agent TLD exists in DNS.", + "inputSchema": { + "additionalProperties": false, + "properties": { + "certificate_id": { + "description": "Certificate ID, e.g. MESA-DD6-660J.", + "type": "string" + } + }, + "required": [ + "certificate_id" + ], + "type": "object" + }, + "name": "verify_certificate", + "outputSchema": { + "additionalProperties": false, + "properties": { + "agent_name": { + "type": [ + "string", + "null" + ] + }, + "certificate_id": { + "type": "string" + }, + "certificate_url": { + "format": "uri", + "type": [ + "string", + "null" + ] + }, + "issued": { + "type": [ + "boolean", + "null" + ] + }, + "status": { + "enum": [ + "invalid_format", + "not_found", + "issued", + "unavailable" + ], + "type": "string" + }, + "valid_format": { + "type": "boolean" + } + }, + "required": [ + "certificate_id", + "status", + "valid_format", + "issued", + "agent_name", + "certificate_url" + ], + "type": "object" + }, + "title": "Verify DMV certificate" + } + ], + "supported_revisions": [ + "2026-07-28", + "2025-11-25", + "2025-06-18", + "2025-03-26", + "2024-11-05" + ] +} diff --git a/tests/test_contract_fixture.py b/tests/test_contract_fixture.py new file mode 100644 index 0000000..76d7e90 --- /dev/null +++ b/tests/test_contract_fixture.py @@ -0,0 +1,132 @@ +import hashlib +from typing import Any + +EXPECTED_CONTRACT_SHA256 = ( + "53c6c703f1dcf2bb28a9d31287f001d9644d64900ed39edf027f77bf01640e87" +) + + +def test_contract_fixture_has_approved_digest(contract_bytes: bytes) -> None: + assert hashlib.sha256(contract_bytes).hexdigest() == EXPECTED_CONTRACT_SHA256 + + +def test_contract_fixture_pins_advertised_revision( + contract: dict[str, Any], +) -> None: + assert contract["advertised_revision"] == "2026-07-28" + + +def test_get_community_stats_schema_is_pinned(contract: dict[str, Any]) -> None: + tools = {tool["name"]: tool for tool in contract["product_tools"]} + tool = tools["get_community_stats"] + + assert tool["inputSchema"] == { + "type": "object", + "properties": {}, + "additionalProperties": False, + } + assert tool["outputSchema"] == { + "type": "object", + "properties": { + "member_count": {"type": "integer"}, + "note": {"type": "string"}, + }, + "required": ["member_count", "note"], + "additionalProperties": False, + } + + +def test_lookup_member_schema_is_pinned(contract: dict[str, Any]) -> None: + tools = {tool["name"]: tool for tool in contract["product_tools"]} + tool = tools["lookup_member"] + + assert tool["inputSchema"] == { + "type": "object", + "properties": { + "query": { + "description": "Exact display name or slug; not free-text search.", + "maxLength": 200, + "minLength": 1, + "type": "string", + } + }, + "required": ["query"], + "additionalProperties": False, + } + assert tool["outputSchema"] == { + "type": "object", + "properties": { + "status": {"type": "string", "enum": ["member", "not_found", "ambiguous"]}, + "matches": { + "type": "array", + "maxItems": 5, + "items": { + "type": "object", + "properties": { + "display_name": {"type": "string"}, + "member_since": { + "format": "date", + "type": ["string", "null"], + }, + "profile_url": {"type": "string", "format": "uri"}, + }, + "required": ["display_name", "member_since", "profile_url"], + "additionalProperties": False, + }, + }, + }, + "required": ["status", "matches"], + "additionalProperties": False, + } + + +def test_verify_certificate_schema_is_pinned(contract: dict[str, Any]) -> None: + tools = {tool["name"]: tool for tool in contract["product_tools"]} + tool = tools["verify_certificate"] + + assert tool["inputSchema"] == { + "type": "object", + "properties": { + "certificate_id": { + "description": "Certificate ID, e.g. MESA-DD6-660J.", + "type": "string", + } + }, + "required": ["certificate_id"], + "additionalProperties": False, + } + assert tool["outputSchema"] == { + "type": "object", + "properties": { + "certificate_id": {"type": "string"}, + "status": { + "type": "string", + "enum": ["invalid_format", "not_found", "issued", "unavailable"], + }, + "valid_format": {"type": "boolean"}, + "issued": {"type": ["boolean", "null"]}, + "agent_name": {"type": ["string", "null"]}, + "certificate_url": { + "format": "uri", + "type": ["string", "null"], + }, + }, + "required": [ + "certificate_id", + "status", + "valid_format", + "issued", + "agent_name", + "certificate_url", + ], + "additionalProperties": False, + } + + +def test_registration_is_not_exported(contract: dict[str, Any]) -> None: + import agentcommunity + + tool_names = {tool["name"] for tool in contract["product_tools"]} + assert "register_agent" in tool_names + assert "register_agent" not in getattr(agentcommunity, "__all__", ()) + assert not hasattr(agentcommunity, "register_agent") From 06a7c1d740424d81e57eed46265e68a4552faa76 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 00:58:41 +0700 Subject: [PATCH 04/21] fix: exclude worktree metadata from sdist --- pyproject.toml | 2 +- tests/test_build_artifacts.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 tests/test_build_artifacts.py diff --git a/pyproject.toml b/pyproject.toml index b0f770a..73b37a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["hatchling>=1.27,<1.28"] +requires = ["hatchling>=1.30.1,<1.31"] build-backend = "hatchling.build" [project] diff --git a/tests/test_build_artifacts.py b/tests/test_build_artifacts.py new file mode 100644 index 0000000..3c85ec0 --- /dev/null +++ b/tests/test_build_artifacts.py @@ -0,0 +1,34 @@ +import re +import subprocess +import sys +import tarfile +from pathlib import Path, PurePosixPath + + +def test_sdist_excludes_local_worktree_metadata(tmp_path: Path) -> None: + subprocess.run( + [ + sys.executable, + "-m", + "build", + "--sdist", + "--outdir", + str(tmp_path), + ], + check=True, + capture_output=True, + text=True, + ) + (sdist_path,) = tmp_path.glob("*.tar.gz") + + with tarfile.open(sdist_path, mode="r:gz") as archive: + members = archive.getmembers() + assert all(".git" not in PurePosixPath(member.name).parts for member in members) + + for member in members: + if not member.isfile(): + continue + extracted = archive.extractfile(member) + assert extracted is not None + contents = extracted.read() + assert re.search(rb"gitdir:\s*(?:/|[A-Za-z]:[\\/])", contents) is None From f9a7b7a7ea452c4a134347bf72990b288c2fda20 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 01:07:18 +0700 Subject: [PATCH 05/21] feat: add strict SDK result models --- src/agentcommunity/__init__.py | 25 ++- src/agentcommunity/errors.py | 14 ++ src/agentcommunity/models.py | 76 +++++++++ tests/test_errors.py | 31 ++++ tests/test_models.py | 271 +++++++++++++++++++++++++++++++++ 5 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 src/agentcommunity/errors.py create mode 100644 src/agentcommunity/models.py create mode 100644 tests/test_errors.py create mode 100644 tests/test_models.py diff --git a/src/agentcommunity/__init__.py b/src/agentcommunity/__init__.py index de48b44..23b82e1 100644 --- a/src/agentcommunity/__init__.py +++ b/src/agentcommunity/__init__.py @@ -1,3 +1,26 @@ +from agentcommunity.errors import ( + AgentCommunityError, + AgentCommunityProtocolError, + AgentCommunityToolError, + AgentCommunityTransportError, +) +from agentcommunity.models import ( + CertificateVerification, + CommunityStats, + MemberLookup, + MemberMatch, +) + __version__ = "0.1.0" -__all__ = ["__version__"] +__all__ = [ + "AgentCommunityError", + "AgentCommunityProtocolError", + "AgentCommunityToolError", + "AgentCommunityTransportError", + "CertificateVerification", + "CommunityStats", + "MemberLookup", + "MemberMatch", + "__version__", +] diff --git a/src/agentcommunity/errors.py b/src/agentcommunity/errors.py new file mode 100644 index 0000000..e63efbe --- /dev/null +++ b/src/agentcommunity/errors.py @@ -0,0 +1,14 @@ +class AgentCommunityError(Exception): + """Base exception for Agent Community SDK errors.""" + + +class AgentCommunityTransportError(AgentCommunityError): + """Raised when communication with the Agent Community endpoint fails.""" + + +class AgentCommunityProtocolError(AgentCommunityError): + """Raised when an MCP response violates the expected protocol.""" + + +class AgentCommunityToolError(AgentCommunityError): + """Raised when an Agent Community MCP tool reports an error.""" diff --git a/src/agentcommunity/models.py b/src/agentcommunity/models.py new file mode 100644 index 0000000..aa14e6a --- /dev/null +++ b/src/agentcommunity/models.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +import datetime +from typing import Any, Literal + +from pydantic import ( + AnyUrl, + BaseModel, + ConfigDict, + Field, + field_validator, + model_validator, +) + + +class _StrictModel(BaseModel): + model_config = ConfigDict(strict=True, frozen=True, extra="forbid") + + +class CommunityStats(_StrictModel): + member_count: int + note: str + + +class MemberMatch(_StrictModel): + display_name: str + member_since: datetime.date | None + profile_url: AnyUrl + + @field_validator("member_since", mode="before") + @classmethod + def parse_member_since(cls, value: Any) -> Any: + if isinstance(value, str): + return datetime.date.fromisoformat(value) + return value + + +class MemberLookup(_StrictModel): + status: Literal["member", "not_found", "ambiguous"] + matches: tuple[MemberMatch, ...] = Field(max_length=5) + + @field_validator("matches", mode="before") + @classmethod + def freeze_matches(cls, value: Any) -> Any: + if isinstance(value, list): + return tuple(value) + return value + + +class CertificateVerification(_StrictModel): + certificate_id: str + status: Literal["invalid_format", "not_found", "issued", "unavailable"] + valid_format: bool + issued: bool | None + agent_name: str | None + certificate_url: AnyUrl | None + + @model_validator(mode="after") + def validate_certificate_state(self) -> CertificateVerification: + expected = { + "invalid_format": (False, False, False, False), + "not_found": (True, False, False, False), + "issued": (True, True, True, True), + "unavailable": (True, None, False, False), + }[self.status] + actual = ( + self.valid_format, + self.issued, + self.agent_name is not None, + self.certificate_url is not None, + ) + if actual != expected: + raise ValueError( + f"certificate state is inconsistent with status {self.status!r}" + ) + return self diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..3a14cc5 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,31 @@ +import agentcommunity + + +def test_public_exception_hierarchy() -> None: + assert issubclass( + agentcommunity.AgentCommunityTransportError, + agentcommunity.AgentCommunityError, + ) + assert issubclass( + agentcommunity.AgentCommunityProtocolError, + agentcommunity.AgentCommunityError, + ) + assert issubclass( + agentcommunity.AgentCommunityToolError, + agentcommunity.AgentCommunityError, + ) + + +def test_public_exports_are_exact() -> None: + assert agentcommunity.__all__ == [ + "AgentCommunityError", + "AgentCommunityProtocolError", + "AgentCommunityToolError", + "AgentCommunityTransportError", + "CertificateVerification", + "CommunityStats", + "MemberLookup", + "MemberMatch", + "__version__", + ] + assert not hasattr(agentcommunity, "register_agent") diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d481e02 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,271 @@ +import datetime +import itertools +from typing import Any + +import pytest +from pydantic import ValidationError + +from agentcommunity import ( + CertificateVerification, + CommunityStats, + MemberLookup, + MemberMatch, +) + + +def test_community_stats_accepts_exact_valid_data() -> None: + stats = CommunityStats(member_count=29_700, note="Verified members") + + assert stats.member_count == 29_700 + assert stats.note == "Verified members" + + +@pytest.mark.parametrize("member_count", [True, "29700"]) +def test_community_stats_rejects_integer_coercion(member_count: object) -> None: + with pytest.raises(ValidationError): + CommunityStats(member_count=member_count, note="Verified members") # type: ignore[arg-type] + + +def test_community_stats_is_frozen_and_forbids_extras() -> None: + stats = CommunityStats(member_count=29_700, note="Verified members") + + with pytest.raises(ValidationError): + stats.member_count = 1 + with pytest.raises(ValidationError): + CommunityStats(member_count=29_700, note="Verified members", extra=True) # type: ignore[call-arg] + + +def test_member_match_parses_contract_date_and_absolute_url() -> None: + match = MemberMatch.model_validate( + { + "display_name": "Example Agent", + "member_since": "2026-08-27", + "profile_url": "https://agentcommunity.org/m/example-agent", + } + ) + + assert match.member_since == datetime.date(2026, 8, 27) + assert str(match.profile_url) == "https://agentcommunity.org/m/example-agent" + + +def test_member_match_accepts_null_date() -> None: + match = MemberMatch.model_validate( + { + "display_name": "Example Agent", + "member_since": None, + "profile_url": "https://agentcommunity.org/m/example-agent", + } + ) + + assert match.member_since is None + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("member_since", "2026-02-30"), + ("member_since", 20_260_827), + ("profile_url", "/m/example-agent"), + ("display_name", 123), + ], +) +def test_member_match_rejects_invalid_or_coerced_fields( + field: str, value: object +) -> None: + data: dict[str, object] = { + "display_name": "Example Agent", + "member_since": "2026-08-27", + "profile_url": "https://agentcommunity.org/m/example-agent", + } + data[field] = value + + with pytest.raises(ValidationError): + MemberMatch.model_validate(data) + + +def test_member_match_forbids_extras() -> None: + with pytest.raises(ValidationError): + MemberMatch.model_validate( + { + "display_name": "Example Agent", + "member_since": None, + "profile_url": "https://agentcommunity.org/m/example-agent", + "slug": "example-agent", + } + ) + + +def make_match(index: int = 0) -> dict[str, object]: + return { + "display_name": f"Example Agent {index}", + "member_since": "2026-08-27", + "profile_url": f"https://agentcommunity.org/m/example-agent-{index}", + } + + +@pytest.mark.parametrize("status", ["member", "not_found", "ambiguous"]) +def test_member_lookup_accepts_exact_statuses_and_immutable_matches( + status: str, +) -> None: + lookup = MemberLookup.model_validate({"status": status, "matches": [make_match()]}) + + assert isinstance(lookup.matches, tuple) + assert isinstance(lookup.matches[0], MemberMatch) + with pytest.raises(ValidationError): + lookup.matches = () + + +def test_member_lookup_rejects_sixth_match_invalid_status_and_extras() -> None: + with pytest.raises(ValidationError): + MemberLookup.model_validate( + {"status": "ambiguous", "matches": [make_match(i) for i in range(6)]} + ) + with pytest.raises(ValidationError): + MemberLookup.model_validate({"status": "unknown", "matches": []}) + with pytest.raises(ValidationError): + MemberLookup.model_validate( + {"status": "member", "matches": [], "query": "Example"} + ) + + +def test_member_lookup_rejects_nested_extras() -> None: + nested_match = make_match() + nested_match["slug"] = "example-agent" + + with pytest.raises(ValidationError): + MemberLookup.model_validate({"status": "member", "matches": [nested_match]}) + + +VALID_CERTIFICATE_STATES: dict[ + str, tuple[bool, bool | None, str | None, str | None] +] = { + "invalid_format": (False, False, None, None), + "not_found": (True, False, None, None), + "issued": ( + True, + True, + "Example Agent", + "https://agentcommunity.org/certificates/AC-123", + ), + "unavailable": (True, None, None, None), +} + + +@pytest.mark.parametrize( + ("status", "state"), + VALID_CERTIFICATE_STATES.items(), +) +def test_certificate_verification_accepts_exact_valid_states( + status: str, + state: tuple[bool, bool | None, str | None, str | None], +) -> None: + valid_format, issued, agent_name, certificate_url = state + + result = CertificateVerification.model_validate( + { + "certificate_id": "AC-123", + "status": status, + "valid_format": valid_format, + "issued": issued, + "agent_name": agent_name, + "certificate_url": certificate_url, + } + ) + + assert result.status == status + assert result.valid_format is valid_format + assert result.issued is issued + assert result.agent_name == agent_name + if certificate_url is None: + assert result.certificate_url is None + else: + assert str(result.certificate_url) == certificate_url + + +INVALID_CERTIFICATE_STATES = [ + pytest.param(status, state, id=f"{status}-{state!r}") + for status, expected in VALID_CERTIFICATE_STATES.items() + for state in itertools.product( + (False, True), + (False, True, None), + (None, "Example Agent"), + (None, "https://agentcommunity.org/certificates/AC-123"), + ) + if state != expected +] + + +@pytest.mark.parametrize(("status", "state"), INVALID_CERTIFICATE_STATES) +def test_certificate_verification_rejects_every_inconsistent_state( + status: str, + state: tuple[bool, bool | None, str | None, str | None], +) -> None: + valid_format, issued, agent_name, certificate_url = state + + with pytest.raises(ValidationError, match="certificate state is inconsistent"): + CertificateVerification.model_validate( + { + "certificate_id": "AC-123", + "status": status, + "valid_format": valid_format, + "issued": issued, + "agent_name": agent_name, + "certificate_url": certificate_url, + } + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("certificate_id", 123), + ("status", "unknown"), + ("valid_format", 1), + ("issued", 0), + ("agent_name", 123), + ("certificate_url", "/certificates/AC-123"), + ], +) +def test_certificate_verification_rejects_invalid_or_coerced_fields( + field: str, value: Any +) -> None: + data: dict[str, Any] = { + "certificate_id": "AC-123", + "status": "issued", + "valid_format": True, + "issued": True, + "agent_name": "Example Agent", + "certificate_url": "https://agentcommunity.org/certificates/AC-123", + } + data[field] = value + + with pytest.raises(ValidationError): + CertificateVerification.model_validate(data) + + +def test_certificate_verification_is_frozen_and_forbids_extras() -> None: + result = CertificateVerification.model_validate( + { + "certificate_id": "AC-123", + "status": "not_found", + "valid_format": True, + "issued": False, + "agent_name": None, + "certificate_url": None, + } + ) + + with pytest.raises(ValidationError): + result.issued = True + with pytest.raises(ValidationError): + CertificateVerification.model_validate( + { + "certificate_id": "AC-123", + "status": "not_found", + "valid_format": True, + "issued": False, + "agent_name": None, + "certificate_url": None, + "detail": "missing", + } + ) From 660da9a1d8b7b033356afc8177878ffc2906800b Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 01:13:43 +0700 Subject: [PATCH 06/21] fix: enforce RFC 3339 member dates --- src/agentcommunity/models.py | 3 +++ tests/test_models.py | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/agentcommunity/models.py b/src/agentcommunity/models.py index aa14e6a..3a90a0c 100644 --- a/src/agentcommunity/models.py +++ b/src/agentcommunity/models.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime +import re from typing import Any, Literal from pydantic import ( @@ -31,6 +32,8 @@ class MemberMatch(_StrictModel): @classmethod def parse_member_since(cls, value: Any) -> Any: if isinstance(value, str): + if re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value) is None: + raise ValueError("member_since must use ASCII YYYY-MM-DD format") return datetime.date.fromisoformat(value) return value diff --git a/tests/test_models.py b/tests/test_models.py index d481e02..9a38290 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -60,6 +60,25 @@ def test_member_match_accepts_null_date() -> None: assert match.member_since is None +@pytest.mark.parametrize( + "member_since", + [ + "20260827", + "2026-W35-4", + "\uff12\uff10\uff12\uff16-\uff10\uff18-\uff12\uff17", + ], +) +def test_member_match_rejects_non_rfc3339_dates(member_since: str) -> None: + with pytest.raises(ValidationError): + MemberMatch.model_validate( + { + "display_name": "Example Agent", + "member_since": member_since, + "profile_url": "https://agentcommunity.org/m/example-agent", + } + ) + + @pytest.mark.parametrize( ("field", "value"), [ From c74ae20cfa76626b8aef9f35469af93e38eafd87 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 01:21:33 +0700 Subject: [PATCH 07/21] feat: add asynchronous MCP client lifecycle --- src/agentcommunity/__init__.py | 2 + src/agentcommunity/_transport.py | 115 +++++++++++++++++++++++ src/agentcommunity/client.py | 126 +++++++++++++++++++++++++ tests/fakes.py | 77 ++++++++++++++++ tests/test_client_errors.py | 142 ++++++++++++++++++++++++++++ tests/test_client_lifecycle.py | 154 +++++++++++++++++++++++++++++++ tests/test_errors.py | 1 + 7 files changed, 617 insertions(+) create mode 100644 src/agentcommunity/_transport.py create mode 100644 src/agentcommunity/client.py create mode 100644 tests/fakes.py create mode 100644 tests/test_client_errors.py create mode 100644 tests/test_client_lifecycle.py diff --git a/src/agentcommunity/__init__.py b/src/agentcommunity/__init__.py index 23b82e1..1c90c60 100644 --- a/src/agentcommunity/__init__.py +++ b/src/agentcommunity/__init__.py @@ -1,3 +1,4 @@ +from agentcommunity.client import AgentCommunityClient from agentcommunity.errors import ( AgentCommunityError, AgentCommunityProtocolError, @@ -14,6 +15,7 @@ __version__ = "0.1.0" __all__ = [ + "AgentCommunityClient", "AgentCommunityError", "AgentCommunityProtocolError", "AgentCommunityToolError", diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py new file mode 100644 index 0000000..14bee0b --- /dev/null +++ b/src/agentcommunity/_transport.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from collections.abc import Callable +from types import TracebackType +from typing import Any, Protocol, cast + +from anyio import fail_after +from mcp import Client +from mcp.types import CallToolResult + +from agentcommunity.errors import ( + AgentCommunityProtocolError, + AgentCommunityTransportError, +) + + +class _HighLevelClient(Protocol): + async def __aenter__(self) -> _HighLevelClient: ... + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: ... + + async def call_tool( + self, name: str, arguments: dict[str, Any] | None = None + ) -> CallToolResult: ... + + +_ClientFactory = Callable[[str], _HighLevelClient] + + +def _create_mcp_client(endpoint: str) -> _HighLevelClient: + return cast(_HighLevelClient, Client(endpoint)) + + +class _MCPTransport: + def __init__( + self, + endpoint: str, + timeout: float, + *, + client_factory: _ClientFactory | None = None, + ) -> None: + self._endpoint = endpoint + self._timeout = timeout + self._client_factory = client_factory or _create_mcp_client + self._client: _HighLevelClient | None = None + + @property + def active(self) -> bool: + return self._client is not None + + async def connect(self) -> None: + if self.active: + raise AgentCommunityProtocolError("Client context is already active") + + try: + client = self._client_factory(self._endpoint) + with fail_after(self._timeout): + await client.__aenter__() + except TimeoutError as error: + raise AgentCommunityTransportError( + f"Timed out while attempting to connect after {self._timeout:g} seconds" + ) from error + except Exception as error: + raise AgentCommunityTransportError( + "Failed to connect to the Agent Community MCP endpoint" + ) from error + + self._client = client + + async def close( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + client = self._client + if client is None: + return + self._client = None + + try: + with fail_after(self._timeout): + await client.__aexit__(exc_type, exc_value, traceback) + except TimeoutError as error: + raise AgentCommunityTransportError( + f"Timed out while attempting to close after {self._timeout:g} seconds" + ) from error + except Exception as error: + raise AgentCommunityTransportError( + "Failed to close the Agent Community MCP connection" + ) from error + + async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResult: + client = self._client + if client is None: + raise AgentCommunityProtocolError( + "Tool calls require an active context for this client" + ) + + try: + with fail_after(self._timeout): + return await client.call_tool(name, arguments) + except TimeoutError as error: + raise AgentCommunityTransportError( + f"Tool {name!r} timed out after {self._timeout:g} seconds" + ) from error + except Exception as error: + raise AgentCommunityTransportError( + f"Failed while calling MCP tool {name!r}" + ) from error diff --git a/src/agentcommunity/client.py b/src/agentcommunity/client.py new file mode 100644 index 0000000..01365cd --- /dev/null +++ b/src/agentcommunity/client.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import math +from types import TracebackType +from typing import Any, TypeVar +from urllib.parse import urlsplit + +from mcp.types import TextContent +from pydantic import BaseModel, ValidationError + +from agentcommunity._transport import _ClientFactory, _MCPTransport +from agentcommunity.errors import AgentCommunityProtocolError, AgentCommunityToolError + +_DEFAULT_ENDPOINT = "https://agentcommunity.org/mcp" +_DEFAULT_TIMEOUT = 15.0 +_MAX_TOOL_ERROR_TEXT = 500 + +_ModelT = TypeVar("_ModelT", bound=BaseModel) + + +class AgentCommunityClient: + def __init__( + self, + endpoint: str = _DEFAULT_ENDPOINT, + timeout: float = _DEFAULT_TIMEOUT, + *, + _client_factory: _ClientFactory | None = None, + ) -> None: + self._endpoint = _validate_endpoint(endpoint) + self._timeout = _validate_timeout(timeout) + self._transport = _MCPTransport( + self._endpoint, + self._timeout, + client_factory=_client_factory, + ) + + @property + def endpoint(self) -> str: + return self._endpoint + + @property + def timeout(self) -> float: + return self._timeout + + async def __aenter__(self) -> AgentCommunityClient: + if self._transport.active: + raise AgentCommunityProtocolError("Client context is already active") + await self._transport.connect() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + await self._transport.close(exc_type, exc_value, traceback) + + async def close(self) -> None: + await self._transport.close() + + async def _call_typed( + self, + tool_name: str, + arguments: dict[str, Any], + model_type: type[_ModelT], + ) -> _ModelT: + result = await self._transport.call_tool(tool_name, arguments) + if result.is_error: + diagnostic = _safe_tool_error_text(result.content) + message = f"MCP tool {tool_name!r} reported an error" + if diagnostic: + message = f"{message}: {diagnostic}" + raise AgentCommunityToolError(message) + if result.structured_content is None: + raise AgentCommunityProtocolError( + f"MCP tool {tool_name!r} returned no structured content" + ) + try: + return model_type.model_validate(result.structured_content) + except ValidationError as error: + raise AgentCommunityProtocolError( + f"MCP tool {tool_name!r} returned invalid structured content" + ) from error + + +def _validate_endpoint(endpoint: str) -> str: + if not isinstance(endpoint, str): + raise TypeError("endpoint must be a string") + if not endpoint or endpoint.strip() != endpoint: + raise ValueError("endpoint must be a non-empty absolute HTTP(S) URL") + if any(ord(character) <= 32 or ord(character) == 127 for character in endpoint): + raise ValueError("endpoint must not contain whitespace or control characters") + try: + parsed = urlsplit(endpoint) + parsed_port = parsed.port + except ValueError as error: + raise ValueError("endpoint is not a valid HTTP(S) URL") from error + if parsed.scheme not in {"http", "https"} or not parsed.netloc: + raise ValueError("endpoint must be an absolute HTTP(S) URL") + if parsed.hostname is None or ( + parsed_port is not None and not 0 < parsed_port < 65536 + ): + raise ValueError("endpoint must include a valid host and port") + if parsed.username is not None or parsed.password is not None: + raise ValueError("endpoint must not contain credentials") + if parsed.fragment: + raise ValueError("endpoint must not contain a fragment") + return endpoint + + +def _validate_timeout(timeout: float) -> float: + if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): + raise TypeError("timeout must be an int or float") + value = float(timeout) + if not math.isfinite(value) or value <= 0: + raise ValueError("timeout must be positive and finite") + return value + + +def _safe_tool_error_text(content: list[Any]) -> str: + text_parts = [item.text for item in content if isinstance(item, TextContent)] + normalized = " ".join(" ".join(text_parts).split()) + if len(normalized) <= _MAX_TOOL_ERROR_TEXT: + return normalized + return f"{normalized[: _MAX_TOOL_ERROR_TEXT - 3]}..." diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..500e43b --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from mcp.types import CallToolResult, TextContent + + +class FakeMCPClient: + def __init__(self) -> None: + self.enter_count = 0 + self.exit_count = 0 + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.connect_error: BaseException | None = None + self.close_error: BaseException | None = None + self.call_error: BaseException | None = None + self.connect_gate: asyncio.Event | None = None + self.call_gate: asyncio.Event | None = None + self.results: list[CallToolResult] = [] + + async def __aenter__(self) -> FakeMCPClient: + self.enter_count += 1 + if self.connect_gate is not None: + await self.connect_gate.wait() + if self.connect_error is not None: + raise self.connect_error + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: object, + ) -> None: + del exc_type, exc_value, traceback + self.exit_count += 1 + if self.close_error is not None: + raise self.close_error + + async def call_tool( + self, name: str, arguments: dict[str, Any] | None = None + ) -> CallToolResult: + self.calls.append((name, arguments or {})) + if self.call_gate is not None: + await self.call_gate.wait() + if self.call_error is not None: + raise self.call_error + if self.results: + return self.results.pop(0) + return successful_result() + + +class RecordingClientFactory: + def __init__(self, build: Callable[[], FakeMCPClient] = FakeMCPClient) -> None: + self._build = build + self.endpoints: list[str] = [] + self.clients: list[FakeMCPClient] = [] + + def __call__(self, endpoint: str) -> FakeMCPClient: + self.endpoints.append(endpoint) + client = self._build() + self.clients.append(client) + return client + + +def successful_result(structured_content: Any | None = None) -> CallToolResult: + if structured_content is None: + structured_content = {"member_count": 29_700, "note": "Verified members"} + return CallToolResult(content=[], structured_content=structured_content) + + +def error_result(text: str) -> CallToolResult: + return CallToolResult( + content=[TextContent(text=text)], + is_error=True, + ) diff --git a/tests/test_client_errors.py b/tests/test_client_errors.py new file mode 100644 index 0000000..7a3a0ca --- /dev/null +++ b/tests/test_client_errors.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import asyncio + +import pytest +from fakes import ( + FakeMCPClient, + RecordingClientFactory, + error_result, + successful_result, +) +from pydantic import ValidationError + +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + AgentCommunityToolError, + AgentCommunityTransportError, +) +from agentcommunity.models import CommunityStats + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio +async def test_connection_failure_is_translated_with_cause() -> None: + upstream = OSError("connection refused") + fake = FakeMCPClient() + fake.connect_error = upstream + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + with pytest.raises(AgentCommunityTransportError, match="connect") as captured: + await client.__aenter__() + + assert captured.value.__cause__ is upstream + + +@pytest.mark.anyio +async def test_tool_transport_failure_is_translated_with_cause() -> None: + upstream = RuntimeError("MCP stream ended") + fake = FakeMCPClient() + fake.call_error = upstream + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises( + AgentCommunityTransportError, match="get_community_stats" + ) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + assert captured.value.__cause__ is upstream + + +@pytest.mark.anyio +async def test_connection_timeout_names_operation_and_duration() -> None: + fake = FakeMCPClient() + fake.connect_gate = asyncio.Event() + client = AgentCommunityClient( + timeout=0.01, _client_factory=RecordingClientFactory(lambda: fake) + ) + + with pytest.raises( + AgentCommunityTransportError, match=r"connect.*0\.01" + ) as captured: + await client.__aenter__() + + assert isinstance(captured.value.__cause__, TimeoutError) + + +@pytest.mark.anyio +async def test_tool_timeout_names_operation_and_duration() -> None: + fake = FakeMCPClient() + fake.call_gate = asyncio.Event() + client = AgentCommunityClient( + timeout=0.01, _client_factory=RecordingClientFactory(lambda: fake) + ) + + async with client: + with pytest.raises( + AgentCommunityTransportError, + match=r"get_community_stats.*0\.01", + ) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + assert isinstance(captured.value.__cause__, TimeoutError) + + +@pytest.mark.anyio +async def test_tool_error_uses_only_bounded_safe_text() -> None: + fake = FakeMCPClient() + fake.results = [error_result(" public\nmessage " + "x" * 1_000)] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(AgentCommunityToolError) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + message = str(captured.value) + assert "public message" in message + assert len(message) <= 600 + assert "TextContent" not in message + + +@pytest.mark.anyio +async def test_missing_structured_content_is_protocol_error() -> None: + fake = FakeMCPClient() + fake.results = [successful_result(None)] + fake.results[0].structured_content = None + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(AgentCommunityProtocolError, match="structured content"): + await client._call_typed("get_community_stats", {}, CommunityStats) + + +@pytest.mark.anyio +async def test_model_validation_failure_is_protocol_error_with_cause() -> None: + fake = FakeMCPClient() + fake.results = [successful_result({"member_count": "many", "note": "bad"})] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises( + AgentCommunityProtocolError, match="invalid structured" + ) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + assert isinstance(captured.value.__cause__, ValidationError) + + +@pytest.mark.anyio +async def test_cancelled_error_is_not_translated() -> None: + fake = FakeMCPClient() + fake.call_error = asyncio.CancelledError() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(asyncio.CancelledError): + await client._call_typed("get_community_stats", {}, CommunityStats) diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py new file mode 100644 index 0000000..598ff4e --- /dev/null +++ b/tests/test_client_lifecycle.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import asyncio +import math + +import pytest +from fakes import FakeMCPClient, RecordingClientFactory, successful_result + +from agentcommunity import AgentCommunityClient, AgentCommunityProtocolError +from agentcommunity.models import CommunityStats + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def test_default_and_overridden_configuration() -> None: + default_client = AgentCommunityClient() + overridden_client = AgentCommunityClient( + endpoint="http://localhost:8765/mcp?tenant=test", timeout=2 + ) + + assert default_client.endpoint == "https://agentcommunity.org/mcp" + assert default_client.timeout == 15.0 + assert overridden_client.endpoint == "http://localhost:8765/mcp?tenant=test" + assert overridden_client.timeout == 2.0 + + +@pytest.mark.parametrize( + "endpoint", + [ + None, + 123, + "", + " agentcommunity.org/mcp", + "agentcommunity.org/mcp", + "ftp://agentcommunity.org/mcp", + "https:///mcp", + "https://user:secret@agentcommunity.org/mcp", + "https://agentcommunity.org/mcp#fragment", + "https://agent community.org/mcp", + ], +) +def test_invalid_endpoints_fail_locally(endpoint: object) -> None: + with pytest.raises((TypeError, ValueError)): + AgentCommunityClient(endpoint=endpoint) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + "timeout", [None, True, "15", 0, -1, math.inf, -math.inf, math.nan] +) +def test_invalid_timeouts_fail_locally(timeout: object) -> None: + with pytest.raises((TypeError, ValueError)): + AgentCommunityClient(timeout=timeout) # type: ignore[arg-type] + + +@pytest.mark.anyio +async def test_context_opens_and_closes_exactly_one_underlying_client() -> None: + factory = RecordingClientFactory() + client = AgentCommunityClient(_client_factory=factory) + + async with client as entered: + assert entered is client + assert len(factory.clients) == 1 + assert factory.clients[0].enter_count == 1 + + assert factory.endpoints == ["https://agentcommunity.org/mcp"] + assert factory.clients[0].exit_count == 1 + + +@pytest.mark.anyio +async def test_one_connection_supports_multiple_sequential_calls() -> None: + fake = FakeMCPClient() + fake.results = [ + successful_result({"member_count": 1, "note": "first"}), + successful_result({"member_count": 2, "note": "second"}), + ] + factory = RecordingClientFactory(lambda: fake) + client = AgentCommunityClient(_client_factory=factory) + + async with client: + first = await client._call_typed("first", {}, CommunityStats) + second = await client._call_typed("second", {}, CommunityStats) + + assert (first.member_count, second.member_count) == (1, 2) + assert fake.enter_count == 1 + assert fake.exit_count == 1 + assert fake.calls == [("first", {}), ("second", {})] + + +@pytest.mark.anyio +async def test_calls_outside_context_raise_protocol_error() -> None: + client = AgentCommunityClient(_client_factory=RecordingClientFactory()) + + with pytest.raises(AgentCommunityProtocolError, match="active context"): + await client._call_typed("before", {}, CommunityStats) + + async with client: + pass + + with pytest.raises(AgentCommunityProtocolError, match="active context"): + await client._call_typed("after", {}, CommunityStats) + + +@pytest.mark.anyio +async def test_reentering_active_client_fails_predictably() -> None: + client = AgentCommunityClient(_client_factory=RecordingClientFactory()) + + async with client: + with pytest.raises(AgentCommunityProtocolError, match="already active"): + await client.__aenter__() + + +@pytest.mark.anyio +async def test_close_is_idempotent_before_and_after_context_exit() -> None: + factory = RecordingClientFactory() + client = AgentCommunityClient(_client_factory=factory) + + await client.close() + await client.close() + async with client: + pass + await client.close() + await client.close() + + assert factory.clients[0].exit_count == 1 + + +@pytest.mark.anyio +async def test_cancellation_during_connect_propagates_unchanged() -> None: + fake = FakeMCPClient() + fake.connect_gate = asyncio.Event() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + task = asyncio.create_task(client.__aenter__()) + await asyncio.sleep(0) + + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.anyio +async def test_cancellation_during_tool_call_propagates_unchanged() -> None: + fake = FakeMCPClient() + fake.call_gate = asyncio.Event() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + task = asyncio.create_task(client._call_typed("blocked", {}, CommunityStats)) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task diff --git a/tests/test_errors.py b/tests/test_errors.py index 3a14cc5..52712d6 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -18,6 +18,7 @@ def test_public_exception_hierarchy() -> None: def test_public_exports_are_exact() -> None: assert agentcommunity.__all__ == [ + "AgentCommunityClient", "AgentCommunityError", "AgentCommunityProtocolError", "AgentCommunityToolError", From f51cb98bfbd4542f9db1b01b535a1fd8c735dfa2 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 01:26:46 +0700 Subject: [PATCH 08/21] fix: harden client lifecycle validation --- src/agentcommunity/_transport.py | 3 +- src/agentcommunity/client.py | 19 ++++++++ tests/fakes.py | 5 ++ tests/test_client_lifecycle.py | 84 +++++++++++++++++++++++++++++++- 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py index 14bee0b..c8dfd4f 100644 --- a/src/agentcommunity/_transport.py +++ b/src/agentcommunity/_transport.py @@ -81,7 +81,6 @@ async def close( client = self._client if client is None: return - self._client = None try: with fail_after(self._timeout): @@ -94,6 +93,8 @@ async def close( raise AgentCommunityTransportError( "Failed to close the Agent Community MCP connection" ) from error + else: + self._client = None async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResult: client = self._client diff --git a/src/agentcommunity/client.py b/src/agentcommunity/client.py index 01365cd..5ae3389 100644 --- a/src/agentcommunity/client.py +++ b/src/agentcommunity/client.py @@ -14,6 +14,7 @@ _DEFAULT_ENDPOINT = "https://agentcommunity.org/mcp" _DEFAULT_TIMEOUT = 15.0 _MAX_TOOL_ERROR_TEXT = 500 +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") _ModelT = TypeVar("_ModelT", bound=BaseModel) @@ -98,6 +99,11 @@ def _validate_endpoint(endpoint: str) -> str: raise ValueError("endpoint is not a valid HTTP(S) URL") from error if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ValueError("endpoint must be an absolute HTTP(S) URL") + if any( + _has_invalid_percent_escape(component) + for component in (parsed.netloc, parsed.path, parsed.query) + ): + raise ValueError("endpoint contains an invalid percent escape") if parsed.hostname is None or ( parsed_port is not None and not 0 < parsed_port < 65536 ): @@ -109,6 +115,19 @@ def _validate_endpoint(endpoint: str) -> str: return endpoint +def _has_invalid_percent_escape(value: str) -> bool: + index = 0 + while index < len(value): + if value[index] != "%": + index += 1 + continue + escape = value[index + 1 : index + 3] + if len(escape) != 2 or not set(escape) <= _HEX_DIGITS: + return True + index += 3 + return False + + def _validate_timeout(timeout: float) -> float: if isinstance(timeout, bool) or not isinstance(timeout, (int, float)): raise TypeError("timeout must be an int or float") diff --git a/tests/fakes.py b/tests/fakes.py index 500e43b..75feb27 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -11,11 +11,13 @@ class FakeMCPClient: def __init__(self) -> None: self.enter_count = 0 self.exit_count = 0 + self.successful_exit_count = 0 self.calls: list[tuple[str, dict[str, Any]]] = [] self.connect_error: BaseException | None = None self.close_error: BaseException | None = None self.call_error: BaseException | None = None self.connect_gate: asyncio.Event | None = None + self.close_gate: asyncio.Event | None = None self.call_gate: asyncio.Event | None = None self.results: list[CallToolResult] = [] @@ -35,8 +37,11 @@ async def __aexit__( ) -> None: del exc_type, exc_value, traceback self.exit_count += 1 + if self.close_gate is not None: + await self.close_gate.wait() if self.close_error is not None: raise self.close_error + self.successful_exit_count += 1 async def call_tool( self, name: str, arguments: dict[str, Any] | None = None diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 598ff4e..5b0779e 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -6,7 +6,11 @@ import pytest from fakes import FakeMCPClient, RecordingClientFactory, successful_result -from agentcommunity import AgentCommunityClient, AgentCommunityProtocolError +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + AgentCommunityTransportError, +) from agentcommunity.models import CommunityStats @@ -40,6 +44,9 @@ def test_default_and_overridden_configuration() -> None: "https://user:secret@agentcommunity.org/mcp", "https://agentcommunity.org/mcp#fragment", "https://agent community.org/mcp", + "https://%zz/mcp", + "https://example.com/%zz", + "https://example.com/mcp?cursor=%0x", ], ) def test_invalid_endpoints_fail_locally(endpoint: object) -> None: @@ -55,6 +62,20 @@ def test_invalid_timeouts_fail_locally(timeout: object) -> None: AgentCommunityClient(timeout=timeout) # type: ignore[arg-type] +@pytest.mark.parametrize( + "endpoint", + [ + "https://%65xample.com/mcp", + "https://example.com/a%20path", + "https://example.com/mcp?cursor=a%2Fb", + ], +) +def test_valid_percent_escapes_are_preserved(endpoint: str) -> None: + client = AgentCommunityClient(endpoint=endpoint) + + assert client.endpoint == endpoint + + @pytest.mark.anyio async def test_context_opens_and_closes_exactly_one_underlying_client() -> None: factory = RecordingClientFactory() @@ -127,6 +148,67 @@ async def test_close_is_idempotent_before_and_after_context_exit() -> None: assert factory.clients[0].exit_count == 1 +@pytest.mark.anyio +async def test_close_timeout_retains_connection_for_successful_retry() -> None: + fake = FakeMCPClient() + fake.close_gate = asyncio.Event() + client = AgentCommunityClient( + timeout=0.01, _client_factory=RecordingClientFactory(lambda: fake) + ) + await client.__aenter__() + + with pytest.raises(AgentCommunityTransportError, match="close"): + await client.close() + + fake.close_gate.set() + await client.close() + await client.close() + + assert fake.exit_count == 2 + assert fake.successful_exit_count == 1 + + +@pytest.mark.anyio +async def test_close_failure_retains_connection_for_successful_retry() -> None: + upstream = OSError("close failed") + fake = FakeMCPClient() + fake.close_error = upstream + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + await client.__aenter__() + + with pytest.raises(AgentCommunityTransportError) as captured: + await client.close() + assert captured.value.__cause__ is upstream + + fake.close_error = None + await client.close() + await client.close() + + assert fake.exit_count == 2 + assert fake.successful_exit_count == 1 + + +@pytest.mark.anyio +async def test_close_cancellation_retains_connection_for_successful_retry() -> None: + fake = FakeMCPClient() + fake.close_gate = asyncio.Event() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + await client.__aenter__() + + task = asyncio.create_task(client.close()) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + fake.close_gate.set() + await client.close() + await client.close() + + assert fake.exit_count == 2 + assert fake.successful_exit_count == 1 + + @pytest.mark.anyio async def test_cancellation_during_connect_propagates_unchanged() -> None: fake = FakeMCPClient() From 95ea619805197e1a546387f205768d5bc38b3b2b Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 01:41:07 +0700 Subject: [PATCH 09/21] fix: serialize MCP client lifecycle --- src/agentcommunity/_transport.py | 94 ++++++++++++++++++-- src/agentcommunity/client.py | 37 ++++++-- tests/test_client_errors.py | 47 ++++++++++ tests/test_client_lifecycle.py | 143 +++++++++++++++++++++++++++++-- 4 files changed, 299 insertions(+), 22 deletions(-) diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py index c8dfd4f..3de10d6 100644 --- a/src/agentcommunity/_transport.py +++ b/src/agentcommunity/_transport.py @@ -1,12 +1,14 @@ from __future__ import annotations from collections.abc import Callable +from enum import Enum, auto from types import TracebackType from typing import Any, Protocol, cast -from anyio import fail_after +from anyio import fail_after, get_current_task from mcp import Client from mcp.types import CallToolResult +from pydantic import ValidationError from agentcommunity.errors import ( AgentCommunityProtocolError, @@ -32,6 +34,16 @@ async def call_tool( _ClientFactory = Callable[[str], _HighLevelClient] +class _LifecycleState(Enum): + NEW = auto() + CONNECTING = auto() + OPEN = auto() + CALLING = auto() + CLOSING = auto() + CLOSE_FAILED = auto() + CLOSED = auto() + + def _create_mcp_client(endpoint: str) -> _HighLevelClient: return cast(_HighLevelClient, Client(endpoint)) @@ -48,15 +60,28 @@ def __init__( self._timeout = timeout self._client_factory = client_factory or _create_mcp_client self._client: _HighLevelClient | None = None + self._state = _LifecycleState.NEW + self._owner_task_id: int | None = None @property def active(self) -> bool: - return self._client is not None + return self._state not in {_LifecycleState.NEW, _LifecycleState.CLOSED} async def connect(self) -> None: - if self.active: + if self._state is _LifecycleState.CONNECTING: + raise AgentCommunityProtocolError("Client is already connecting") + if self._state is _LifecycleState.CLOSING: + raise AgentCommunityProtocolError("Client is currently closing") + if self._state is _LifecycleState.CLOSE_FAILED: + raise AgentCommunityProtocolError( + "Client has a failed close; retry close before reconnecting" + ) + if self._state in {_LifecycleState.OPEN, _LifecycleState.CALLING}: raise AgentCommunityProtocolError("Client context is already active") + previous_state = self._state + self._state = _LifecycleState.CONNECTING + self._owner_task_id = get_current_task().id try: client = self._client_factory(self._endpoint) with fail_after(self._timeout): @@ -69,8 +94,13 @@ async def connect(self) -> None: raise AgentCommunityTransportError( "Failed to connect to the Agent Community MCP endpoint" ) from error - - self._client = client + else: + self._client = client + self._state = _LifecycleState.OPEN + finally: + if self._state is _LifecycleState.CONNECTING: + self._state = previous_state + self._owner_task_id = None async def close( self, @@ -78,31 +108,72 @@ async def close( exc_value: BaseException | None = None, traceback: TracebackType | None = None, ) -> None: + if self._state in {_LifecycleState.NEW, _LifecycleState.CLOSED}: + return + if self._state is _LifecycleState.CONNECTING: + raise AgentCommunityProtocolError("Cannot close while client is connecting") + if self._state is _LifecycleState.CALLING: + raise AgentCommunityProtocolError( + "Cannot close while a tool call is active" + ) + if self._state is _LifecycleState.CLOSING: + raise AgentCommunityProtocolError("Client close is already in progress") + if get_current_task().id != self._owner_task_id: + raise AgentCommunityProtocolError( + "Client must be closed in the same task that entered it" + ) + client = self._client if client is None: - return + raise AgentCommunityProtocolError("Client lifecycle state is inconsistent") + self._state = _LifecycleState.CLOSING try: with fail_after(self._timeout): await client.__aexit__(exc_type, exc_value, traceback) except TimeoutError as error: + self._state = _LifecycleState.CLOSE_FAILED raise AgentCommunityTransportError( f"Timed out while attempting to close after {self._timeout:g} seconds" ) from error except Exception as error: + self._state = _LifecycleState.CLOSE_FAILED raise AgentCommunityTransportError( "Failed to close the Agent Community MCP connection" ) from error else: self._client = None + self._owner_task_id = None + self._state = _LifecycleState.CLOSED + finally: + if self._state is _LifecycleState.CLOSING: + self._state = _LifecycleState.CLOSE_FAILED async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResult: - client = self._client - if client is None: + if self._state is _LifecycleState.CONNECTING: + raise AgentCommunityProtocolError( + "Cannot call tools while client is connecting" + ) + if self._state is _LifecycleState.CALLING: + raise AgentCommunityProtocolError("Concurrent tool calls are not supported") + if self._state is _LifecycleState.CLOSING: + raise AgentCommunityProtocolError( + "Cannot call tools while client is closing" + ) + if self._state is _LifecycleState.CLOSE_FAILED: + raise AgentCommunityProtocolError( + "Cannot call tools after a failed close; retry close first" + ) + if self._state is not _LifecycleState.OPEN: raise AgentCommunityProtocolError( "Tool calls require an active context for this client" ) + client = self._client + if client is None: + raise AgentCommunityProtocolError("Client lifecycle state is inconsistent") + self._state = _LifecycleState.CALLING + try: with fail_after(self._timeout): return await client.call_tool(name, arguments) @@ -110,7 +181,14 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResul raise AgentCommunityTransportError( f"Tool {name!r} timed out after {self._timeout:g} seconds" ) from error + except ValidationError as error: + raise AgentCommunityProtocolError( + f"MCP tool {name!r} returned a protocol-invalid result" + ) from error except Exception as error: raise AgentCommunityTransportError( f"Failed while calling MCP tool {name!r}" ) from error + finally: + if self._state is _LifecycleState.CALLING: + self._state = _LifecycleState.OPEN diff --git a/src/agentcommunity/client.py b/src/agentcommunity/client.py index 5ae3389..d25ec06 100644 --- a/src/agentcommunity/client.py +++ b/src/agentcommunity/client.py @@ -44,8 +44,6 @@ def timeout(self) -> float: return self._timeout async def __aenter__(self) -> AgentCommunityClient: - if self._transport.active: - raise AgentCommunityProtocolError("Client context is already active") await self._transport.connect() return self @@ -138,8 +136,33 @@ def _validate_timeout(timeout: float) -> float: def _safe_tool_error_text(content: list[Any]) -> str: - text_parts = [item.text for item in content if isinstance(item, TextContent)] - normalized = " ".join(" ".join(text_parts).split()) - if len(normalized) <= _MAX_TOOL_ERROR_TEXT: - return normalized - return f"{normalized[: _MAX_TOOL_ERROR_TEXT - 3]}..." + output: list[str] = [] + pending_space = False + truncated = False + for item in content: + if not isinstance(item, TextContent): + continue + if output: + pending_space = True + for character in item.text: + codepoint = ord(character) + if character.isspace() or codepoint <= 31 or 127 <= codepoint <= 159: + if output: + pending_space = True + continue + if pending_space: + if len(output) == _MAX_TOOL_ERROR_TEXT: + truncated = True + break + output.append(" ") + pending_space = False + if len(output) == _MAX_TOOL_ERROR_TEXT: + truncated = True + break + output.append(character) + if truncated: + break + diagnostic = "".join(output) + if truncated: + return f"{diagnostic[: _MAX_TOOL_ERROR_TEXT - 3]}..." + return diagnostic diff --git a/tests/test_client_errors.py b/tests/test_client_errors.py index 7a3a0ca..e7028d9 100644 --- a/tests/test_client_errors.py +++ b/tests/test_client_errors.py @@ -9,6 +9,7 @@ error_result, successful_result, ) +from mcp.types import CallToolResult, ImageContent, TextContent from pydantic import ValidationError from agentcommunity import ( @@ -54,6 +55,22 @@ async def test_tool_transport_failure_is_translated_with_cause() -> None: assert captured.value.__cause__ is upstream +@pytest.mark.anyio +async def test_upstream_mcp_result_validation_failure_is_protocol_error() -> None: + with pytest.raises(ValidationError) as validation: + CommunityStats.model_validate({"member_count": "many", "note": "bad"}) + upstream = validation.value + fake = FakeMCPClient() + fake.call_error = upstream + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(AgentCommunityProtocolError) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + assert captured.value.__cause__ is upstream + + @pytest.mark.anyio async def test_connection_timeout_names_operation_and_duration() -> None: fake = FakeMCPClient() @@ -104,6 +121,36 @@ async def test_tool_error_uses_only_bounded_safe_text() -> None: assert "TextContent" not in message +@pytest.mark.anyio +async def test_tool_error_sanitizes_controls_and_bounds_multiple_blocks() -> None: + fake = FakeMCPClient() + fake.results = [ + CallToolResult( + content=[ + ImageContent(data="AAAA", mime_type="image/png"), + TextContent(text="safe\x1b[31m red\x00 nul\x9b c1"), + TextContent(text="y" * 100_000), + TextContent(text="must-not-appear"), + ], + is_error=True, + ) + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(AgentCommunityToolError) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + message = str(captured.value) + assert "safe [31m red nul c1" in message + assert "\x1b" not in message + assert "\x00" not in message + assert "\x9b" not in message + assert "AAAA" not in message + assert "must-not-appear" not in message + assert len(message) <= 600 + + @pytest.mark.anyio async def test_missing_structured_content_is_protocol_error() -> None: fake = FakeMCPClient() diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 5b0779e..42cc862 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -133,6 +133,36 @@ async def test_reentering_active_client_fails_predictably() -> None: await client.__aenter__() +@pytest.mark.anyio +async def test_simultaneous_entry_cannot_open_two_clients() -> None: + fake = FakeMCPClient() + fake.connect_gate = asyncio.Event() + factory = RecordingClientFactory(lambda: fake) + client = AgentCommunityClient(_client_factory=factory) + entered = asyncio.Event() + close_owner = asyncio.Event() + + async def own_lifecycle() -> None: + await client.__aenter__() + entered.set() + await close_owner.wait() + await client.close() + + owner = asyncio.create_task(own_lifecycle()) + while fake.enter_count == 0: + await asyncio.sleep(0) + + with pytest.raises(AgentCommunityProtocolError, match="connect"): + await client.__aenter__() + + fake.connect_gate.set() + await entered.wait() + assert len(factory.clients) == 1 + close_owner.set() + await owner + assert fake.successful_exit_count == 1 + + @pytest.mark.anyio async def test_close_is_idempotent_before_and_after_context_exit() -> None: factory = RecordingClientFactory() @@ -179,6 +209,8 @@ async def test_close_failure_retains_connection_for_successful_retry() -> None: with pytest.raises(AgentCommunityTransportError) as captured: await client.close() assert captured.value.__cause__ is upstream + with pytest.raises(AgentCommunityProtocolError, match="failed close"): + await client._call_typed("get_community_stats", {}, CommunityStats) fake.close_error = None await client.close() @@ -193,22 +225,98 @@ async def test_close_cancellation_retains_connection_for_successful_retry() -> N fake = FakeMCPClient() fake.close_gate = asyncio.Event() client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) - await client.__aenter__() + entered = asyncio.Event() + begin_close = asyncio.Event() + retry_close = asyncio.Event() + cancellation_seen = asyncio.Event() + + async def own_lifecycle() -> None: + await client.__aenter__() + entered.set() + await begin_close.wait() + try: + await client.close() + except asyncio.CancelledError: + cancellation_seen.set() + await retry_close.wait() + await client.close() - task = asyncio.create_task(client.close()) - await asyncio.sleep(0) + task = asyncio.create_task(own_lifecycle()) + await entered.wait() + begin_close.set() + while fake.exit_count == 0: + await asyncio.sleep(0) task.cancel() - with pytest.raises(asyncio.CancelledError): - await task + await cancellation_seen.wait() fake.close_gate.set() - await client.close() - await client.close() + retry_close.set() + await task assert fake.exit_count == 2 assert fake.successful_exit_count == 1 +@pytest.mark.anyio +async def test_cross_task_and_simultaneous_close_do_not_duplicate_exit() -> None: + fake = FakeMCPClient() + fake.close_gate = asyncio.Event() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + entered = asyncio.Event() + begin_close = asyncio.Event() + + async def own_lifecycle() -> None: + await client.__aenter__() + entered.set() + await begin_close.wait() + await client.close() + + owner = asyncio.create_task(own_lifecycle()) + await entered.wait() + with pytest.raises(AgentCommunityProtocolError, match="same task"): + await client.close() + + begin_close.set() + while fake.exit_count == 0: + await asyncio.sleep(0) + with pytest.raises(AgentCommunityProtocolError, match="close"): + await client.close() + assert fake.exit_count == 1 + + fake.close_gate.set() + await owner + assert fake.exit_count == 1 + assert fake.successful_exit_count == 1 + + +@pytest.mark.anyio +async def test_tool_call_during_close_is_rejected() -> None: + fake = FakeMCPClient() + fake.close_gate = asyncio.Event() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + entered = asyncio.Event() + begin_close = asyncio.Event() + + async def own_lifecycle() -> None: + await client.__aenter__() + entered.set() + await begin_close.wait() + await client.close() + + owner = asyncio.create_task(own_lifecycle()) + await entered.wait() + begin_close.set() + while fake.exit_count == 0: + await asyncio.sleep(0) + + with pytest.raises(AgentCommunityProtocolError, match="closing"): + await client._call_typed("get_community_stats", {}, CommunityStats) + + fake.close_gate.set() + await owner + assert fake.calls == [] + + @pytest.mark.anyio async def test_cancellation_during_connect_propagates_unchanged() -> None: fake = FakeMCPClient() @@ -234,3 +342,24 @@ async def test_cancellation_during_tool_call_propagates_unchanged() -> None: task.cancel() with pytest.raises(asyncio.CancelledError): await task + + +@pytest.mark.anyio +async def test_concurrent_tool_call_is_rejected() -> None: + fake = FakeMCPClient() + fake.call_gate = asyncio.Event() + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + first_call = asyncio.create_task( + client._call_typed("first", {}, CommunityStats) + ) + while not fake.calls: + await asyncio.sleep(0) + with pytest.raises(AgentCommunityProtocolError, match="Concurrent"): + await client._call_typed("second", {}, CommunityStats) + first_call.cancel() + with pytest.raises(asyncio.CancelledError): + await first_call + + assert fake.calls == [("first", {})] From 09ea774aff0989ec9be3b18e84cecbc9ef9bd7bf Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 01:45:16 +0700 Subject: [PATCH 10/21] fix: classify MCP protocol failures --- src/agentcommunity/_transport.py | 4 ++++ src/agentcommunity/client.py | 5 +++-- tests/test_client_errors.py | 34 ++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py index 3de10d6..646b5d6 100644 --- a/src/agentcommunity/_transport.py +++ b/src/agentcommunity/_transport.py @@ -90,6 +90,10 @@ async def connect(self) -> None: raise AgentCommunityTransportError( f"Timed out while attempting to connect after {self._timeout:g} seconds" ) from error + except ValidationError as error: + raise AgentCommunityProtocolError( + "MCP initialization returned protocol-invalid data" + ) from error except Exception as error: raise AgentCommunityTransportError( "Failed to connect to the Agent Community MCP endpoint" diff --git a/src/agentcommunity/client.py b/src/agentcommunity/client.py index d25ec06..3fcc92c 100644 --- a/src/agentcommunity/client.py +++ b/src/agentcommunity/client.py @@ -3,6 +3,7 @@ import math from types import TracebackType from typing import Any, TypeVar +from unicodedata import category from urllib.parse import urlsplit from mcp.types import TextContent @@ -15,6 +16,7 @@ _DEFAULT_TIMEOUT = 15.0 _MAX_TOOL_ERROR_TEXT = 500 _HEX_DIGITS = frozenset("0123456789abcdefABCDEF") +_UNSAFE_UNICODE_CATEGORIES = frozenset({"Cc", "Cf", "Cs"}) _ModelT = TypeVar("_ModelT", bound=BaseModel) @@ -145,8 +147,7 @@ def _safe_tool_error_text(content: list[Any]) -> str: if output: pending_space = True for character in item.text: - codepoint = ord(character) - if character.isspace() or codepoint <= 31 or 127 <= codepoint <= 159: + if character.isspace() or category(character) in _UNSAFE_UNICODE_CATEGORIES: if output: pending_space = True continue diff --git a/tests/test_client_errors.py b/tests/test_client_errors.py index e7028d9..72b6246 100644 --- a/tests/test_client_errors.py +++ b/tests/test_client_errors.py @@ -39,6 +39,21 @@ async def test_connection_failure_is_translated_with_cause() -> None: assert captured.value.__cause__ is upstream +@pytest.mark.anyio +async def test_connection_validation_failure_is_protocol_error_with_cause() -> None: + with pytest.raises(ValidationError) as validation: + CommunityStats.model_validate({"member_count": "many", "note": "bad"}) + upstream = validation.value + fake = FakeMCPClient() + fake.connect_error = upstream + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + with pytest.raises(AgentCommunityProtocolError, match="initialization") as captured: + await client.__aenter__() + + assert captured.value.__cause__ is upstream + + @pytest.mark.anyio async def test_tool_transport_failure_is_translated_with_cause() -> None: upstream = RuntimeError("MCP stream ended") @@ -151,6 +166,25 @@ async def test_tool_error_sanitizes_controls_and_bounds_multiple_blocks() -> Non assert len(message) <= 600 +@pytest.mark.anyio +async def test_tool_error_sanitizes_bidi_controls_and_preserves_unicode() -> None: + fake = FakeMCPClient() + fake.results = [ + error_result("readable café 日本語 \u202eevil\u2066 hidden\ud800 surrogate") + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(AgentCommunityToolError) as captured: + await client._call_typed("get_community_stats", {}, CommunityStats) + + message = str(captured.value) + assert "readable café 日本語 evil hidden surrogate" in message + assert "\u202e" not in message + assert "\u2066" not in message + assert "\ud800" not in message + + @pytest.mark.anyio async def test_missing_structured_content_is_protocol_error() -> None: fake = FakeMCPClient() From 79b3b37b553a90fd1f3df55874ab38611bd9ac3f Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:11:49 +0700 Subject: [PATCH 11/21] fix: align timeouts with MCP lifecycle --- .../2026-08-27-agentcommunity-python-sdk.md | 22 +- ...-08-27-agentcommunity-python-sdk-design.md | 6 +- pyproject.toml | 1 + src/agentcommunity/_transport.py | 127 ++++++++--- src/agentcommunity/client.py | 3 + tests/fakes.py | 24 ++- tests/test_client_errors.py | 20 +- tests/test_client_lifecycle.py | 38 ++-- tests/test_mcp_lifecycle_integration.py | 203 ++++++++++++++++++ 9 files changed, 375 insertions(+), 69 deletions(-) create mode 100644 tests/test_mcp_lifecycle_integration.py diff --git a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md index f2aa0a3..8839e60 100644 --- a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md +++ b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md @@ -167,6 +167,8 @@ git commit -m "feat: add strict SDK result models" - Create: `tests/fakes.py` - Create: `tests/test_client_lifecycle.py` - Create: `tests/test_client_errors.py` +- Create: `tests/test_mcp_lifecycle_integration.py` +- Modify: `pyproject.toml` ### Step 1: Write failing lifecycle tests @@ -178,7 +180,8 @@ Use a small injected factory/fake at the private transport seam. Test: - A connected client supports multiple sequential calls without reconnecting. - Calls before enter and after exit raise `AgentCommunityProtocolError`. - Re-entering an active instance fails predictably. -- `close()` is safe and idempotent, including after context exit. +- `close()` is safe and idempotent before entry and after a completed teardown attempt. +- Official teardown is unwrapped, runs once, and may exceed the configured connection/call timeout. A failed or cancelled teardown is terminal and is not retried. - Cancellation during connect or call propagates as `asyncio.CancelledError` (or the active cancellation class), not a package error. Keep the injection mechanism private. Tests may import a private constructor/factory seam directly, but it must not appear in `agentcommunity.__all__` or user documentation. @@ -188,7 +191,8 @@ Keep the injection mechanism private. Tests may import a private constructor/fac Test: - Connection and generic MCP/HTTP failures become `AgentCommunityTransportError` with the original error as `__cause__`. -- Timeout during connection or tool call becomes `AgentCommunityTransportError` naming the operation and configured timeout, preserving the timeout cause. +- Timeout during connection or tool call becomes `AgentCommunityTransportError` naming the operation and configured timeout, preserving the upstream timeout cause. +- Real official-stack tests cover clean lifecycle teardown, stalled initialization cleanup, native per-call timeout with continued connection usability, and advertised output-schema mismatch classification. - A tool result with `is_error=True` becomes `AgentCommunityToolError`, retaining bounded safe text from result content. - A successful result without `structured_content` becomes `AgentCommunityProtocolError`. - A model validation failure becomes `AgentCommunityProtocolError` and preserves the Pydantic validation error as cause. @@ -196,7 +200,7 @@ Test: ### Step 3: Run tests to verify failure -Run: `python -m pytest tests/test_client_lifecycle.py tests/test_client_errors.py -q` +Run: `python -m pytest tests/test_client_lifecycle.py tests/test_client_errors.py tests/test_mcp_lifecycle_integration.py -q` Expected: FAIL because client/transport do not exist. @@ -205,7 +209,9 @@ Expected: FAIL because client/transport do not exist. In `_transport.py`, implement the minimum adapter around `mcp.Client(endpoint)`: - Own async enter/exit and a single active high-level MCP client. -- Apply timeout to connect, close, and tool operations using cancellation-safe AnyIO/Python primitives compatible with Python 3.10. +- Put the connection timeout outside the complete official client context, disable its deadline after connection succeeds, and allow official teardown to finish before exiting that outer scope. +- Apply tool timeouts through the official high-level client's float `read_timeout_seconds` argument. +- Do not wrap or retry official teardown. Translate a non-cancellation teardown failure once, mark the instance terminal, and allow cancellation to propagate unchanged. - Translate transport failures while never catching `BaseException` or caller cancellation. - Call tools using the public MCP SDK API. @@ -226,6 +232,7 @@ Run: ```bash python -m pytest tests/test_client_lifecycle.py tests/test_client_errors.py -q +python -m pytest tests/test_mcp_lifecycle_integration.py -q python -m pytest -q python -m ruff check src tests python -m ruff format --check src tests @@ -235,7 +242,7 @@ python -m mypy src tests Expected: all pass. ```bash -git add src/agentcommunity tests/fakes.py tests/test_client_lifecycle.py tests/test_client_errors.py +git add pyproject.toml src/agentcommunity tests/fakes.py tests/test_client_lifecycle.py tests/test_client_errors.py tests/test_mcp_lifecycle_integration.py docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md git commit -m "feat: add asynchronous MCP client lifecycle" ``` @@ -273,13 +280,13 @@ Read the pinned fixture and verify: Avoid a general JSON Schema code generator. The contract tests should clearly identify drift while keeping handwritten public models readable. -### Step 3: Write a failing official-stack integration test +### Step 3: Complete the official-stack integration coverage Use the official MCP SDK's supported in-process/local server and client facilities to exercise a deterministic local endpoint through the actual `mcp.Client` code path. Register read-only test implementations of the three supported tools with structured outputs, then prove: - One client connection performs all three calls. - Results become the public typed models. -- Lifecycle closes cleanly. +- All three public methods reuse one connection and close cleanly. Task 3 already covers the real-stack lifecycle/timeout mechanics and advertised output-schema mismatch; do not duplicate those lower-level cases here. Do not call production and do not register the unsafe `register_agent` tool. @@ -422,4 +429,3 @@ After every task has passed its specification and code-quality reviews: 4. Resolve every Critical and Important finding, rerun affected and full checks, and request re-review when necessary. 5. Apply `superpowers:verification-before-completion` before claiming success. 6. Apply `superpowers:finishing-a-development-branch` and present the four integration choices. Do not create a remote, push, open a pull request, configure PyPI, publish, or modify the website without explicit user authorization. - diff --git a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md index 78e1624..0cd52b1 100644 --- a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md +++ b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md @@ -48,7 +48,7 @@ It exposes exactly these public capability methods in version 0.1: - `lookup_member(query: str) -> MemberLookup` - `verify_certificate(certificate_id: str) -> CertificateVerification` -The client is an async context manager. One connected instance supports multiple sequential calls. Calls made before entering or after leaving the context fail with a package protocol error. Closing is idempotent. The SDK does not expose its underlying MCP client or session. +The client is an async context manager. One connected instance supports multiple sequential calls. Calls made before entering or after leaving the context fail with a package protocol error. Closing is idempotent before entry and after a completed teardown attempt. The SDK does not expose its underlying MCP client or session. Version 0.1 intentionally omits: @@ -67,6 +67,8 @@ The server may advertise additional tools. The SDK must ignore unknown tools and The implementation wraps the official MCP Python SDK high-level client, created as `mcp.Client(endpoint)`. It must use the public SDK interface rather than duplicating Streamable HTTP or JSON-RPC behavior. +The configured timeout covers connection/handshake and each tool call. The connection deadline lexically encloses the official client lifecycle so AnyIO cancel scopes remain correctly nested, and it is disabled after connection succeeds. Tool calls use the official client's per-call `read_timeout_seconds` option. Official teardown is deliberately not wrapped in another timeout: it runs once because cancellation can consume cleanup callbacks that cannot safely be retried. A failed or cancelled teardown leaves that SDK instance terminal and non-callable; later `close()` calls are safe no-ops. + For each typed method, the wrapper: 1. Requires an active client context. @@ -187,7 +189,7 @@ Unit tests cover: ### MCP integration test -An offline integration test uses the official MCP client stack against a deterministic in-process or local test server. It proves connection lifecycle, tool invocation, and structured-content decoding without reaching production. +Offline integration tests use the official MCP client stack against deterministic in-process servers. Task 3 exercises the lifecycle/timeout and advertised output-schema boundaries early because mocks cannot model the official stack's persistent AnyIO cancel scopes. Task 4 retains end-to-end coverage of all three public methods and structured-content decoding without reaching production. ### Contract tests diff --git a/pyproject.toml b/pyproject.toml index 73b37a6..e0bd20c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ classifiers = [ "Typing :: Typed", ] dependencies = [ + "jsonschema>=4.20,<5", "mcp>=2.1.1,<3", "pydantic>=2.12,<3", ] diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py index 646b5d6..989485d 100644 --- a/src/agentcommunity/_transport.py +++ b/src/agentcommunity/_transport.py @@ -1,13 +1,18 @@ from __future__ import annotations -from collections.abc import Callable +import math +from collections.abc import AsyncIterator, Callable +from contextlib import AbstractAsyncContextManager, asynccontextmanager from enum import Enum, auto from types import TracebackType from typing import Any, Protocol, cast from anyio import fail_after, get_current_task -from mcp import Client -from mcp.types import CallToolResult +from jsonschema.exceptions import ( # type: ignore[import-untyped] + ValidationError as JsonSchemaValidationError, +) +from mcp import Client, MCPError +from mcp.types import REQUEST_TIMEOUT, CallToolResult from pydantic import ValidationError from agentcommunity.errors import ( @@ -27,11 +32,15 @@ async def __aexit__( ) -> None: ... async def call_tool( - self, name: str, arguments: dict[str, Any] | None = None + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + read_timeout_seconds: float | None = None, ) -> CallToolResult: ... -_ClientFactory = Callable[[str], _HighLevelClient] +_ClientFactory = Callable[[str, float], _HighLevelClient] class _LifecycleState(Enum): @@ -44,8 +53,27 @@ class _LifecycleState(Enum): CLOSED = auto() -def _create_mcp_client(endpoint: str) -> _HighLevelClient: - return cast(_HighLevelClient, Client(endpoint)) +def _create_mcp_client(endpoint: str, timeout: float) -> _HighLevelClient: + return cast(_HighLevelClient, Client(endpoint, read_timeout_seconds=timeout)) + + +@asynccontextmanager +async def _client_lifecycle( + endpoint: str, + timeout: float, + client_factory: _ClientFactory, +) -> AsyncIterator[_HighLevelClient]: + client = client_factory(endpoint, timeout) + # This scope must outlive every scope opened by the official client. Exiting a + # shorter wrapper around either lifecycle method violates AnyIO's scope LIFO. + with fail_after(timeout) as connection_scope: + async with client as connected: + connection_scope.deadline = math.inf + yield connected + + +def _is_output_schema_failure(error: RuntimeError) -> bool: + return isinstance(error.__cause__, JsonSchemaValidationError) class _MCPTransport: @@ -60,12 +88,18 @@ def __init__( self._timeout = timeout self._client_factory = client_factory or _create_mcp_client self._client: _HighLevelClient | None = None + self._lifecycle: AbstractAsyncContextManager[_HighLevelClient] | None = None self._state = _LifecycleState.NEW self._owner_task_id: int | None = None @property def active(self) -> bool: - return self._state not in {_LifecycleState.NEW, _LifecycleState.CLOSED} + return self._state in { + _LifecycleState.CONNECTING, + _LifecycleState.OPEN, + _LifecycleState.CALLING, + _LifecycleState.CLOSING, + } async def connect(self) -> None: if self._state is _LifecycleState.CONNECTING: @@ -74,7 +108,7 @@ async def connect(self) -> None: raise AgentCommunityProtocolError("Client is currently closing") if self._state is _LifecycleState.CLOSE_FAILED: raise AgentCommunityProtocolError( - "Client has a failed close; retry close before reconnecting" + "Client teardown failed and this instance cannot be reused" ) if self._state in {_LifecycleState.OPEN, _LifecycleState.CALLING}: raise AgentCommunityProtocolError("Client context is already active") @@ -82,10 +116,12 @@ async def connect(self) -> None: previous_state = self._state self._state = _LifecycleState.CONNECTING self._owner_task_id = get_current_task().id + lifecycle = _client_lifecycle( + self._endpoint, self._timeout, self._client_factory + ) + self._lifecycle = lifecycle try: - client = self._client_factory(self._endpoint) - with fail_after(self._timeout): - await client.__aenter__() + client = await lifecycle.__aenter__() except TimeoutError as error: raise AgentCommunityTransportError( f"Timed out while attempting to connect after {self._timeout:g} seconds" @@ -94,6 +130,15 @@ async def connect(self) -> None: raise AgentCommunityProtocolError( "MCP initialization returned protocol-invalid data" ) from error + except MCPError as error: + if error.code == REQUEST_TIMEOUT: + raise AgentCommunityTransportError( + "Timed out while attempting to connect " + f"after {self._timeout:g} seconds" + ) from error + raise AgentCommunityTransportError( + "Failed to connect to the Agent Community MCP endpoint" + ) from error except Exception as error: raise AgentCommunityTransportError( "Failed to connect to the Agent Community MCP endpoint" @@ -105,6 +150,7 @@ async def connect(self) -> None: if self._state is _LifecycleState.CONNECTING: self._state = previous_state self._owner_task_id = None + self._lifecycle = None async def close( self, @@ -112,7 +158,11 @@ async def close( exc_value: BaseException | None = None, traceback: TracebackType | None = None, ) -> None: - if self._state in {_LifecycleState.NEW, _LifecycleState.CLOSED}: + if self._state in { + _LifecycleState.NEW, + _LifecycleState.CLOSE_FAILED, + _LifecycleState.CLOSED, + }: return if self._state is _LifecycleState.CONNECTING: raise AgentCommunityProtocolError("Cannot close while client is connecting") @@ -127,31 +177,25 @@ async def close( "Client must be closed in the same task that entered it" ) - client = self._client - if client is None: + lifecycle = self._lifecycle + if lifecycle is None: raise AgentCommunityProtocolError("Client lifecycle state is inconsistent") self._state = _LifecycleState.CLOSING try: - with fail_after(self._timeout): - await client.__aexit__(exc_type, exc_value, traceback) - except TimeoutError as error: - self._state = _LifecycleState.CLOSE_FAILED - raise AgentCommunityTransportError( - f"Timed out while attempting to close after {self._timeout:g} seconds" - ) from error + await lifecycle.__aexit__(exc_type, exc_value, traceback) except Exception as error: - self._state = _LifecycleState.CLOSE_FAILED raise AgentCommunityTransportError( "Failed to close the Agent Community MCP connection" ) from error else: - self._client = None - self._owner_task_id = None self._state = _LifecycleState.CLOSED finally: if self._state is _LifecycleState.CLOSING: self._state = _LifecycleState.CLOSE_FAILED + self._client = None + self._lifecycle = None + self._owner_task_id = None async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResult: if self._state is _LifecycleState.CONNECTING: @@ -164,10 +208,6 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResul raise AgentCommunityProtocolError( "Cannot call tools while client is closing" ) - if self._state is _LifecycleState.CLOSE_FAILED: - raise AgentCommunityProtocolError( - "Cannot call tools after a failed close; retry close first" - ) if self._state is not _LifecycleState.OPEN: raise AgentCommunityProtocolError( "Tool calls require an active context for this client" @@ -179,16 +219,33 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResul self._state = _LifecycleState.CALLING try: - with fail_after(self._timeout): - return await client.call_tool(name, arguments) - except TimeoutError as error: - raise AgentCommunityTransportError( - f"Tool {name!r} timed out after {self._timeout:g} seconds" - ) from error + return await client.call_tool( + name, arguments, read_timeout_seconds=self._timeout + ) except ValidationError as error: raise AgentCommunityProtocolError( f"MCP tool {name!r} returned a protocol-invalid result" ) from error + except RuntimeError as error: + if _is_output_schema_failure(error): + raise AgentCommunityProtocolError( + f"MCP tool {name!r} returned a protocol-invalid result" + ) from error + raise AgentCommunityTransportError( + f"Failed while calling MCP tool {name!r}" + ) from error + except MCPError as error: + if error.code == REQUEST_TIMEOUT: + raise AgentCommunityTransportError( + f"Tool {name!r} timed out after {self._timeout:g} seconds" + ) from error + raise AgentCommunityTransportError( + f"Failed while calling MCP tool {name!r}" + ) from error + except TimeoutError as error: + raise AgentCommunityTransportError( + f"Tool {name!r} timed out after {self._timeout:g} seconds" + ) from error except Exception as error: raise AgentCommunityTransportError( f"Failed while calling MCP tool {name!r}" diff --git a/src/agentcommunity/client.py b/src/agentcommunity/client.py index 3fcc92c..069a230 100644 --- a/src/agentcommunity/client.py +++ b/src/agentcommunity/client.py @@ -161,6 +161,9 @@ def _safe_tool_error_text(content: list[Any]) -> str: truncated = True break output.append(character) + if len(output) == _MAX_TOOL_ERROR_TEXT: + truncated = True + break if truncated: break diagnostic = "".join(output) diff --git a/tests/fakes.py b/tests/fakes.py index 75feb27..85c9514 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -4,7 +4,8 @@ from collections.abc import Callable from typing import Any -from mcp.types import CallToolResult, TextContent +from mcp import MCPError +from mcp.types import REQUEST_TIMEOUT, CallToolResult, TextContent class FakeMCPClient: @@ -13,6 +14,7 @@ def __init__(self) -> None: self.exit_count = 0 self.successful_exit_count = 0 self.calls: list[tuple[str, dict[str, Any]]] = [] + self.call_timeouts: list[float | None] = [] self.connect_error: BaseException | None = None self.close_error: BaseException | None = None self.call_error: BaseException | None = None @@ -44,11 +46,23 @@ async def __aexit__( self.successful_exit_count += 1 async def call_tool( - self, name: str, arguments: dict[str, Any] | None = None + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + read_timeout_seconds: float | None = None, ) -> CallToolResult: self.calls.append((name, arguments or {})) + self.call_timeouts.append(read_timeout_seconds) if self.call_gate is not None: - await self.call_gate.wait() + try: + await asyncio.wait_for( + self.call_gate.wait(), timeout=read_timeout_seconds + ) + except TimeoutError as error: + raise MCPError( + REQUEST_TIMEOUT, f"Request {name!r} timed out" + ) from error if self.call_error is not None: raise self.call_error if self.results: @@ -60,10 +74,12 @@ class RecordingClientFactory: def __init__(self, build: Callable[[], FakeMCPClient] = FakeMCPClient) -> None: self._build = build self.endpoints: list[str] = [] + self.timeouts: list[float] = [] self.clients: list[FakeMCPClient] = [] - def __call__(self, endpoint: str) -> FakeMCPClient: + def __call__(self, endpoint: str, timeout: float = 15.0) -> FakeMCPClient: self.endpoints.append(endpoint) + self.timeouts.append(timeout) client = self._build() self.clients.append(client) return client diff --git a/tests/test_client_errors.py b/tests/test_client_errors.py index 72b6246..cd0d624 100644 --- a/tests/test_client_errors.py +++ b/tests/test_client_errors.py @@ -9,6 +9,7 @@ error_result, successful_result, ) +from mcp import MCPError from mcp.types import CallToolResult, ImageContent, TextContent from pydantic import ValidationError @@ -18,6 +19,7 @@ AgentCommunityToolError, AgentCommunityTransportError, ) +from agentcommunity.client import _safe_tool_error_text from agentcommunity.models import CommunityStats @@ -26,6 +28,19 @@ def anyio_backend() -> str: return "asyncio" +def test_tool_error_scan_stops_as_soon_as_limit_is_reached() -> None: + class GuardedText(str): + def __iter__(self): # type: ignore[no-untyped-def] + for index, character in enumerate(super().__iter__()): + if index == 500: + raise AssertionError("diagnostic scanner read beyond its bound") + yield character + + content = [TextContent.model_construct(text=GuardedText("x" * 1_000))] + + assert _safe_tool_error_text(content).endswith("...") + + @pytest.mark.anyio async def test_connection_failure_is_translated_with_cause() -> None: upstream = OSError("connection refused") @@ -56,7 +71,9 @@ async def test_connection_validation_failure_is_protocol_error_with_cause() -> N @pytest.mark.anyio async def test_tool_transport_failure_is_translated_with_cause() -> None: + transport_cause = ConnectionError("stream broke") upstream = RuntimeError("MCP stream ended") + upstream.__cause__ = transport_cause fake = FakeMCPClient() fake.call_error = upstream client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) @@ -68,6 +85,7 @@ async def test_tool_transport_failure_is_translated_with_cause() -> None: await client._call_typed("get_community_stats", {}, CommunityStats) assert captured.value.__cause__ is upstream + assert upstream.__cause__ is transport_cause @pytest.mark.anyio @@ -117,7 +135,7 @@ async def test_tool_timeout_names_operation_and_duration() -> None: ) as captured: await client._call_typed("get_community_stats", {}, CommunityStats) - assert isinstance(captured.value.__cause__, TimeoutError) + assert isinstance(captured.value.__cause__, MCPError) @pytest.mark.anyio diff --git a/tests/test_client_lifecycle.py b/tests/test_client_lifecycle.py index 42cc862..00d454c 100644 --- a/tests/test_client_lifecycle.py +++ b/tests/test_client_lifecycle.py @@ -87,6 +87,7 @@ async def test_context_opens_and_closes_exactly_one_underlying_client() -> None: assert factory.clients[0].enter_count == 1 assert factory.endpoints == ["https://agentcommunity.org/mcp"] + assert factory.timeouts == [15.0] assert factory.clients[0].exit_count == 1 @@ -108,6 +109,7 @@ async def test_one_connection_supports_multiple_sequential_calls() -> None: assert fake.enter_count == 1 assert fake.exit_count == 1 assert fake.calls == [("first", {}), ("second", {})] + assert fake.call_timeouts == [15.0, 15.0] @pytest.mark.anyio @@ -179,27 +181,29 @@ async def test_close_is_idempotent_before_and_after_context_exit() -> None: @pytest.mark.anyio -async def test_close_timeout_retains_connection_for_successful_retry() -> None: +async def test_close_is_not_limited_by_request_timeout() -> None: fake = FakeMCPClient() - fake.close_gate = asyncio.Event() + close_gate = asyncio.Event() + fake.close_gate = close_gate client = AgentCommunityClient( timeout=0.01, _client_factory=RecordingClientFactory(lambda: fake) ) - await client.__aenter__() - with pytest.raises(AgentCommunityTransportError, match="close"): - await client.close() + async def release_close() -> None: + await asyncio.sleep(0.03) + close_gate.set() - fake.close_gate.set() - await client.close() + async with client: + release = asyncio.create_task(release_close()) + await release await client.close() - assert fake.exit_count == 2 + assert fake.exit_count == 1 assert fake.successful_exit_count == 1 @pytest.mark.anyio -async def test_close_failure_retains_connection_for_successful_retry() -> None: +async def test_close_failure_is_terminal_and_subsequent_close_is_noop() -> None: upstream = OSError("close failed") fake = FakeMCPClient() fake.close_error = upstream @@ -209,25 +213,24 @@ async def test_close_failure_retains_connection_for_successful_retry() -> None: with pytest.raises(AgentCommunityTransportError) as captured: await client.close() assert captured.value.__cause__ is upstream - with pytest.raises(AgentCommunityProtocolError, match="failed close"): + with pytest.raises(AgentCommunityProtocolError, match="active context"): await client._call_typed("get_community_stats", {}, CommunityStats) fake.close_error = None await client.close() await client.close() - assert fake.exit_count == 2 - assert fake.successful_exit_count == 1 + assert fake.exit_count == 1 + assert fake.successful_exit_count == 0 @pytest.mark.anyio -async def test_close_cancellation_retains_connection_for_successful_retry() -> None: +async def test_close_cancellation_is_terminal_and_subsequent_close_is_noop() -> None: fake = FakeMCPClient() fake.close_gate = asyncio.Event() client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) entered = asyncio.Event() begin_close = asyncio.Event() - retry_close = asyncio.Event() cancellation_seen = asyncio.Event() async def own_lifecycle() -> None: @@ -238,7 +241,6 @@ async def own_lifecycle() -> None: await client.close() except asyncio.CancelledError: cancellation_seen.set() - await retry_close.wait() await client.close() task = asyncio.create_task(own_lifecycle()) @@ -249,12 +251,10 @@ async def own_lifecycle() -> None: task.cancel() await cancellation_seen.wait() - fake.close_gate.set() - retry_close.set() await task - assert fake.exit_count == 2 - assert fake.successful_exit_count == 1 + assert fake.exit_count == 1 + assert fake.successful_exit_count == 0 @pytest.mark.anyio diff --git a/tests/test_mcp_lifecycle_integration.py b/tests/test_mcp_lifecycle_integration.py new file mode 100644 index 0000000..054a37e --- /dev/null +++ b/tests/test_mcp_lifecycle_integration.py @@ -0,0 +1,203 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from typing import Any, cast + +import anyio +import mcp_types as types +import pytest +from jsonschema.exceptions import ( # type: ignore[import-untyped] + ValidationError as JsonSchemaValidationError, +) +from mcp import Client, MCPError +from mcp.server import Server, ServerRequestContext +from mcp.server.mcpserver import MCPServer + +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + AgentCommunityTransportError, +) +from agentcommunity.models import CommunityStats + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio +async def test_real_client_lifecycle_call_and_slow_cleanup_are_clean() -> None: + cleanup_complete = anyio.Event() + + @asynccontextmanager + async def lifespan( + server: MCPServer[None], + ) -> AsyncIterator[None]: + del server + try: + yield None + finally: + await anyio.sleep(0.03) + cleanup_complete.set() + + server = MCPServer("real-lifecycle", lifespan=lifespan) + + @server.tool() + def stats() -> dict[str, object]: + return {"member_count": 1, "note": "real stack"} + + clients: list[Client] = [] + + def factory(endpoint: str, timeout: float) -> Client: + del endpoint + client = Client(server, read_timeout_seconds=timeout) + clients.append(client) + return client + + sdk = AgentCommunityClient(timeout=0.01, _client_factory=factory) + async with sdk: + result = await sdk._call_typed("stats", {}, CommunityStats) + + assert result == CommunityStats(member_count=1, note="real stack") + assert cleanup_complete.is_set() + assert clients[0]._session is None + exit_stack = cast(Any, clients[0]._exit_stack) + assert not exit_stack._exit_callbacks + + +@pytest.mark.anyio +async def test_real_stalled_initialization_times_out_and_cleans_up() -> None: + initialize_started = anyio.Event() + initialize_cancelled = anyio.Event() + + async def stall_initialize( + context: ServerRequestContext[Any, Any], + call_next: Callable[[ServerRequestContext[Any, Any]], Awaitable[Any]], + ) -> Any: + if context.method == "initialize": + initialize_started.set() + try: + await anyio.Event().wait() + except anyio.get_cancelled_exc_class(): + initialize_cancelled.set() + raise + return await call_next(context) + + server = Server("stalled-initialize") + server.middleware.append(cast(Any, stall_initialize)) + clients: list[Client] = [] + + def factory(endpoint: str, timeout: float) -> Client: + del endpoint + client = Client(server, mode="legacy", read_timeout_seconds=timeout) + clients.append(client) + return client + + sdk = AgentCommunityClient(timeout=0.01, _client_factory=factory) + with pytest.raises( + AgentCommunityTransportError, match=r"connect.*0\.01" + ) as captured: + await sdk.__aenter__() + + assert isinstance(captured.value.__cause__, TimeoutError) + assert initialize_started.is_set() + assert initialize_cancelled.is_set() + assert clients[0]._session is None + assert clients[0]._exit_stack is None + with anyio.fail_after(1): + await anyio.lowlevel.checkpoint() + + +@pytest.mark.anyio +async def test_real_native_call_timeout_leaves_connection_usable() -> None: + async def list_tools( + context: ServerRequestContext[Any, Any], + params: types.PaginatedRequestParams | None, + ) -> types.ListToolsResult: + del context, params + return types.ListToolsResult( + tools=[ + types.Tool(name="block", input_schema={"type": "object"}), + types.Tool(name="stats", input_schema={"type": "object"}), + ] + ) + + async def call_tool( + context: ServerRequestContext[Any, Any], + params: types.CallToolRequestParams, + ) -> types.CallToolResult: + del context + if params.name == "block": + await anyio.Event().wait() + raise AssertionError("unreachable") + return types.CallToolResult( + content=[], + structured_content={"member_count": 2, "note": "still usable"}, + ) + + server = Server( + "native-call-timeout", on_list_tools=list_tools, on_call_tool=call_tool + ) + + def factory(endpoint: str, timeout: float) -> Client: + del endpoint + return Client(server, mode="legacy", read_timeout_seconds=timeout) + + sdk = AgentCommunityClient(timeout=0.01, _client_factory=factory) + async with sdk: + with pytest.raises( + AgentCommunityTransportError, match=r"block.*0\.01" + ) as captured: + await sdk._call_typed("block", {}, CommunityStats) + result = await sdk._call_typed("stats", {}, CommunityStats) + + assert isinstance(captured.value.__cause__, MCPError) + assert result.member_count == 2 + + +@pytest.mark.anyio +async def test_real_output_schema_mismatch_is_protocol_error() -> None: + async def list_tools( + context: ServerRequestContext[Any, Any], + params: types.PaginatedRequestParams | None, + ) -> types.ListToolsResult: + del context, params + return types.ListToolsResult( + tools=[ + types.Tool( + name="bad", + input_schema={"type": "object"}, + output_schema={ + "type": "object", + "properties": {"member_count": {"type": "integer"}}, + "required": ["member_count"], + }, + ) + ] + ) + + async def call_tool( + context: ServerRequestContext[Any, Any], + params: types.CallToolRequestParams, + ) -> types.CallToolResult: + del context, params + return types.CallToolResult( + content=[], structured_content={"member_count": "many"} + ) + + server = Server("schema-mismatch", on_list_tools=list_tools, on_call_tool=call_tool) + + def factory(endpoint: str, timeout: float) -> Client: + del endpoint + return Client(server, mode="legacy", read_timeout_seconds=timeout) + + sdk = AgentCommunityClient(_client_factory=factory) + async with sdk: + with pytest.raises(AgentCommunityProtocolError) as captured: + await sdk._call_typed("bad", {}, CommunityStats) + + upstream = captured.value.__cause__ + assert isinstance(upstream, RuntimeError) + assert isinstance(upstream.__cause__, JsonSchemaValidationError) From 5f9dbc8f1fdaf99e04df154449a489c80bd2e0c4 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:14:33 +0700 Subject: [PATCH 12/21] docs: align SDK protocol dependency --- .../plans/2026-08-27-agentcommunity-python-sdk.md | 5 +++-- .../specs/2026-08-27-agentcommunity-python-sdk-design.md | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md index 8839e60..51a35a6 100644 --- a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md +++ b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md @@ -6,7 +6,7 @@ **Architecture:** `AgentCommunityClient` is an async context-manager wrapper around the official high-level `mcp.Client`. A private transport adapter owns MCP lifecycle and exception translation; public methods validate arguments and turn required `structured_content` into strict, frozen Pydantic models. Tests pin the immutable 1.5.0 MCP contract and exercise the real MCP client stack locally, while production smoke checks stay optional and read-only. -**Tech Stack:** Python 3.10+, MCP Python SDK 2.x, Pydantic 2.x, Hatchling, pytest/AnyIO, Ruff, mypy, GitHub Actions, PyPI Trusted Publishing. +**Tech Stack:** Python 3.10+, MCP Python SDK 2.x, JSON Schema validation 4.x, Pydantic 2.x, Hatchling, pytest/AnyIO, Ruff, mypy, GitHub Actions, PyPI Trusted Publishing. **Design reference:** `docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md` @@ -49,7 +49,7 @@ Create a PEP 621 `pyproject.toml` using Hatchling with: - Distribution `agentcommunity`, version `0.1.0`, Python `>=3.10`, MIT metadata. - Homepage `https://agentcommunity.org`, documentation `https://agentcommunity.org/mcp/docs`, source `https://github.com/agentcommunity/agentcommunity-python`, issues URL, and typed-package classifier. -- Dependencies exactly bounded as `mcp>=2.1.1,<3` and `pydantic>=2.12,<3`. +- Direct runtime dependencies exactly bounded as `jsonschema>=4.20,<5`, `mcp>=2.1.1,<3`, and `pydantic>=2.12,<3`. `jsonschema` is direct because runtime protocol-error classification imports `jsonschema.exceptions.ValidationError`; do not rely on MCP to provide it transitively. - Dev dependency groups sufficient for pytest, AnyIO, Ruff, mypy, build, and Twine. - Ruff target Python 3.10 and a focused rule set; mypy strict mode for `src` and `tests`; pytest test path/configuration. - Hatch wheel package path `src/agentcommunity` and include `py.typed`. @@ -212,6 +212,7 @@ In `_transport.py`, implement the minimum adapter around `mcp.Client(endpoint)`: - Put the connection timeout outside the complete official client context, disable its deadline after connection succeeds, and allow official teardown to finish before exiting that outer scope. - Apply tool timeouts through the official high-level client's float `read_timeout_seconds` argument. - Do not wrap or retry official teardown. Translate a non-cancellation teardown failure once, mark the instance terminal, and allow cancellation to propagate unchanged. +- Classify the official output-schema failure through its `jsonschema.exceptions.ValidationError` cause. Keep `jsonschema>=4.20,<5` as a declared direct runtime dependency rather than relying on MCP's transitive dependency. - Translate transport failures while never catching `BaseException` or caller cancellation. - Call tools using the public MCP SDK API. diff --git a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md index 0cd52b1..453df11 100644 --- a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md +++ b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md @@ -19,9 +19,12 @@ The SDK is also intended to give package registries and agent-readiness scanners - License: MIT. - Package homepage and project URLs must point to `https://agentcommunity.org` and the official source repository. - Runtime dependencies: + - `jsonschema>=4.20,<5` - `mcp>=2.1.1,<3` - `pydantic>=2.12,<3` +`jsonschema` is a direct runtime dependency because protocol-error classification imports `jsonschema.exceptions.ValidationError`. The SDK must declare that import rather than rely on MCP's transitive dependency. + The PyPI name must be checked again immediately before publication. A currently unclaimed name is not a reservation. ## Public API From 7dce89bb717788bdba399537f8921adc5f24e5a8 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:18:46 +0700 Subject: [PATCH 13/21] fix: classify MCP result state failures --- src/agentcommunity/_transport.py | 13 ++++---- tests/test_client_errors.py | 18 +++++++--- tests/test_mcp_lifecycle_integration.py | 44 +++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py index 989485d..766b770 100644 --- a/src/agentcommunity/_transport.py +++ b/src/agentcommunity/_transport.py @@ -227,12 +227,13 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> CallToolResul f"MCP tool {name!r} returned a protocol-invalid result" ) from error except RuntimeError as error: - if _is_output_schema_failure(error): - raise AgentCommunityProtocolError( - f"MCP tool {name!r} returned a protocol-invalid result" - ) from error - raise AgentCommunityTransportError( - f"Failed while calling MCP tool {name!r}" + diagnostic = ( + "violated its advertised output schema" + if _is_output_schema_failure(error) + else "returned a protocol-invalid result" + ) + raise AgentCommunityProtocolError( + f"MCP tool {name!r} {diagnostic}" ) from error except MCPError as error: if error.code == REQUEST_TIMEOUT: diff --git a/tests/test_client_errors.py b/tests/test_client_errors.py index cd0d624..4e3d002 100644 --- a/tests/test_client_errors.py +++ b/tests/test_client_errors.py @@ -54,6 +54,19 @@ async def test_connection_failure_is_translated_with_cause() -> None: assert captured.value.__cause__ is upstream +@pytest.mark.anyio +async def test_connection_runtime_failure_remains_transport_error() -> None: + upstream = RuntimeError("MCP initialization state failed") + fake = FakeMCPClient() + fake.connect_error = upstream + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + with pytest.raises(AgentCommunityTransportError, match="connect") as captured: + await client.__aenter__() + + assert captured.value.__cause__ is upstream + + @pytest.mark.anyio async def test_connection_validation_failure_is_protocol_error_with_cause() -> None: with pytest.raises(ValidationError) as validation: @@ -71,9 +84,7 @@ async def test_connection_validation_failure_is_protocol_error_with_cause() -> N @pytest.mark.anyio async def test_tool_transport_failure_is_translated_with_cause() -> None: - transport_cause = ConnectionError("stream broke") - upstream = RuntimeError("MCP stream ended") - upstream.__cause__ = transport_cause + upstream = ConnectionError("MCP stream ended") fake = FakeMCPClient() fake.call_error = upstream client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) @@ -85,7 +96,6 @@ async def test_tool_transport_failure_is_translated_with_cause() -> None: await client._call_typed("get_community_stats", {}, CommunityStats) assert captured.value.__cause__ is upstream - assert upstream.__cause__ is transport_cause @pytest.mark.anyio diff --git a/tests/test_mcp_lifecycle_integration.py b/tests/test_mcp_lifecycle_integration.py index 054a37e..342baee 100644 --- a/tests/test_mcp_lifecycle_integration.py +++ b/tests/test_mcp_lifecycle_integration.py @@ -201,3 +201,47 @@ def factory(endpoint: str, timeout: float) -> Client: upstream = captured.value.__cause__ assert isinstance(upstream, RuntimeError) assert isinstance(upstream.__cause__, JsonSchemaValidationError) + + +@pytest.mark.anyio +async def test_real_missing_structured_content_is_protocol_error() -> None: + async def list_tools( + context: ServerRequestContext[Any, Any], + params: types.PaginatedRequestParams | None, + ) -> types.ListToolsResult: + del context, params + return types.ListToolsResult( + tools=[ + types.Tool( + name="missing", + input_schema={"type": "object"}, + output_schema={ + "type": "object", + "properties": {"member_count": {"type": "integer"}}, + "required": ["member_count"], + }, + ) + ] + ) + + async def call_tool( + context: ServerRequestContext[Any, Any], + params: types.CallToolRequestParams, + ) -> types.CallToolResult: + del context, params + return types.CallToolResult(content=[types.TextContent(text="missing")]) + + server = Server( + "missing-structured", on_list_tools=list_tools, on_call_tool=call_tool + ) + + def factory(endpoint: str, timeout: float) -> Client: + del endpoint + return Client(server, mode="legacy", read_timeout_seconds=timeout) + + sdk = AgentCommunityClient(_client_factory=factory) + async with sdk: + with pytest.raises(AgentCommunityProtocolError) as captured: + await sdk._call_typed("missing", {}, CommunityStats) + + assert isinstance(captured.value.__cause__, RuntimeError) From 3534e1577f96b8ea674ab920800125db521997ba Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:26:13 +0700 Subject: [PATCH 14/21] feat: wrap read-only Agent Community tools --- src/agentcommunity/client.py | 23 +++ tests/test_client_methods.py | 210 ++++++++++++++++++++++++ tests/test_contract_compatibility.py | 236 +++++++++++++++++++++++++++ tests/test_mcp_integration.py | 89 ++++++++++ 4 files changed, 558 insertions(+) create mode 100644 tests/test_client_methods.py create mode 100644 tests/test_contract_compatibility.py create mode 100644 tests/test_mcp_integration.py diff --git a/src/agentcommunity/client.py b/src/agentcommunity/client.py index 069a230..172b54f 100644 --- a/src/agentcommunity/client.py +++ b/src/agentcommunity/client.py @@ -11,6 +11,7 @@ from agentcommunity._transport import _ClientFactory, _MCPTransport from agentcommunity.errors import AgentCommunityProtocolError, AgentCommunityToolError +from agentcommunity.models import CertificateVerification, CommunityStats, MemberLookup _DEFAULT_ENDPOINT = "https://agentcommunity.org/mcp" _DEFAULT_TIMEOUT = 15.0 @@ -60,6 +61,28 @@ async def __aexit__( async def close(self) -> None: await self._transport.close() + async def community_stats(self) -> CommunityStats: + return await self._call_typed("get_community_stats", {}, CommunityStats) + + async def lookup_member(self, query: str) -> MemberLookup: + if not isinstance(query, str): + raise ValueError("query must be a string") + normalized_query = query.strip() + if not 1 <= len(normalized_query) <= 200: + raise ValueError("query must contain between 1 and 200 characters") + return await self._call_typed( + "lookup_member", {"query": normalized_query}, MemberLookup + ) + + async def verify_certificate(self, certificate_id: str) -> CertificateVerification: + if not isinstance(certificate_id, str): + raise ValueError("certificate_id must be a string") + return await self._call_typed( + "verify_certificate", + {"certificate_id": certificate_id}, + CertificateVerification, + ) + async def _call_typed( self, tool_name: str, diff --git a/tests/test_client_methods.py b/tests/test_client_methods.py new file mode 100644 index 0000000..8c31994 --- /dev/null +++ b/tests/test_client_methods.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from collections.abc import Awaitable, Callable +from typing import Any + +import pytest +from fakes import FakeMCPClient, RecordingClientFactory, successful_result + +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + CertificateVerification, + CommunityStats, + MemberLookup, +) + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio +async def test_community_stats_calls_exact_tool_and_returns_model() -> None: + fake = FakeMCPClient() + fake.results = [successful_result({"member_count": 29_700, "note": "members"})] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + result = await client.community_stats() + + assert result == CommunityStats(member_count=29_700, note="members") + assert fake.calls == [("get_community_stats", {})] + + +@pytest.mark.anyio +async def test_lookup_member_trims_query_and_returns_model() -> None: + fake = FakeMCPClient() + fake.results = [ + successful_result( + { + "status": "member", + "matches": [ + { + "display_name": "Example Agent", + "member_since": "2026-08-27", + "profile_url": "https://agentcommunity.org/m/example-agent", + } + ], + } + ) + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + result = await client.lookup_member(" Example Agent ") + + assert isinstance(result, MemberLookup) + assert result.status == "member" + assert fake.calls == [("lookup_member", {"query": "Example Agent"})] + + +@pytest.mark.anyio +@pytest.mark.parametrize("length", [1, 200]) +async def test_lookup_member_accepts_boundary_lengths(length: int) -> None: + fake = FakeMCPClient() + fake.results = [successful_result({"status": "not_found", "matches": []})] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + query = "q" * length + + async with client: + await client.lookup_member(f" {query} ") + + assert fake.calls == [("lookup_member", {"query": query})] + + +@pytest.mark.anyio +@pytest.mark.parametrize("query", ["", " ", "q" * 201, None, 123, ["agent"]]) +async def test_lookup_member_rejects_invalid_input_before_transport( + query: object, +) -> None: + factory = RecordingClientFactory() + client = AgentCommunityClient(_client_factory=factory) + + with pytest.raises(ValueError): + await client.lookup_member(query) # type: ignore[arg-type] + + assert factory.clients == [] + + +CERTIFICATE_STATES: dict[str, dict[str, object]] = { + "invalid_format": { + "valid_format": False, + "issued": False, + "agent_name": None, + "certificate_url": None, + }, + "not_found": { + "valid_format": True, + "issued": False, + "agent_name": None, + "certificate_url": None, + }, + "issued": { + "valid_format": True, + "issued": True, + "agent_name": "Example Agent", + "certificate_url": "https://agentcommunity.org/certificates/MESA-DD6-660J", + }, + "unavailable": { + "valid_format": True, + "issued": None, + "agent_name": None, + "certificate_url": None, + }, +} + + +@pytest.mark.anyio +@pytest.mark.parametrize("status", CERTIFICATE_STATES) +async def test_verify_certificate_returns_every_typed_state(status: str) -> None: + fake = FakeMCPClient() + fake.results = [ + successful_result( + { + "certificate_id": "MESA-DD6-660J", + "status": status, + **CERTIFICATE_STATES[status], + } + ) + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + result = await client.verify_certificate("MESA-DD6-660J") + + assert isinstance(result, CertificateVerification) + assert result.status == status + + +@pytest.mark.anyio +@pytest.mark.parametrize("certificate_id", ["", " ", "not-a-certificate", " X "]) +async def test_verify_certificate_passes_every_string_unchanged( + certificate_id: str, +) -> None: + fake = FakeMCPClient() + fake.results = [ + successful_result( + { + "certificate_id": certificate_id, + "status": "invalid_format", + **CERTIFICATE_STATES["invalid_format"], + } + ) + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + result = await client.verify_certificate(certificate_id) + + assert result.certificate_id == certificate_id + assert fake.calls == [("verify_certificate", {"certificate_id": certificate_id})] + + +@pytest.mark.anyio +async def test_verify_certificate_rejects_non_string_before_transport() -> None: + factory = RecordingClientFactory() + client = AgentCommunityClient(_client_factory=factory) + + with pytest.raises(ValueError): + await client.verify_certificate(123) # type: ignore[arg-type] + + assert factory.clients == [] + + +@pytest.mark.anyio +@pytest.mark.parametrize( + "invoke", + [ + pytest.param(lambda client: client.community_stats(), id="community-stats"), + pytest.param(lambda client: client.lookup_member("agent"), id="lookup-member"), + pytest.param( + lambda client: client.verify_certificate("bad"), id="verify-certificate" + ), + ], +) +async def test_each_method_translates_malformed_output_to_protocol_error( + invoke: Callable[[AgentCommunityClient], Awaitable[Any]], +) -> None: + fake = FakeMCPClient() + fake.results = [successful_result({"unexpected": True})] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + with pytest.raises(AgentCommunityProtocolError): + await invoke(client) + + +def test_client_exposes_only_the_approved_high_level_surface() -> None: + client = AgentCommunityClient() + + for forbidden_name in ( + "register_agent", + "client", + "session", + "sync", + "call_tool", + "one_shot", + ): + assert not hasattr(client, forbidden_name) diff --git a/tests/test_contract_compatibility.py b/tests/test_contract_compatibility.py new file mode 100644 index 0000000..b787f94 --- /dev/null +++ b/tests/test_contract_compatibility.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +import datetime +import inspect +from typing import Any, get_args + +import pytest +from fakes import FakeMCPClient, RecordingClientFactory, successful_result +from jsonschema import ( # type: ignore[import-untyped] + Draft202012Validator, + FormatChecker, +) +from pydantic import AnyUrl, BaseModel + +from agentcommunity import ( + AgentCommunityClient, + CertificateVerification, + CommunityStats, + MemberLookup, + MemberMatch, +) + +WRAPPED_TOOLS = { + "community_stats": "get_community_stats", + "lookup_member": "lookup_member", + "verify_certificate": "verify_certificate", +} + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +def product_tools(contract: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {tool["name"]: tool for tool in contract["product_tools"]} + + +@pytest.mark.anyio +async def test_emitted_arguments_satisfy_pinned_input_schemas( + contract: dict[str, Any], +) -> None: + fake = FakeMCPClient() + fake.results = [ + successful_result({"member_count": 1, "note": "fixture"}), + successful_result({"status": "not_found", "matches": []}), + successful_result( + { + "certificate_id": " malformed ", + "status": "invalid_format", + "valid_format": False, + "issued": False, + "agent_name": None, + "certificate_url": None, + } + ), + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + await client.community_stats() + await client.lookup_member(" member ") + await client.verify_certificate(" malformed ") + + tools = product_tools(contract) + for tool_name, arguments in fake.calls: + Draft202012Validator(tools[tool_name]["inputSchema"]).validate(arguments) + + assert fake.calls == [ + ("get_community_stats", {}), + ("lookup_member", {"query": "member"}), + ("verify_certificate", {"certificate_id": " malformed "}), + ] + + +@pytest.mark.parametrize( + ("tool_name", "model_type", "examples"), + [ + ( + "get_community_stats", + CommunityStats, + [{"member_count": 29_700, "note": "Verified members"}], + ), + ( + "lookup_member", + MemberLookup, + [ + {"status": "not_found", "matches": []}, + { + "status": "member", + "matches": [ + { + "display_name": "Example Agent", + "member_since": None, + "profile_url": "https://agentcommunity.org/m/example-agent", + } + ], + }, + { + "status": "ambiguous", + "matches": [ + { + "display_name": f"Example Agent {index}", + "member_since": "2026-08-27", + "profile_url": ( + f"https://agentcommunity.org/m/example-agent-{index}" + ), + } + for index in range(5) + ], + }, + ], + ), + ( + "verify_certificate", + CertificateVerification, + [ + { + "certificate_id": "bad", + "status": "invalid_format", + "valid_format": False, + "issued": False, + "agent_name": None, + "certificate_url": None, + }, + { + "certificate_id": "MESA-DD6-660J", + "status": "not_found", + "valid_format": True, + "issued": False, + "agent_name": None, + "certificate_url": None, + }, + { + "certificate_id": "MESA-DD6-660J", + "status": "issued", + "valid_format": True, + "issued": True, + "agent_name": "Example Agent", + "certificate_url": ( + "https://agentcommunity.org/certificates/MESA-DD6-660J" + ), + }, + { + "certificate_id": "MESA-DD6-660J", + "status": "unavailable", + "valid_format": True, + "issued": None, + "agent_name": None, + "certificate_url": None, + }, + ], + ), + ], +) +def test_fixture_valid_outputs_map_directly_to_public_models( + contract: dict[str, Any], + tool_name: str, + model_type: type[BaseModel], + examples: list[dict[str, object]], +) -> None: + schema = product_tools(contract)[tool_name]["outputSchema"] + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + + for example in examples: + validator.validate(example) + result = model_type.model_validate(example) + assert set(result.model_dump(mode="json")) == set(schema["required"]) + + +def test_public_model_shapes_align_with_pinned_output_contract( + contract: dict[str, Any], +) -> None: + tools = product_tools(contract) + models: dict[str, type[BaseModel]] = { + "get_community_stats": CommunityStats, + "lookup_member": MemberLookup, + "verify_certificate": CertificateVerification, + } + + for tool_name, model_type in models.items(): + output_schema = tools[tool_name]["outputSchema"] + assert set(model_type.model_fields) == set(output_schema["properties"]) + assert { + name + for name, field in model_type.model_fields.items() + if field.is_required() + } == set(output_schema["required"]) + + assert CommunityStats.model_fields["member_count"].annotation is int + assert CommunityStats.model_fields["note"].annotation is str + + lookup_schema = tools["lookup_member"]["outputSchema"] + assert set(get_args(MemberLookup.model_fields["status"].annotation)) == set( + lookup_schema["properties"]["status"]["enum"] + ) + assert MemberLookup.model_fields["matches"].metadata[0].max_length == 5 + match_schema = lookup_schema["properties"]["matches"]["items"] + assert set(MemberMatch.model_fields) == set(match_schema["properties"]) + assert set(MemberMatch.model_fields) == set(match_schema["required"]) + assert MemberMatch.model_fields["display_name"].annotation is str + assert MemberMatch.model_fields["member_since"].annotation == datetime.date | None + assert MemberMatch.model_fields["profile_url"].annotation is AnyUrl + + certificate_schema = tools["verify_certificate"]["outputSchema"] + assert set( + get_args(CertificateVerification.model_fields["status"].annotation) + ) == set(certificate_schema["properties"]["status"]["enum"]) + assert CertificateVerification.model_fields["certificate_id"].annotation is str + assert CertificateVerification.model_fields["valid_format"].annotation is bool + for field_name in ("issued", "agent_name", "certificate_url"): + assert type(None) in get_args( + CertificateVerification.model_fields[field_name].annotation + ) + assert "null" in certificate_schema["properties"][field_name]["type"] + assert CertificateVerification.model_fields["certificate_url"].annotation == ( + AnyUrl | None + ) + + +def test_only_approved_contract_tools_have_public_wrappers( + contract: dict[str, Any], +) -> None: + contract_names = set(product_tools(contract)) + public_async_methods = { + name + for name, value in inspect.getmembers( + AgentCommunityClient, predicate=inspect.iscoroutinefunction + ) + if not name.startswith("_") and name != "close" + } + + assert set(WRAPPED_TOOLS.values()) <= contract_names + assert "register_agent" in contract_names + assert public_async_methods == set(WRAPPED_TOOLS) + assert not hasattr(AgentCommunityClient, "register_agent") diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py new file mode 100644 index 0000000..986da91 --- /dev/null +++ b/tests/test_mcp_integration.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any, cast + +import pytest +from mcp import Client +from mcp.server.mcpserver import MCPServer + +from agentcommunity import ( + AgentCommunityClient, + CertificateVerification, + CommunityStats, + MemberLookup, +) + + +@pytest.fixture +def anyio_backend() -> str: + return "asyncio" + + +@pytest.mark.anyio +async def test_real_mcp_client_executes_all_three_typed_methods_on_one_connection() -> ( + None +): + server = MCPServer("typed-read-only-tools") + calls: list[tuple[str, dict[str, str]]] = [] + + @server.tool(name="get_community_stats", structured_output=True) + def get_community_stats() -> dict[str, object]: + calls.append(("get_community_stats", {})) + return {"member_count": 29_700, "note": "Verified members"} + + @server.tool(name="lookup_member", structured_output=True) + def lookup_member(query: str) -> dict[str, object]: + calls.append(("lookup_member", {"query": query})) + return { + "status": "member", + "matches": [ + { + "display_name": query, + "member_since": "2026-08-27", + "profile_url": "https://agentcommunity.org/m/example-agent", + } + ], + } + + @server.tool(name="verify_certificate", structured_output=True) + def verify_certificate(certificate_id: str) -> dict[str, object]: + calls.append(("verify_certificate", {"certificate_id": certificate_id})) + return { + "certificate_id": certificate_id, + "status": "issued", + "valid_format": True, + "issued": True, + "agent_name": "Example Agent", + "certificate_url": "https://agentcommunity.org/certificates/MESA-DD6-660J", + } + + @server.tool(name="unrelated_tool", structured_output=True) + def unrelated_tool() -> dict[str, bool]: + return {"ignored": True} + + clients: list[Client] = [] + + def factory(endpoint: str, timeout: float) -> Client: + del endpoint + client = Client(server, read_timeout_seconds=timeout) + clients.append(client) + return client + + sdk = AgentCommunityClient(_client_factory=factory) + async with sdk: + stats = await sdk.community_stats() + lookup = await sdk.lookup_member(" Example Agent ") + certificate = await sdk.verify_certificate("MESA-DD6-660J") + + assert isinstance(stats, CommunityStats) + assert isinstance(lookup, MemberLookup) + assert isinstance(certificate, CertificateVerification) + assert calls == [ + ("get_community_stats", {}), + ("lookup_member", {"query": "Example Agent"}), + ("verify_certificate", {"certificate_id": "MESA-DD6-660J"}), + ] + assert len(clients) == 1 + assert clients[0]._session is None + exit_stack = cast(Any, clients[0]._exit_stack) + assert not exit_stack._exit_callbacks From 77557b229a3d156ae37fc4cfd6b61c245acfd023 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:30:07 +0700 Subject: [PATCH 15/21] test: derive SDK limits from MCP contract --- tests/test_contract_compatibility.py | 43 ++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/test_contract_compatibility.py b/tests/test_contract_compatibility.py index b787f94..6e2770c 100644 --- a/tests/test_contract_compatibility.py +++ b/tests/test_contract_compatibility.py @@ -73,6 +73,36 @@ async def test_emitted_arguments_satisfy_pinned_input_schemas( ] +@pytest.mark.anyio +async def test_lookup_validation_tracks_pinned_input_boundaries( + contract: dict[str, Any], +) -> None: + query_schema = product_tools(contract)["lookup_member"]["inputSchema"][ + "properties" + ]["query"] + minimum = query_schema["minLength"] + maximum = query_schema["maxLength"] + accepted_queries = ["q" * minimum, "q" * maximum] + rejected_queries = ["q" * (minimum - 1), "q" * (maximum + 1)] + fake = FakeMCPClient() + fake.results = [ + successful_result({"status": "not_found", "matches": []}) + for _ in accepted_queries + ] + client = AgentCommunityClient(_client_factory=RecordingClientFactory(lambda: fake)) + + async with client: + for query in accepted_queries: + await client.lookup_member(f" {query} ") + for query in rejected_queries: + with pytest.raises(ValueError): + await client.lookup_member(query) + + assert fake.calls == [ + ("lookup_member", {"query": query}) for query in accepted_queries + ] + + @pytest.mark.parametrize( ("tool_name", "model_type", "examples"), [ @@ -100,13 +130,10 @@ async def test_emitted_arguments_satisfy_pinned_input_schemas( "status": "ambiguous", "matches": [ { - "display_name": f"Example Agent {index}", + "display_name": "Example Agent 2", "member_since": "2026-08-27", - "profile_url": ( - f"https://agentcommunity.org/m/example-agent-{index}" - ), + "profile_url": "https://agentcommunity.org/m/example-agent-2", } - for index in range(5) ], }, ], @@ -194,7 +221,11 @@ def test_public_model_shapes_align_with_pinned_output_contract( assert set(get_args(MemberLookup.model_fields["status"].annotation)) == set( lookup_schema["properties"]["status"]["enum"] ) - assert MemberLookup.model_fields["matches"].metadata[0].max_length == 5 + model_matches_schema = MemberLookup.model_json_schema()["properties"]["matches"] + assert ( + model_matches_schema["maxItems"] + == lookup_schema["properties"]["matches"]["maxItems"] + ) match_schema = lookup_schema["properties"]["matches"]["items"] assert set(MemberMatch.model_fields) == set(match_schema["properties"]) assert set(MemberMatch.model_fields) == set(match_schema["required"]) From c0d4fa6a15854d5da2ddf4a08e5cd5b9d8f234d9 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:36:46 +0700 Subject: [PATCH 16/21] test: strengthen SDK contract compatibility --- tests/test_contract_compatibility.py | 93 +++++++++++++++++++++++++--- tests/test_mcp_integration.py | 30 ++++++--- 2 files changed, 103 insertions(+), 20 deletions(-) diff --git a/tests/test_contract_compatibility.py b/tests/test_contract_compatibility.py index 6e2770c..a1d2660 100644 --- a/tests/test_contract_compatibility.py +++ b/tests/test_contract_compatibility.py @@ -2,6 +2,7 @@ import datetime import inspect +from copy import deepcopy from typing import Any, get_args import pytest @@ -36,6 +37,52 @@ def product_tools(contract: dict[str, Any]) -> dict[str, dict[str, Any]]: return {tool["name"]: tool for tool in contract["product_tools"]} +def assert_schema_type_and_format( + schema: dict[str, Any], + expected_types: set[str], + expected_format: str | None = None, +) -> None: + raw_type = schema.get("type") + actual_types = {raw_type} if isinstance(raw_type, str) else set(raw_type or []) + assert actual_types == expected_types + assert schema.get("format") == expected_format + + +def assert_public_scalar_compatibility( + tools: dict[str, dict[str, Any]], +) -> None: + stats = tools["get_community_stats"]["outputSchema"]["properties"] + assert CommunityStats.model_fields["member_count"].annotation is int + assert_schema_type_and_format(stats["member_count"], {"integer"}) + assert CommunityStats.model_fields["note"].annotation is str + assert_schema_type_and_format(stats["note"], {"string"}) + + matches = tools["lookup_member"]["outputSchema"]["properties"]["matches"] + match = matches["items"]["properties"] + assert MemberMatch.model_fields["display_name"].annotation is str + assert_schema_type_and_format(match["display_name"], {"string"}) + assert MemberMatch.model_fields["member_since"].annotation == datetime.date | None + assert_schema_type_and_format(match["member_since"], {"string", "null"}, "date") + assert MemberMatch.model_fields["profile_url"].annotation is AnyUrl + assert_schema_type_and_format(match["profile_url"], {"string"}, "uri") + + certificate = tools["verify_certificate"]["outputSchema"]["properties"] + assert CertificateVerification.model_fields["certificate_id"].annotation is str + assert_schema_type_and_format(certificate["certificate_id"], {"string"}) + assert CertificateVerification.model_fields["valid_format"].annotation is bool + assert_schema_type_and_format(certificate["valid_format"], {"boolean"}) + assert CertificateVerification.model_fields["issued"].annotation == bool | None + assert_schema_type_and_format(certificate["issued"], {"boolean", "null"}) + assert CertificateVerification.model_fields["agent_name"].annotation == str | None + assert_schema_type_and_format(certificate["agent_name"], {"string", "null"}) + assert CertificateVerification.model_fields["certificate_url"].annotation == ( + AnyUrl | None + ) + assert_schema_type_and_format( + certificate["certificate_url"], {"string", "null"}, "uri" + ) + + @pytest.mark.anyio async def test_emitted_arguments_satisfy_pinned_input_schemas( contract: dict[str, Any], @@ -214,8 +261,7 @@ def test_public_model_shapes_align_with_pinned_output_contract( if field.is_required() } == set(output_schema["required"]) - assert CommunityStats.model_fields["member_count"].annotation is int - assert CommunityStats.model_fields["note"].annotation is str + assert_public_scalar_compatibility(tools) lookup_schema = tools["lookup_member"]["outputSchema"] assert set(get_args(MemberLookup.model_fields["status"].annotation)) == set( @@ -229,24 +275,51 @@ def test_public_model_shapes_align_with_pinned_output_contract( match_schema = lookup_schema["properties"]["matches"]["items"] assert set(MemberMatch.model_fields) == set(match_schema["properties"]) assert set(MemberMatch.model_fields) == set(match_schema["required"]) - assert MemberMatch.model_fields["display_name"].annotation is str - assert MemberMatch.model_fields["member_since"].annotation == datetime.date | None - assert MemberMatch.model_fields["profile_url"].annotation is AnyUrl certificate_schema = tools["verify_certificate"]["outputSchema"] assert set( get_args(CertificateVerification.model_fields["status"].annotation) ) == set(certificate_schema["properties"]["status"]["enum"]) - assert CertificateVerification.model_fields["certificate_id"].annotation is str - assert CertificateVerification.model_fields["valid_format"].annotation is bool for field_name in ("issued", "agent_name", "certificate_url"): assert type(None) in get_args( CertificateVerification.model_fields[field_name].annotation ) assert "null" in certificate_schema["properties"][field_name]["type"] - assert CertificateVerification.model_fields["certificate_url"].annotation == ( - AnyUrl | None - ) + + +@pytest.mark.parametrize( + ("tool_name", "nested_matches", "field_name", "mutated_schema"), + [ + ("get_community_stats", False, "member_count", {"type": "number"}), + ( + "verify_certificate", + False, + "issued", + {"type": ["boolean", "integer", "null"]}, + ), + ( + "lookup_member", + True, + "member_since", + {"type": ["string", "null"]}, + ), + ], +) +def test_scalar_compatibility_rejects_incompatible_contract_mutations( + contract: dict[str, Any], + tool_name: str, + nested_matches: bool, + field_name: str, + mutated_schema: dict[str, Any], +) -> None: + tools = product_tools(deepcopy(contract)) + properties = tools[tool_name]["outputSchema"]["properties"] + if nested_matches: + properties = properties["matches"]["items"]["properties"] + properties[field_name] = mutated_schema + + with pytest.raises(AssertionError): + assert_public_scalar_compatibility(tools) def test_only_approved_contract_tools_have_public_wrappers( diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py index 986da91..20d7801 100644 --- a/tests/test_mcp_integration.py +++ b/tests/test_mcp_integration.py @@ -1,7 +1,9 @@ from __future__ import annotations -from typing import Any, cast +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +import anyio import pytest from mcp import Client from mcp.server.mcpserver import MCPServer @@ -23,7 +25,17 @@ def anyio_backend() -> str: async def test_real_mcp_client_executes_all_three_typed_methods_on_one_connection() -> ( None ): - server = MCPServer("typed-read-only-tools") + cleanup_complete = anyio.Event() + + @asynccontextmanager + async def lifespan(server: MCPServer[None]) -> AsyncIterator[None]: + del server + try: + yield None + finally: + cleanup_complete.set() + + server = MCPServer("typed-read-only-tools", lifespan=lifespan) calls: list[tuple[str, dict[str, str]]] = [] @server.tool(name="get_community_stats", structured_output=True) @@ -61,13 +73,13 @@ def verify_certificate(certificate_id: str) -> dict[str, object]: def unrelated_tool() -> dict[str, bool]: return {"ignored": True} - clients: list[Client] = [] + connection_count = 0 def factory(endpoint: str, timeout: float) -> Client: + nonlocal connection_count del endpoint - client = Client(server, read_timeout_seconds=timeout) - clients.append(client) - return client + connection_count += 1 + return Client(server, read_timeout_seconds=timeout) sdk = AgentCommunityClient(_client_factory=factory) async with sdk: @@ -83,7 +95,5 @@ def factory(endpoint: str, timeout: float) -> Client: ("lookup_member", {"query": "Example Agent"}), ("verify_certificate", {"certificate_id": "MESA-DD6-660J"}), ] - assert len(clients) == 1 - assert clients[0]._session is None - exit_stack = cast(Any, clients[0]._exit_stack) - assert not exit_stack._exit_callbacks + assert connection_count == 1 + assert cleanup_complete.is_set() From 3673dc5a0ad8a0577188d60a26e5b4f98215e79b Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:48:25 +0700 Subject: [PATCH 17/21] chore: prepare SDK release pipeline --- .github/workflows/ci.yml | 72 +++++++++ .github/workflows/production-smoke.yml | 23 +++ .github/workflows/publish.yml | 65 +++++++++ CHANGELOG.md | 13 ++ CONTRIBUTING.md | 27 ++++ LICENSE | 21 +++ README.md | 100 +++++++++++++ SECURITY.md | 18 +++ pyproject.toml | 16 +- scripts/verify-artifacts.sh | 193 +++++++++++++++++++++++++ src/agentcommunity/__init__.py | 14 +- tests/smoke/test_production.py | 18 +++ tests/test_build_artifacts.py | 6 + tests/test_errors.py | 12 +- tests/test_public_api.py | 66 +++++++++ tests/test_readme_examples.py | 88 +++++++++++ 16 files changed, 737 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/production-smoke.yml create mode 100644 .github/workflows/publish.yml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100755 scripts/verify-artifacts.sh create mode 100644 tests/smoke/test_production.py create mode 100644 tests/test_public_api.py create mode 100644 tests/test_readme_examples.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f7769fe --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,72 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + quality: + name: Python ${{ matrix.python }} / ${{ matrix.dependencies }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - python: "3.10" + dependencies: lowest-direct + - python: "3.14" + dependencies: newest-allowed + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + cache: pip + - name: Install lowest direct dependency bounds + if: matrix.dependencies == 'lowest-direct' + run: | + python -m pip install --upgrade pip + python -m pip install \ + 'jsonschema==4.20.0' \ + 'mcp==2.1.1' \ + 'pydantic==2.12.0' \ + -e '.[dev]' + python - <<'PY' + from importlib.metadata import version + + assert version("jsonschema") == "4.20.0" + assert version("mcp") == "2.1.1" + assert version("pydantic") == "2.12.0" + PY + - name: Install newest allowed dependency resolution + if: matrix.dependencies == 'newest-allowed' + run: | + python -m pip install --upgrade pip + python -m pip install --upgrade --upgrade-strategy eager -e '.[dev]' + - run: python -m ruff check . + - run: python -m ruff format --check . + - run: python -m mypy src tests + - run: python -m pytest -q + + artifacts: + name: Build and verify distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + - run: python -m pip install --upgrade pip 'build>=1.3,<2' 'twine>=6.1,<7' + - run: python -m build + - run: python -m twine check dist/* + - run: bash scripts/verify-artifacts.sh dist + - uses: actions/upload-artifact@v4 + with: + name: distributions-${{ github.sha }} + path: dist/ + if-no-files-found: error + retention-days: 7 diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml new file mode 100644 index 0000000..b74ffc4 --- /dev/null +++ b/.github/workflows/production-smoke.yml @@ -0,0 +1,23 @@ +name: Production smoke + +on: + schedule: + - cron: "17 4 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + smoke: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + - run: python -m pip install -e '.[dev]' + - name: Run explicitly marked read-only production checks + run: python -m pytest -q -m production tests/smoke/test_production.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..f2f18fe --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,65 @@ +name: Publish + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + - run: python -m pip install --upgrade pip 'build>=1.3,<2' + - run: python -m build + - name: Upload immutable release distributions + id: distributions + uses: actions/upload-artifact@v4 + with: + name: release-distributions-${{ github.sha }} + path: dist/ + if-no-files-found: error + retention-days: 7 + - name: Record artifact digest + run: echo '${{ steps.distributions.outputs.artifact-digest }}' + + verify: + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.14" + cache: pip + - uses: actions/download-artifact@v4 + with: + name: release-distributions-${{ github.sha }} + path: dist + - run: python -m pip install --upgrade pip 'twine>=6.1,<7' + - run: bash scripts/verify-artifacts.sh dist + + publish: + needs: verify + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: release-distributions-${{ github.sha }} + path: dist + - name: Publish verified distributions with Trusted Publishing + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + attestations: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..016f31c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +## 0.1.0 - 2026-08-27 + +- Add the initial asynchronous, read-only Agent Community MCP client. +- Add typed models for community statistics, member lookup, and certificate + verification. +- Add package-specific transport, protocol, and tool error handling. +- Add deterministic contract, integration, documentation, and artifact checks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0013a6f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,27 @@ +# Contributing + +Use Python 3.10 or newer and create an isolated environment: + +```bash +python -m venv .venv +.venv/bin/python -m pip install --upgrade pip +.venv/bin/python -m pip install -e ".[dev]" +``` + +Run the full local gate before submitting a change: + +```bash +.venv/bin/python -m ruff check . +.venv/bin/python -m ruff format --check . +.venv/bin/python -m mypy src tests +.venv/bin/python -m pytest -q +rm -rf dist +.venv/bin/python -m build +.venv/bin/python -m twine check dist/* +bash scripts/verify-artifacts.sh dist +``` + +The default test suite is entirely offline and excludes tests marked +`production`. Do not run the production smoke test as part of ordinary local or +pull-request verification. Keep changes focused, add a failing test before a +behavior change, and update documentation with the code it describes. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cfe612c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Open Agent Registry, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a918232 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# Agent Community Python SDK + +`agentcommunity` is the official typed Python wrapper for the public Agent +Community MCP endpoint at `https://agentcommunity.org/mcp`. Version 0.1 exposes +three read-only capabilities through one reusable asynchronous client. + +## Installation + +Install the package from a checked-out release artifact or source tree: + +```bash +python -m pip install ./dist/agentcommunity-0.1.0-py3-none-any.whl +``` + +Publication to PyPI is a separate release step; this repository does not claim +that the package is currently available there. + +## Usage + +Enter the client once and reuse that connection for sequential calls: + + +```python +import asyncio + +from agentcommunity import AgentCommunityClient + + +async def main() -> None: + async with AgentCommunityClient() as client: + stats = await client.community_stats() + lookup = await client.lookup_member("Example Agent") + certificate = await client.verify_certificate("MESA-DD6-660J") + + print(stats.member_count) + print(lookup.status, len(lookup.matches)) + print(certificate.status) + + +asyncio.run(main()) +``` + + +The methods return frozen, strictly validated Pydantic models: + +- `community_stats() -> CommunityStats` returns the verified member count and + its explanatory note. +- `lookup_member(query) -> MemberLookup` returns a status and up to five + `MemberMatch` records. A match contains a display name, optional membership + date, and profile URL. +- `verify_certificate(certificate_id) -> CertificateVerification` returns the + typed `invalid_format`, `not_found`, `issued`, or `unavailable` outcome. + +Registration is intentionally omitted. `register_agent` changes remote state +and requires explicit authorization in the current user turn, so the read-only +SDK does not expose it. + +## Errors and configuration + +Package failures share the `AgentCommunityError` base class: + +```python +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + AgentCommunityToolError, + AgentCommunityTransportError, +) + +try: + async with AgentCommunityClient( + endpoint="https://agentcommunity.org/mcp", + timeout=10.0, + ) as client: + stats = await client.community_stats() +except AgentCommunityTransportError: + # Connection, transport, or timeout failure. + raise +except AgentCommunityProtocolError: + # Lifecycle misuse or an invalid MCP response. + raise +except AgentCommunityToolError: + # The remote MCP tool returned an error result. + raise +``` + +The endpoint must be an absolute HTTP(S) URL. The positive finite timeout covers +connection and tool operations. There are no automatic retries in version 0.1. + +## Support policy + +Python 3.10 and newer is supported. Runtime dependencies are deliberately +bounded to compatible major versions: `jsonschema>=4.20,<5`, `mcp>=2.1.1,<3`, +and `pydantic>=2.12,<3`. Only the latest 0.1 patch release receives security and +compatibility fixes while the project is in its initial alpha series. + +See the [MCP documentation](https://agentcommunity.org/mcp/docs), +[project source](https://github.com/agentcommunity/agentcommunity-python), +[issue tracker](https://github.com/agentcommunity/agentcommunity-python/issues), +and [security policy](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..e420bee --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,18 @@ +# Security policy + +## Supported versions + +While the SDK is in the 0.1 alpha series, only the latest 0.1 patch release is +supported with security fixes. Older releases should be upgraded before a +report is evaluated. + +## Reporting a vulnerability + +Please do not open a public issue for a suspected vulnerability. Report it +privately to `support@agentcommunity.org` and include the affected version, +impact, reproduction steps, and any suggested remediation. The official +[Agent Community contact page](https://agentcommunity.org/contact) is the source +for current contact routes. + +Do not include secrets or personal data beyond what is necessary to explain the +issue. We will acknowledge the report and coordinate disclosure after triage. diff --git a/pyproject.toml b/pyproject.toml index e0bd20c..b10c85d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" name = "agentcommunity" version = "0.1.0" description = "Typed Python client for the Agent Community MCP endpoint" -readme = { text = "Official typed Python client for the Agent Community MCP endpoint.", content-type = "text/markdown" } +readme = "README.md" requires-python = ">=3.10" license = "MIT" classifiers = [ @@ -45,10 +45,22 @@ dev = [ [tool.hatch.build.targets.wheel] packages = ["src/agentcommunity"] +[tool.hatch.build.targets.sdist] +exclude = [ + "/.github", + "/docs", + "/scripts", + "/tests", + "/uv.lock", +] + [tool.pytest.ini_options] -addopts = "-ra" +addopts = "-ra -m 'not production'" testpaths = ["tests"] pythonpath = ["src"] +markers = [ + "production: tests that call the live Agent Community endpoint", +] [tool.ruff] target-version = "py310" diff --git a/scripts/verify-artifacts.sh b/scripts/verify-artifacts.sh new file mode 100755 index 0000000..0761040 --- /dev/null +++ b/scripts/verify-artifacts.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 ARTIFACT_DIRECTORY" >&2 + exit 64 +fi + +if [[ ! -d "$1" ]]; then + echo "artifact directory does not exist: $1" >&2 + exit 66 +fi + +artifact_dir="$(cd "$1" && pwd -P)" +if [[ "$artifact_dir" != /* || "$artifact_dir" == "/" ]]; then + echo "could not resolve a safe artifact directory" >&2 + exit 66 +fi + +shopt -s nullglob +wheels=("$artifact_dir"/*.whl) +sdists=("$artifact_dir"/*.tar.gz) +shopt -u nullglob + +if [[ ${#wheels[@]} -ne 1 || ${#sdists[@]} -ne 1 ]]; then + echo "expected exactly one wheel and one .tar.gz sdist in $artifact_dir" >&2 + exit 65 +fi + +wheel="${wheels[0]}" +sdist="${sdists[0]}" +temp_dir="$(mktemp -d "${TMPDIR:-/tmp}/agentcommunity-artifacts.XXXXXX")" +case "$temp_dir" in + /*/agentcommunity-artifacts.*) ;; + *) + echo "refusing unsafe temporary path: $temp_dir" >&2 + exit 70 + ;; +esac + +cleanup() { + if [[ -n "${temp_dir:-}" && -d "$temp_dir" ]]; then + rm -rf -- "$temp_dir" + fi +} +trap cleanup EXIT HUP INT TERM + +unset PYTHONPATH +python_bin="" +for candidate in "${PYTHON:-}" python "$PWD/.venv/bin/python" python3; do + if [[ -n "$candidate" ]] && command -v "$candidate" >/dev/null 2>&1 \ + && "$candidate" -c "import twine" >/dev/null 2>&1; then + python_bin="$candidate" + break + fi +done +if [[ -z "$python_bin" ]]; then + echo "no Python interpreter with Twine installed was found" >&2 + exit 69 +fi +"$python_bin" -m twine check "$wheel" "$sdist" + +"$python_bin" - "$wheel" "$sdist" <<'PY' +from __future__ import annotations + +import re +import sys +import tarfile +import zipfile +from pathlib import PurePosixPath + +wheel_path, sdist_path = sys.argv[1:] +expected_package_files = { + "agentcommunity/__init__.py", + "agentcommunity/_transport.py", + "agentcommunity/client.py", + "agentcommunity/errors.py", + "agentcommunity/models.py", + "agentcommunity/py.typed", +} +def validate_name(archive_path: str, name: str) -> None: + parts = PurePosixPath(name).parts + lowered = {part.lower() for part in parts} + assert ".git" not in parts, f"git metadata shipped in {archive_path}: {name}" + assert not lowered.intersection( + {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache"} + ), f"cache shipped in {archive_path}: {name}" + assert not any( + part == ".env" or part.startswith(".env.") for part in parts + ), f"environment file shipped in {archive_path}: {name}" + assert "uv.lock" not in parts, f"local lock file shipped in {archive_path}: {name}" + assert "tests" not in parts, f"tests-only content shipped in {archive_path}: {name}" + assert not any( + part.lower() in {"credentials", "secrets"} + or part.lower().endswith((".key", ".pem")) + for part in parts + ), f"possible secret material shipped in {archive_path}: {name}" + + +def validate_contents(archive_path: str, name: str, data: bytes) -> None: + assert re.search(rb"gitdir:\s*(?:/|[A-Za-z]:[\\/])", data) is None, ( + f"absolute gitdir reference shipped in {archive_path}: {name}" + ) + + +with zipfile.ZipFile(wheel_path) as archive: + for name in archive.namelist(): + validate_name(wheel_path, name) + if not name.endswith("/"): + validate_contents(wheel_path, name, archive.read(name)) + +with tarfile.open(sdist_path, mode="r:gz") as archive: + for member in archive.getmembers(): + name = member.name + validate_name(sdist_path, name) + if member.isfile(): + extracted = archive.extractfile(member) + assert extracted is not None + validate_contents(sdist_path, name, extracted.read()) + +with zipfile.ZipFile(wheel_path) as archive: + wheel_names = set(archive.namelist()) +package_files = {name for name in wheel_names if name.startswith("agentcommunity/")} +assert package_files == expected_package_files, ( + f"unexpected wheel package files: {sorted(package_files ^ expected_package_files)}" +) +assert not any("tests" in PurePosixPath(name).parts for name in wheel_names) +assert not any(name.endswith("entry_points.txt") for name in wheel_names) +assert all( + name.startswith("agentcommunity/") or ".dist-info/" in name + for name in wheel_names +), f"unexpected top-level wheel content: {sorted(wheel_names)}" +PY + +verify_install() { + local artifact="$1" + local environment="$2" + "$python_bin" -m venv "$environment" + "$environment/bin/python" -m pip install --disable-pip-version-check "$artifact" + ( + cd "$temp_dir" + "$environment/bin/python" - <<'PY' +from importlib.metadata import distribution, version +from importlib.resources import files + +import agentcommunity +from agentcommunity import AgentCommunityClient, CommunityStats + +expected = [ + "__version__", + "AgentCommunityClient", + "CommunityStats", + "MemberMatch", + "MemberLookup", + "CertificateVerification", + "AgentCommunityError", + "AgentCommunityTransportError", + "AgentCommunityProtocolError", + "AgentCommunityToolError", +] +assert agentcommunity.__all__ == expected +assert agentcommunity.__version__ == "0.1.0" +assert version("agentcommunity") == "0.1.0" +assert files("agentcommunity").joinpath("py.typed").is_file() + +metadata = distribution("agentcommunity").metadata +assert metadata["Requires-Python"] == ">=3.10" +assert metadata["License-Expression"] == "MIT" +assert set(metadata.get_all("Requires-Dist") or ()) >= { + "jsonschema<5,>=4.20", + "mcp<3,>=2.1.1", + "pydantic<3,>=2.12", +} +assert set(metadata.get_all("Project-URL") or ()) == { + "Homepage, https://agentcommunity.org", + "Documentation, https://agentcommunity.org/mcp/docs", + "Source, https://github.com/agentcommunity/agentcommunity-python", + "Issues, https://github.com/agentcommunity/agentcommunity-python/issues", +} + +client = AgentCommunityClient(endpoint="http://127.0.0.1:9/mcp", timeout=1.0) +assert client.endpoint == "http://127.0.0.1:9/mcp" +assert client.timeout == 1.0 +stats = CommunityStats(member_count=0, note="offline artifact check") +assert stats.member_count == 0 +PY + ) +} + +verify_install "$wheel" "$temp_dir/wheel-venv" +verify_install "$sdist" "$temp_dir/sdist-venv" + +echo "verified wheel and sdist: $artifact_dir" diff --git a/src/agentcommunity/__init__.py b/src/agentcommunity/__init__.py index 1c90c60..a7c2de0 100644 --- a/src/agentcommunity/__init__.py +++ b/src/agentcommunity/__init__.py @@ -14,15 +14,15 @@ __version__ = "0.1.0" -__all__ = [ +__all__ = [ # noqa: RUF022 - public contract order is intentional + "__version__", "AgentCommunityClient", + "CommunityStats", + "MemberMatch", + "MemberLookup", + "CertificateVerification", "AgentCommunityError", + "AgentCommunityTransportError", "AgentCommunityProtocolError", "AgentCommunityToolError", - "AgentCommunityTransportError", - "CertificateVerification", - "CommunityStats", - "MemberLookup", - "MemberMatch", - "__version__", ] diff --git a/tests/smoke/test_production.py b/tests/smoke/test_production.py new file mode 100644 index 0000000..75aab11 --- /dev/null +++ b/tests/smoke/test_production.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +import pytest + +from agentcommunity import AgentCommunityClient + + +@pytest.mark.production +@pytest.mark.anyio +async def test_read_only_production_tools() -> None: + async with AgentCommunityClient(timeout=8.0) as client: + stats = await client.community_stats() + certificate = await client.verify_certificate("invalid") + + assert stats.member_count >= 0 + assert certificate.status == "invalid_format" + assert certificate.valid_format is False + assert certificate.issued is False diff --git a/tests/test_build_artifacts.py b/tests/test_build_artifacts.py index 3c85ec0..e362590 100644 --- a/tests/test_build_artifacts.py +++ b/tests/test_build_artifacts.py @@ -24,6 +24,12 @@ def test_sdist_excludes_local_worktree_metadata(tmp_path: Path) -> None: with tarfile.open(sdist_path, mode="r:gz") as archive: members = archive.getmembers() assert all(".git" not in PurePosixPath(member.name).parts for member in members) + assert all( + "uv.lock" not in PurePosixPath(member.name).parts for member in members + ) + assert all( + "tests" not in PurePosixPath(member.name).parts for member in members + ) for member in members: if not member.isfile(): diff --git a/tests/test_errors.py b/tests/test_errors.py index 52712d6..cfa6c4d 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -18,15 +18,15 @@ def test_public_exception_hierarchy() -> None: def test_public_exports_are_exact() -> None: assert agentcommunity.__all__ == [ + "__version__", "AgentCommunityClient", + "CommunityStats", + "MemberMatch", + "MemberLookup", + "CertificateVerification", "AgentCommunityError", + "AgentCommunityTransportError", "AgentCommunityProtocolError", "AgentCommunityToolError", - "AgentCommunityTransportError", - "CertificateVerification", - "CommunityStats", - "MemberLookup", - "MemberMatch", - "__version__", ] assert not hasattr(agentcommunity, "register_agent") diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..aea3294 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.metadata +from pathlib import Path + +import tomllib # type: ignore[import-untyped] + +import agentcommunity + +EXPECTED_PUBLIC_API = [ + "__version__", + "AgentCommunityClient", + "CommunityStats", + "MemberMatch", + "MemberLookup", + "CertificateVerification", + "AgentCommunityError", + "AgentCommunityTransportError", + "AgentCommunityProtocolError", + "AgentCommunityToolError", +] + + +def test_public_api_is_exactly_the_approved_read_only_surface() -> None: + assert agentcommunity.__all__ == EXPECTED_PUBLIC_API + assert not hasattr(agentcommunity.AgentCommunityClient, "register_agent") + + forbidden_names = { + "Client", + "MCPClient", + "Session", + "SyncAgentCommunityClient", + "community_stats", + "lookup_member", + "register_agent", + "verify_certificate", + } + assert forbidden_names.isdisjoint(agentcommunity.__all__) + + +def test_project_metadata_matches_the_official_package_contract() -> None: + project_root = Path(__file__).parents[1] + with (project_root / "pyproject.toml").open("rb") as metadata_file: + pyproject = tomllib.load(metadata_file) + + project = pyproject["project"] + assert project["requires-python"] == ">=3.10" + assert project["license"] == "MIT" + assert project["readme"] == "README.md" + assert project["dependencies"] == [ + "jsonschema>=4.20,<5", + "mcp>=2.1.1,<3", + "pydantic>=2.12,<3", + ] + assert project["urls"] == { + "Homepage": "https://agentcommunity.org", + "Documentation": "https://agentcommunity.org/mcp/docs", + "Source": "https://github.com/agentcommunity/agentcommunity-python", + "Issues": "https://github.com/agentcommunity/agentcommunity-python/issues", + } + assert "Typing :: Typed" in project["classifiers"] + assert "scripts" not in project + + package_root = project_root / "src" / "agentcommunity" + assert (package_root / "py.typed").is_file() + assert importlib.metadata.version("agentcommunity") == agentcommunity.__version__ diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py new file mode 100644 index 0000000..8aba72f --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fakes import FakeMCPClient, RecordingClientFactory +from mcp.types import CallToolResult + + +def test_readme_main_example_runs_against_an_offline_transport( + capsys: pytest.CaptureFixture[str], +) -> None: + readme = (Path(__file__).parents[1] / "README.md").read_text() + match = re.search( + r"\s*```python\n(?P.*?)\n```\s*" + r"", + readme, + flags=re.DOTALL, + ) + assert match is not None + + factory = RecordingClientFactory() + client = factory.clients + code = match.group("code").replace( + "AgentCommunityClient()", + "AgentCommunityClient(_client_factory=readme_test_factory)", + 1, + ) + + original_call = factory + + def configured_factory(endpoint: str, timeout: float) -> FakeMCPClient: + fake = original_call(endpoint, timeout) + fake.results.extend( + [ + CallToolResult( + content=[], + structured_content={ + "member_count": 29_700, + "note": "Verified members", + }, + ), + CallToolResult( + content=[], + structured_content={ + "status": "member", + "matches": [ + { + "display_name": "Example Agent", + "member_since": "2026-08-27", + "profile_url": ( + "https://agentcommunity.org/m/example-agent" + ), + } + ], + }, + ), + CallToolResult( + content=[], + structured_content={ + "certificate_id": "MESA-DD6-660J", + "status": "issued", + "valid_format": True, + "issued": True, + "agent_name": "Example Agent", + "certificate_url": ( + "https://agentcommunity.org/certificates/MESA-DD6-660J" + ), + }, + ), + ] + ) + return fake + + namespace = { + "__name__": "__main__", + "readme_test_factory": configured_factory, + } + exec(compile(code, "README.md", "exec"), namespace) + + assert len(client) == 1 + assert client[0].calls == [ + ("get_community_stats", {}), + ("lookup_member", {"query": "Example Agent"}), + ("verify_certificate", {"certificate_id": "MESA-DD6-660J"}), + ] + assert "29700" in capsys.readouterr().out From d6dda6f2f56efa1e55723cc378289a47f6a7066c Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 02:55:50 +0700 Subject: [PATCH 18/21] fix: harden release artifact verification --- scripts/verify-artifacts.sh | 114 ++++++++++++++++++++++++++++++++-- tests/test_build_artifacts.py | 72 ++++++++++++++++++--- 2 files changed, 171 insertions(+), 15 deletions(-) diff --git a/scripts/verify-artifacts.sh b/scripts/verify-artifacts.sh index 0761040..861b6d7 100755 --- a/scripts/verify-artifacts.sh +++ b/scripts/verify-artifacts.sh @@ -67,8 +67,14 @@ import re import sys import tarfile import zipfile +from email.parser import BytesParser from pathlib import PurePosixPath +from packaging.requirements import Requirement + +if sys.flags.optimize: + raise SystemExit("PYTHONOPTIMIZE must be disabled for artifact verification") + wheel_path, sdist_path = sys.argv[1:] expected_package_files = { "agentcommunity/__init__.py", @@ -103,13 +109,44 @@ def validate_contents(archive_path: str, name: str, data: bytes) -> None: ) +def validate_runtime_requirements(archive_path: str, metadata_bytes: bytes) -> None: + metadata = BytesParser().parsebytes(metadata_bytes) + requirements = { + Requirement(value) + for value in metadata.get_all("Requires-Dist", []) + if "extra" not in str(Requirement(value).marker) + } + expected = { + Requirement("jsonschema>=4.20,<5"), + Requirement("mcp>=2.1.1,<3"), + Requirement("pydantic>=2.12,<3"), + } + if requirements != expected: + raise SystemExit( + "mandatory Requires-Dist mismatch in " + f"{archive_path}: got {sorted(map(str, requirements))}" + ) + + with zipfile.ZipFile(wheel_path) as archive: + wheel_metadata = [ + name for name in archive.namelist() if name.endswith(".dist-info/METADATA") + ] + assert len(wheel_metadata) == 1 + validate_runtime_requirements(wheel_path, archive.read(wheel_metadata[0])) for name in archive.namelist(): validate_name(wheel_path, name) if not name.endswith("/"): validate_contents(wheel_path, name, archive.read(name)) with tarfile.open(sdist_path, mode="r:gz") as archive: + sdist_metadata = [ + member for member in archive.getmembers() if member.name.endswith("/PKG-INFO") + ] + assert len(sdist_metadata) == 1 + extracted_metadata = archive.extractfile(sdist_metadata[0]) + assert extracted_metadata is not None + validate_runtime_requirements(sdist_path, extracted_metadata.read()) for member in archive.getmembers(): name = member.name validate_name(sdist_path, name) @@ -142,9 +179,17 @@ verify_install() { "$environment/bin/python" - <<'PY' from importlib.metadata import distribution, version from importlib.resources import files +import asyncio +import sys +from types import TracebackType +from typing import Any import agentcommunity from agentcommunity import AgentCommunityClient, CommunityStats +from mcp.types import CallToolResult + +if sys.flags.optimize: + raise SystemExit("PYTHONOPTIMIZE must be disabled for artifact verification") expected = [ "__version__", @@ -178,11 +223,70 @@ assert set(metadata.get_all("Project-URL") or ()) == { "Issues, https://github.com/agentcommunity/agentcommunity-python/issues", } -client = AgentCommunityClient(endpoint="http://127.0.0.1:9/mcp", timeout=1.0) -assert client.endpoint == "http://127.0.0.1:9/mcp" -assert client.timeout == 1.0 -stats = CommunityStats(member_count=0, note="offline artifact check") -assert stats.member_count == 0 + +class OfflineMCPClient: + def __init__(self) -> None: + self.enter_count = 0 + self.exit_count = 0 + self.calls: list[tuple[str, dict[str, Any]]] = [] + + async def __aenter__(self) -> "OfflineMCPClient": + self.enter_count += 1 + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_value: BaseException | None, + traceback: TracebackType | None, + ) -> None: + del exc_type, exc_value, traceback + self.exit_count += 1 + + async def call_tool( + self, + name: str, + arguments: dict[str, Any] | None = None, + *, + read_timeout_seconds: float | None = None, + ) -> CallToolResult: + assert read_timeout_seconds == 1.0 + self.calls.append((name, arguments or {})) + return CallToolResult( + content=[], + structured_content={ + "member_count": 0, + "note": "offline artifact check", + }, + ) + + +offline_client = OfflineMCPClient() + + +def offline_factory(endpoint: str, timeout: float) -> OfflineMCPClient: + assert endpoint == "http://127.0.0.1:9/mcp" + assert timeout == 1.0 + return offline_client + + +async def verify_offline_behavior() -> None: + client = AgentCommunityClient( + endpoint="http://127.0.0.1:9/mcp", + timeout=1.0, + _client_factory=offline_factory, + ) + async with client: + stats = await client.community_stats() + assert isinstance(stats, CommunityStats) + assert stats.member_count == 0 + assert stats.note == "offline artifact check" + + +asyncio.run(verify_offline_behavior()) +assert offline_client.enter_count == 1 +assert offline_client.exit_count == 1 +assert offline_client.calls == [("get_community_stats", {})] PY ) } diff --git a/tests/test_build_artifacts.py b/tests/test_build_artifacts.py index e362590..6a33836 100644 --- a/tests/test_build_artifacts.py +++ b/tests/test_build_artifacts.py @@ -1,25 +1,29 @@ +import os import re +import shutil import subprocess import sys import tarfile +import zipfile from pathlib import Path, PurePosixPath +import pytest -def test_sdist_excludes_local_worktree_metadata(tmp_path: Path) -> None: + +@pytest.fixture(scope="module") +def built_artifacts(tmp_path_factory: pytest.TempPathFactory) -> Path: + artifact_dir = tmp_path_factory.mktemp("artifacts") subprocess.run( - [ - sys.executable, - "-m", - "build", - "--sdist", - "--outdir", - str(tmp_path), - ], + [sys.executable, "-m", "build", "--outdir", str(artifact_dir)], check=True, capture_output=True, text=True, ) - (sdist_path,) = tmp_path.glob("*.tar.gz") + return artifact_dir + + +def test_sdist_excludes_local_worktree_metadata(built_artifacts: Path) -> None: + (sdist_path,) = built_artifacts.glob("*.tar.gz") with tarfile.open(sdist_path, mode="r:gz") as archive: members = archive.getmembers() @@ -38,3 +42,51 @@ def test_sdist_excludes_local_worktree_metadata(tmp_path: Path) -> None: assert extracted is not None contents = extracted.read() assert re.search(rb"gitdir:\s*(?:/|[A-Za-z]:[\\/])", contents) is None + + +def test_verifier_fails_closed_when_python_optimization_is_enabled( + built_artifacts: Path, +) -> None: + result = subprocess.run( + ["bash", "scripts/verify-artifacts.sh", str(built_artifacts)], + check=False, + capture_output=True, + text=True, + env={**os.environ, "PYTHONOPTIMIZE": "1"}, + timeout=30, + ) + + assert result.returncode != 0 + assert "PYTHONOPTIMIZE" in result.stderr + + +def test_verifier_rejects_an_extra_mandatory_runtime_dependency( + built_artifacts: Path, tmp_path: Path +) -> None: + mutated_dir = tmp_path / "mutated" + shutil.copytree(built_artifacts, mutated_dir) + (wheel_path,) = mutated_dir.glob("*.whl") + rewritten_path = tmp_path / "rewritten.whl" + + with ( + zipfile.ZipFile(wheel_path, mode="r") as source, + zipfile.ZipFile(rewritten_path, mode="w") as destination, + ): + for member in source.infolist(): + data = source.read(member.filename) + if member.filename.endswith(".dist-info/METADATA"): + header, body = data.split(b"\n\n", maxsplit=1) + data = header + b"\nRequires-Dist: typing-extensions>=4\n\n" + body + destination.writestr(member, data) + rewritten_path.replace(wheel_path) + + result = subprocess.run( + ["bash", "scripts/verify-artifacts.sh", str(mutated_dir)], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode != 0 + assert "mandatory Requires-Dist mismatch" in result.stderr From ae10fcc1323fb93464a392cf8cac0e4ee383fadf Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 03:14:15 +0700 Subject: [PATCH 19/21] fix: harden SDK release supply chain --- .github/workflows/ci.yml | 13 +- .github/workflows/production-smoke.yml | 4 +- .github/workflows/publish.yml | 21 +-- CONTRIBUTING.md | 9 +- README.md | 2 +- .../2026-08-27-agentcommunity-python-sdk.md | 11 +- ...-08-27-agentcommunity-python-sdk-design.md | 8 ++ pyproject.toml | 21 ++- scripts/check-release-tag.py | 32 +++++ scripts/hatch-sdist.py | 13 ++ scripts/prepare-wheelhouse.sh | 40 ++++++ scripts/verify-artifacts.sh | 134 ++++++++++++++++-- tests/test_build_artifacts.py | 101 ++++++++++++- tests/test_public_api.py | 2 +- tests/test_readme_examples.py | 23 ++- tests/test_release_tag.py | 29 ++++ tests/test_workflows.py | 26 ++++ 17 files changed, 435 insertions(+), 54 deletions(-) create mode 100644 scripts/check-release-tag.py create mode 100644 scripts/hatch-sdist.py create mode 100755 scripts/prepare-wheelhouse.sh create mode 100644 tests/test_release_tag.py create mode 100644 tests/test_workflows.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f7769fe..a83171b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,8 +20,8 @@ jobs: - python: "3.14" dependencies: newest-allowed steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.python }} cache: pip @@ -55,16 +55,17 @@ jobs: name: Build and verify distributions runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: pip - run: python -m pip install --upgrade pip 'build>=1.3,<2' 'twine>=6.1,<7' - run: python -m build - run: python -m twine check dist/* - - run: bash scripts/verify-artifacts.sh dist - - uses: actions/upload-artifact@v4 + - run: bash scripts/prepare-wheelhouse.sh wheelhouse + - run: bash scripts/verify-artifacts.sh dist wheelhouse + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: distributions-${{ github.sha }} path: dist/ diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml index b74ffc4..58049af 100644 --- a/.github/workflows/production-smoke.yml +++ b/.github/workflows/production-smoke.yml @@ -13,8 +13,8 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: pip diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f2f18fe..3139ea2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,16 +12,18 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: pip + - name: Require tag to match package version + run: python scripts/check-release-tag.py "$GITHUB_REF_NAME" pyproject.toml - run: python -m pip install --upgrade pip 'build>=1.3,<2' - run: python -m build - name: Upload immutable release distributions id: distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: release-distributions-${{ github.sha }} path: dist/ @@ -34,17 +36,18 @@ jobs: needs: build runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.14" cache: pip - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-distributions-${{ github.sha }} path: dist - run: python -m pip install --upgrade pip 'twine>=6.1,<7' - - run: bash scripts/verify-artifacts.sh dist + - run: bash scripts/prepare-wheelhouse.sh wheelhouse + - run: bash scripts/verify-artifacts.sh dist wheelhouse publish: needs: verify @@ -54,12 +57,12 @@ jobs: contents: read id-token: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: release-distributions-${{ github.sha }} path: dist - name: Publish verified distributions with Trusted Publishing - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 with: packages-dir: dist/ attestations: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0013a6f..d6c10ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,9 +18,16 @@ Run the full local gate before submitting a change: rm -rf dist .venv/bin/python -m build .venv/bin/python -m twine check dist/* -bash scripts/verify-artifacts.sh dist +wheelhouse_dir="$(mktemp -d "${TMPDIR:-/tmp}/agentcommunity-wheelhouse.XXXXXX")" +trap 'rm -rf -- "$wheelhouse_dir"' EXIT +bash scripts/prepare-wheelhouse.sh "$wheelhouse_dir" +bash scripts/verify-artifacts.sh dist "$wheelhouse_dir" ``` +Wheelhouse preparation is the only artifact-check step allowed to contact a +package index. The verifier installs exclusively from that explicit directory +with pip's `--no-index` mode and fails if the wheelhouse is incomplete. + The default test suite is entirely offline and excludes tests marked `production`. Do not run the production smoke test as part of ordinary local or pull-request verification. Keep changes focused, add a failing test before a diff --git a/README.md b/README.md index a918232..1e173b4 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ bounded to compatible major versions: `jsonschema>=4.20,<5`, `mcp>=2.1.1,<3`, and `pydantic>=2.12,<3`. Only the latest 0.1 patch release receives security and compatibility fixes while the project is in its initial alpha series. -See the [MCP documentation](https://agentcommunity.org/mcp/docs), +See the [MCP documentation](https://agentcommunity.org/docs/mcp-server), [project source](https://github.com/agentcommunity/agentcommunity-python), [issue tracker](https://github.com/agentcommunity/agentcommunity-python/issues), and [security policy](SECURITY.md). diff --git a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md index 51a35a6..5d45889 100644 --- a/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md +++ b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md @@ -48,7 +48,7 @@ Expected: FAIL because package/test dependencies and the fixture do not exist ye Create a PEP 621 `pyproject.toml` using Hatchling with: - Distribution `agentcommunity`, version `0.1.0`, Python `>=3.10`, MIT metadata. -- Homepage `https://agentcommunity.org`, documentation `https://agentcommunity.org/mcp/docs`, source `https://github.com/agentcommunity/agentcommunity-python`, issues URL, and typed-package classifier. +- Homepage `https://agentcommunity.org`, documentation `https://agentcommunity.org/docs/mcp-server`, source `https://github.com/agentcommunity/agentcommunity-python`, issues URL, and typed-package classifier. - Direct runtime dependencies exactly bounded as `jsonschema>=4.20,<5`, `mcp>=2.1.1,<3`, and `pydantic>=2.12,<3`. `jsonschema` is direct because runtime protocol-error classification imports `jsonschema.exceptions.ValidationError`; do not rely on MCP to provide it transitively. - Dev dependency groups sufficient for pytest, AnyIO, Ruff, mypy, build, and Twine. - Ruff target Python 3.10 and a focused rule set; mypy strict mode for `src` and `tests`; pytest test path/configuration. @@ -386,14 +386,16 @@ Do not claim the package is already available on PyPI or that the website alread ### Step 4: Implement and exercise artifact verification -Create `scripts/verify-artifacts.sh` as a strict, portable Bash script that accepts an artifact directory, creates temporary isolated virtual environments, then: +Create `scripts/verify-artifacts.sh` as a strict, portable Bash script that accepts an artifact directory and an explicitly prepared wheelhouse, creates temporary isolated virtual environments, then: - Runs `twine check` on wheel and source distribution. - Installs the wheel without the source tree on `PYTHONPATH`, imports the exact public surface, checks version, and runs a minimal deterministic fake/local example. - Installs the source distribution in another clean environment and performs the same import/public-surface check. - Always cleans temporary directories. -Do not publish or contact package registries. +Do not publish or contact package registries. `scripts/prepare-wheelhouse.sh` is +the separate, pre-verification step that may resolve dependencies; the verifier +must install with `--no-index` from that wheelhouse only. ### Step 5: Run the full local release gate @@ -406,7 +408,8 @@ python -m mypy src tests python -m pytest -q python -m build python -m twine check dist/* -bash scripts/verify-artifacts.sh dist +bash scripts/prepare-wheelhouse.sh /tmp/agentcommunity-wheelhouse +bash scripts/verify-artifacts.sh dist /tmp/agentcommunity-wheelhouse ``` Also inspect wheel and source distribution contents and verify no secrets, caches, tests-only helpers, or unexpected files ship. The pinned contract fixture may remain test-only and should not ship in the wheel. diff --git a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md index 453df11..9173dd4 100644 --- a/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md +++ b/docs/superpowers/specs/2026-08-27-agentcommunity-python-sdk-design.md @@ -217,6 +217,11 @@ Continuous integration runs: - Installation and tests from the source distribution. - A checked README example smoke test. +Artifact verification receives a separately prepared wheelhouse and performs +all clean installs with `--no-index`. Registry resolution belongs only to the +wheelhouse-preparation step; verification fails closed when that input is +missing or incomplete. + End-to-end tests should avoid dependence on developer credentials or mutable registry state. ## Documentation @@ -242,6 +247,9 @@ Releases use PyPI Trusted Publishing with provenance; no long-lived PyPI token i 2. Tests those exact artifacts in clean environments. 3. Publishes the unchanged artifacts after the configured trusted-publisher gate. +Before building, the release workflow requires the Git tag to equal `v` plus +the exact `project.version` from `pyproject.toml`. + Creating the remote repository, pushing commits, configuring PyPI, and publishing are external state changes and require explicit user authorization. Local implementation can be completed and committed before those gates. After the package is publicly installed and verified, a separate Agent Community website change may add it to the official package registry and discovery outputs. The website must not advertise an unpublished package. The website change is outside this repository's version 0.1 implementation scope. diff --git a/pyproject.toml b/pyproject.toml index b10c85d..17f9b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ [project.urls] Homepage = "https://agentcommunity.org" -Documentation = "https://agentcommunity.org/mcp/docs" +Documentation = "https://agentcommunity.org/docs/mcp-server" Source = "https://github.com/agentcommunity/agentcommunity-python" Issues = "https://github.com/agentcommunity/agentcommunity-python/issues" @@ -39,6 +39,7 @@ dev = [ "mypy>=1.17,<2", "pytest>=8.4,<9", "ruff>=0.12,<1", + "tomli>=2,<3; python_version < '3.11'", "twine>=6.1,<7", ] @@ -46,14 +47,20 @@ dev = [ packages = ["src/agentcommunity"] [tool.hatch.build.targets.sdist] -exclude = [ - "/.github", - "/docs", - "/scripts", - "/tests", - "/uv.lock", +ignore-vcs = true +only-include = [ + "CHANGELOG.md", + "CONTRIBUTING.md", + "LICENSE", + "README.md", + "SECURITY.md", + "pyproject.toml", + "src/agentcommunity", ] +[tool.hatch.build.targets.sdist.hooks.custom] +path = "scripts/hatch-sdist.py" + [tool.pytest.ini_options] addopts = "-ra -m 'not production'" testpaths = ["tests"] diff --git a/scripts/check-release-tag.py b/scripts/check-release-tag.py new file mode 100644 index 0000000..5806510 --- /dev/null +++ b/scripts/check-release-tag.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +try: + import tomllib # type: ignore[import-untyped] +except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10 + import tomli as tomllib # type: ignore[import-not-found] + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Require a release tag to match project.version exactly." + ) + parser.add_argument("tag") + parser.add_argument("pyproject", type=Path) + args = parser.parse_args() + + with args.pyproject.open("rb") as pyproject_file: + project = tomllib.load(pyproject_file)["project"] + expected = f"v{project['version']}" + if args.tag != expected: + parser.exit( + 1, + f"release tag mismatch: expected {expected!r}, got {args.tag!r}\n", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/hatch-sdist.py b/scripts/hatch-sdist.py new file mode 100644 index 0000000..ac7830c --- /dev/null +++ b/scripts/hatch-sdist.py @@ -0,0 +1,13 @@ +from pathlib import Path +from typing import Any + +from hatchling.builders.hooks.plugin.interface import ( # type: ignore[import-not-found] + BuildHookInterface, +) + + +class CustomBuildHook(BuildHookInterface): # type: ignore[misc] + def initialize(self, version: str, build_data: dict[str, Any]) -> None: + del version + gitignore = str(Path(self.root) / ".gitignore") + build_data["force_include"].pop(gitignore, None) diff --git a/scripts/prepare-wheelhouse.sh b/scripts/prepare-wheelhouse.sh new file mode 100755 index 0000000..54e05ff --- /dev/null +++ b/scripts/prepare-wheelhouse.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 1 ]]; then + echo "usage: $0 WHEELHOUSE_DIRECTORY" >&2 + exit 64 +fi + +mkdir -p -- "$1" +wheelhouse_dir="$(cd "$1" && pwd -P)" +if [[ "$wheelhouse_dir" != /* || "$wheelhouse_dir" == "/" ]]; then + echo "could not resolve a safe wheelhouse directory" >&2 + exit 66 +fi +if [[ -n "$(find "$wheelhouse_dir" -mindepth 1 -maxdepth 1 -print -quit)" ]]; then + echo "wheelhouse directory must be empty: $wheelhouse_dir" >&2 + exit 65 +fi + +python_bin="" +for candidate in "${PYTHON:-}" python "$PWD/.venv/bin/python" python3; do + if [[ -n "$candidate" ]] && command -v "$candidate" >/dev/null 2>&1; then + python_bin="$candidate" + break + fi +done +if [[ -z "$python_bin" ]]; then + echo "no Python interpreter was found" >&2 + exit 69 +fi + +PIP_DISABLE_PIP_VERSION_CHECK=1 "$python_bin" -m pip download \ + --only-binary=:all: \ + --dest "$wheelhouse_dir" \ + 'hatchling==1.30.1' \ + 'jsonschema>=4.20,<5' \ + 'mcp>=2.1.1,<3' \ + 'pydantic>=2.12,<3' + +echo "prepared wheelhouse: $wheelhouse_dir" diff --git a/scripts/verify-artifacts.sh b/scripts/verify-artifacts.sh index 861b6d7..454afd3 100755 --- a/scripts/verify-artifacts.sh +++ b/scripts/verify-artifacts.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash set -euo pipefail -if [[ $# -ne 1 ]]; then - echo "usage: $0 ARTIFACT_DIRECTORY" >&2 +if [[ $# -ne 2 ]]; then + echo "usage: $0 ARTIFACT_DIRECTORY WHEELHOUSE_DIRECTORY" >&2 exit 64 fi @@ -17,6 +17,16 @@ if [[ "$artifact_dir" != /* || "$artifact_dir" == "/" ]]; then exit 66 fi +if [[ ! -d "$2" ]]; then + echo "wheelhouse directory does not exist: $2" >&2 + exit 66 +fi +wheelhouse_dir="$(cd "$2" && pwd -P)" +if [[ "$wheelhouse_dir" != /* || "$wheelhouse_dir" == "/" ]]; then + echo "could not resolve a safe wheelhouse directory" >&2 + exit 66 +fi + shopt -s nullglob wheels=("$artifact_dir"/*.whl) sdists=("$artifact_dir"/*.tar.gz) @@ -134,26 +144,68 @@ with zipfile.ZipFile(wheel_path) as archive: ] assert len(wheel_metadata) == 1 validate_runtime_requirements(wheel_path, archive.read(wheel_metadata[0])) + wheel_package_contents = { + name: archive.read(name) + for name in archive.namelist() + if name.startswith("agentcommunity/") + } for name in archive.namelist(): validate_name(wheel_path, name) if not name.endswith("/"): validate_contents(wheel_path, name, archive.read(name)) with tarfile.open(sdist_path, mode="r:gz") as archive: + members = archive.getmembers() + roots = {PurePosixPath(member.name).parts[0] for member in members} + assert len(roots) == 1 + (sdist_root,) = roots + expected_sdist_files = { + f"{sdist_root}/CHANGELOG.md", + f"{sdist_root}/CONTRIBUTING.md", + f"{sdist_root}/LICENSE", + f"{sdist_root}/PKG-INFO", + f"{sdist_root}/README.md", + f"{sdist_root}/SECURITY.md", + f"{sdist_root}/pyproject.toml", + *{ + f"{sdist_root}/src/{package_name}" + for package_name in expected_package_files + }, + } + actual_sdist_files = {member.name for member in members if member.isfile()} + if actual_sdist_files != expected_sdist_files: + raise SystemExit( + "unexpected sdist contents: " + f"{sorted(actual_sdist_files ^ expected_sdist_files)}" + ) + assert all(member.isfile() for member in members) sdist_metadata = [ - member for member in archive.getmembers() if member.name.endswith("/PKG-INFO") + member for member in members if member.name.endswith("/PKG-INFO") ] assert len(sdist_metadata) == 1 extracted_metadata = archive.extractfile(sdist_metadata[0]) assert extracted_metadata is not None validate_runtime_requirements(sdist_path, extracted_metadata.read()) - for member in archive.getmembers(): + sdist_package_contents = {} + source_prefix = f"{sdist_root}/src/" + for member in members: name = member.name validate_name(sdist_path, name) if member.isfile(): extracted = archive.extractfile(member) assert extracted is not None - validate_contents(sdist_path, name, extracted.read()) + data = extracted.read() + validate_contents(sdist_path, name, data) + if name.startswith(source_prefix): + sdist_package_contents[name.removeprefix(source_prefix)] = data + +if wheel_package_contents != sdist_package_contents: + differing = sorted( + name + for name in expected_package_files + if wheel_package_contents.get(name) != sdist_package_contents.get(name) + ) + raise SystemExit(f"wheel/sdist source mismatch: {differing}") with zipfile.ZipFile(wheel_path) as archive: wheel_names = set(archive.namelist()) @@ -172,8 +224,34 @@ PY verify_install() { local artifact="$1" local environment="$2" + local artifact_kind="$3" "$python_bin" -m venv "$environment" - "$environment/bin/python" -m pip install --disable-pip-version-check "$artifact" + if [[ "$artifact_kind" == "sdist" ]]; then + if ! PIP_NO_INDEX=1 "$environment/bin/python" -m pip install \ + --disable-pip-version-check \ + --no-index \ + --find-links "$wheelhouse_dir" \ + 'hatchling==1.30.1'; then + echo "wheelhouse is incomplete for the Hatchling build backend" >&2 + return 67 + fi + if ! PIP_NO_INDEX=1 "$environment/bin/python" -m pip install \ + --disable-pip-version-check \ + --no-build-isolation \ + --no-index \ + --find-links "$wheelhouse_dir" \ + "$artifact"; then + echo "wheelhouse is incomplete for the sdist installation" >&2 + return 67 + fi + elif ! PIP_NO_INDEX=1 "$environment/bin/python" -m pip install \ + --disable-pip-version-check \ + --no-index \ + --find-links "$wheelhouse_dir" \ + "$artifact"; then + echo "wheelhouse is incomplete for the wheel installation" >&2 + return 67 + fi ( cd "$temp_dir" "$environment/bin/python" - <<'PY' @@ -185,7 +263,12 @@ from types import TracebackType from typing import Any import agentcommunity -from agentcommunity import AgentCommunityClient, CommunityStats +from agentcommunity import ( + AgentCommunityClient, + CertificateVerification, + CommunityStats, + MemberLookup, +) from mcp.types import CallToolResult if sys.flags.optimize: @@ -218,7 +301,7 @@ assert set(metadata.get_all("Requires-Dist") or ()) >= { } assert set(metadata.get_all("Project-URL") or ()) == { "Homepage, https://agentcommunity.org", - "Documentation, https://agentcommunity.org/mcp/docs", + "Documentation, https://agentcommunity.org/docs/mcp-server", "Source, https://github.com/agentcommunity/agentcommunity-python", "Issues, https://github.com/agentcommunity/agentcommunity-python/issues", } @@ -252,13 +335,22 @@ class OfflineMCPClient: ) -> CallToolResult: assert read_timeout_seconds == 1.0 self.calls.append((name, arguments or {})) - return CallToolResult( - content=[], - structured_content={ + results = { + "get_community_stats": { "member_count": 0, "note": "offline artifact check", }, - ) + "lookup_member": {"status": "not_found", "matches": []}, + "verify_certificate": { + "certificate_id": "invalid", + "status": "invalid_format", + "valid_format": False, + "issued": False, + "agent_name": None, + "certificate_url": None, + }, + } + return CallToolResult(content=[], structured_content=results[name]) offline_client = OfflineMCPClient() @@ -278,20 +370,32 @@ async def verify_offline_behavior() -> None: ) async with client: stats = await client.community_stats() + lookup = await client.lookup_member("Example Agent") + certificate = await client.verify_certificate("invalid") assert isinstance(stats, CommunityStats) assert stats.member_count == 0 assert stats.note == "offline artifact check" + assert isinstance(lookup, MemberLookup) + assert lookup.status == "not_found" + assert lookup.matches == () + assert isinstance(certificate, CertificateVerification) + assert certificate.status == "invalid_format" + assert certificate.valid_format is False asyncio.run(verify_offline_behavior()) assert offline_client.enter_count == 1 assert offline_client.exit_count == 1 -assert offline_client.calls == [("get_community_stats", {})] +assert offline_client.calls == [ + ("get_community_stats", {}), + ("lookup_member", {"query": "Example Agent"}), + ("verify_certificate", {"certificate_id": "invalid"}), +] PY ) } -verify_install "$wheel" "$temp_dir/wheel-venv" -verify_install "$sdist" "$temp_dir/sdist-venv" +verify_install "$wheel" "$temp_dir/wheel-venv" wheel +verify_install "$sdist" "$temp_dir/sdist-venv" sdist echo "verified wheel and sdist: $artifact_dir" diff --git a/tests/test_build_artifacts.py b/tests/test_build_artifacts.py index 6a33836..86c2000 100644 --- a/tests/test_build_artifacts.py +++ b/tests/test_build_artifacts.py @@ -27,6 +27,23 @@ def test_sdist_excludes_local_worktree_metadata(built_artifacts: Path) -> None: with tarfile.open(sdist_path, mode="r:gz") as archive: members = archive.getmembers() + root = PurePosixPath(members[0].name).parts[0] + expected_files = { + f"{root}/CHANGELOG.md", + f"{root}/CONTRIBUTING.md", + f"{root}/LICENSE", + f"{root}/PKG-INFO", + f"{root}/README.md", + f"{root}/SECURITY.md", + f"{root}/pyproject.toml", + f"{root}/src/agentcommunity/__init__.py", + f"{root}/src/agentcommunity/_transport.py", + f"{root}/src/agentcommunity/client.py", + f"{root}/src/agentcommunity/errors.py", + f"{root}/src/agentcommunity/models.py", + f"{root}/src/agentcommunity/py.typed", + } + assert {member.name for member in members if member.isfile()} == expected_files assert all(".git" not in PurePosixPath(member.name).parts for member in members) assert all( "uv.lock" not in PurePosixPath(member.name).parts for member in members @@ -45,10 +62,17 @@ def test_sdist_excludes_local_worktree_metadata(built_artifacts: Path) -> None: def test_verifier_fails_closed_when_python_optimization_is_enabled( - built_artifacts: Path, + built_artifacts: Path, tmp_path: Path ) -> None: + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() result = subprocess.run( - ["bash", "scripts/verify-artifacts.sh", str(built_artifacts)], + [ + "bash", + "scripts/verify-artifacts.sh", + str(built_artifacts), + str(wheelhouse), + ], check=False, capture_output=True, text=True, @@ -79,9 +103,16 @@ def test_verifier_rejects_an_extra_mandatory_runtime_dependency( data = header + b"\nRequires-Dist: typing-extensions>=4\n\n" + body destination.writestr(member, data) rewritten_path.replace(wheel_path) + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() result = subprocess.run( - ["bash", "scripts/verify-artifacts.sh", str(mutated_dir)], + [ + "bash", + "scripts/verify-artifacts.sh", + str(mutated_dir), + str(wheelhouse), + ], check=False, capture_output=True, text=True, @@ -90,3 +121,67 @@ def test_verifier_rejects_an_extra_mandatory_runtime_dependency( assert result.returncode != 0 assert "mandatory Requires-Dist mismatch" in result.stderr + + +def test_verifier_uses_only_an_explicit_wheelhouse_and_rejects_an_empty_one( + built_artifacts: Path, tmp_path: Path +) -> None: + wheelhouse = tmp_path / "empty-wheelhouse" + wheelhouse.mkdir() + + result = subprocess.run( + [ + "bash", + "scripts/verify-artifacts.sh", + str(built_artifacts), + str(wheelhouse), + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode != 0 + assert "wheelhouse is incomplete" in result.stderr + verifier = Path("scripts/verify-artifacts.sh").read_text() + assert "--no-index" in verifier + assert "--find-links" in verifier + + +def test_verifier_rejects_divergent_wheel_and_sdist_package_sources( + built_artifacts: Path, tmp_path: Path +) -> None: + mutated_dir = tmp_path / "divergent" + shutil.copytree(built_artifacts, mutated_dir) + (wheel_path,) = mutated_dir.glob("*.whl") + rewritten_path = tmp_path / "divergent.whl" + + with ( + zipfile.ZipFile(wheel_path, mode="r") as source, + zipfile.ZipFile(rewritten_path, mode="w") as destination, + ): + for member in source.infolist(): + data = source.read(member.filename) + if member.filename == "agentcommunity/py.typed": + data = b"divergent\n" + destination.writestr(member, data) + rewritten_path.replace(wheel_path) + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() + + result = subprocess.run( + [ + "bash", + "scripts/verify-artifacts.sh", + str(mutated_dir), + str(wheelhouse), + ], + check=False, + capture_output=True, + text=True, + timeout=30, + ) + + assert result.returncode != 0 + assert "wheel/sdist source mismatch" in result.stderr diff --git a/tests/test_public_api.py b/tests/test_public_api.py index aea3294..02cbfad 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -54,7 +54,7 @@ def test_project_metadata_matches_the_official_package_contract() -> None: ] assert project["urls"] == { "Homepage": "https://agentcommunity.org", - "Documentation": "https://agentcommunity.org/mcp/docs", + "Documentation": "https://agentcommunity.org/docs/mcp-server", "Source": "https://github.com/agentcommunity/agentcommunity-python", "Issues": "https://github.com/agentcommunity/agentcommunity-python/issues", } diff --git a/tests/test_readme_examples.py b/tests/test_readme_examples.py index 8aba72f..fe0f16f 100644 --- a/tests/test_readme_examples.py +++ b/tests/test_readme_examples.py @@ -8,6 +8,23 @@ from mcp.types import CallToolResult +def _inject_offline_factory(code: str) -> str: + constructor = "AgentCommunityClient()" + assert code.count(constructor) == 1, ( + "README main example must contain exactly one injectable default constructor" + ) + return code.replace( + constructor, + "AgentCommunityClient(_client_factory=readme_test_factory)", + 1, + ) + + +def test_readme_injection_fails_closed_when_constructor_target_drifts() -> None: + with pytest.raises(AssertionError, match="exactly one injectable"): + _inject_offline_factory("AgentCommunityClient(timeout=10.0)") + + def test_readme_main_example_runs_against_an_offline_transport( capsys: pytest.CaptureFixture[str], ) -> None: @@ -22,11 +39,7 @@ def test_readme_main_example_runs_against_an_offline_transport( factory = RecordingClientFactory() client = factory.clients - code = match.group("code").replace( - "AgentCommunityClient()", - "AgentCommunityClient(_client_factory=readme_test_factory)", - 1, - ) + code = _inject_offline_factory(match.group("code")) original_call = factory diff --git a/tests/test_release_tag.py b/tests/test_release_tag.py new file mode 100644 index 0000000..f981ed4 --- /dev/null +++ b/tests/test_release_tag.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import subprocess +import sys + +import pytest + + +def run_release_check(tag: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "scripts/check-release-tag.py", tag, "pyproject.toml"], + check=False, + capture_output=True, + text=True, + ) + + +def test_release_tag_must_exactly_match_project_version() -> None: + result = run_release_check("v0.1.0") + + assert result.returncode == 0 + + +@pytest.mark.parametrize("tag", ["vbanana", "v0.2.0", "v0.1.0-typo"]) +def test_release_tag_rejects_nonmatching_or_malformed_values(tag: str) -> None: + result = run_release_check(tag) + + assert result.returncode != 0 + assert "release tag mismatch" in result.stderr diff --git a/tests/test_workflows.py b/tests/test_workflows.py new file mode 100644 index 0000000..73fd819 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import re +from pathlib import Path + + +def test_every_third_party_action_is_pinned_to_a_full_commit_sha() -> None: + workflow_root = Path(".github/workflows") + uses_pattern = re.compile( + r"^\s*-?\s*uses:\s*([^\s#]+)(?:\s+#\s*(.+))?$", re.MULTILINE + ) + + found = 0 + for workflow in workflow_root.glob("*.yml"): + for reference, comment in uses_pattern.findall(workflow.read_text()): + if reference.startswith("./"): + continue + found += 1 + assert re.fullmatch(r"[^/@]+/[^/@]+@[0-9a-f]{40}", reference), ( + f"unpinned third-party action in {workflow}: {reference}" + ) + assert re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,2}", comment.strip()), ( + f"missing human release comment for {reference} in {workflow}" + ) + + assert found > 0 From 9e377a1138aa831b3336c8a21dba8b14899be259 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 03:19:25 +0700 Subject: [PATCH 20/21] fix: pin executable PyPI publish action --- .github/workflows/publish.yml | 2 +- tests/test_workflows.py | 37 +++++++++++++++++++++++++++++++---- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 3139ea2..10bb433 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -62,7 +62,7 @@ jobs: name: release-distributions-${{ github.sha }} path: dist - name: Publish verified distributions with Trusted Publishing - uses: pypa/gh-action-pypi-publish@a892a5a61159132606e93a2fa6f4358831b04d26 # v1.14.2 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: packages-dir: dist/ attestations: true diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 73fd819..7de5159 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -3,6 +3,25 @@ import re from pathlib import Path +# Audited against each official repository with ``git ls-remote``. Annotated +# tags use their peeled ``^{}`` commit, never the tag-object SHA. +AUDITED_ACTION_PINS = { + "actions/checkout": ("3d3c42e5aac5ba805825da76410c181273ba90b1", "v7.0.1"), + "actions/setup-python": ("5fda3b95a4ea91299a34e894583c3862153e4b97", "v7.0.0"), + "actions/upload-artifact": ( + "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a", + "v7.0.1", + ), + "actions/download-artifact": ( + "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c", + "v8.0.1", + ), + "pypa/gh-action-pypi-publish": ( + "dc37677b2e1c63e2034f94d8a5b11f265b73ba33", + "v1.14.2", + ), +} + def test_every_third_party_action_is_pinned_to_a_full_commit_sha() -> None: workflow_root = Path(".github/workflows") @@ -10,17 +29,27 @@ def test_every_third_party_action_is_pinned_to_a_full_commit_sha() -> None: r"^\s*-?\s*uses:\s*([^\s#]+)(?:\s+#\s*(.+))?$", re.MULTILINE ) - found = 0 + found_actions: set[str] = set() for workflow in workflow_root.glob("*.yml"): for reference, comment in uses_pattern.findall(workflow.read_text()): if reference.startswith("./"): continue - found += 1 - assert re.fullmatch(r"[^/@]+/[^/@]+@[0-9a-f]{40}", reference), ( + match = re.fullmatch(r"([^/@]+/[^/@]+)@([0-9a-f]{40})", reference) + assert match is not None, ( f"unpinned third-party action in {workflow}: {reference}" ) assert re.fullmatch(r"v?[0-9]+(?:\.[0-9]+){1,2}", comment.strip()), ( f"missing human release comment for {reference} in {workflow}" ) + action, commit_sha = match.groups() + found_actions.add(action) + assert action in AUDITED_ACTION_PINS, ( + f"unaudited third-party action in {workflow}: {action}" + ) + expected_sha, expected_tag = AUDITED_ACTION_PINS[action] + assert (commit_sha, comment.strip()) == (expected_sha, expected_tag), ( + f"pin for {action} in {workflow} does not match its audited " + f"{expected_tag} commit" + ) - assert found > 0 + assert found_actions == set(AUDITED_ACTION_PINS) From cf36ae15ecb415933f215f3cb9423a58e9b8c858 Mon Sep 17 00:00:00 2001 From: nembal Date: Thu, 27 Aug 2026 09:53:45 +0700 Subject: [PATCH 21/21] fix: support Python 3.10 test collection --- tests/fakes.py | 2 +- tests/test_public_api.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/fakes.py b/tests/fakes.py index 85c9514..190a9e1 100644 --- a/tests/fakes.py +++ b/tests/fakes.py @@ -59,7 +59,7 @@ async def call_tool( await asyncio.wait_for( self.call_gate.wait(), timeout=read_timeout_seconds ) - except TimeoutError as error: + except asyncio.TimeoutError as error: raise MCPError( REQUEST_TIMEOUT, f"Request {name!r} timed out" ) from error diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 02cbfad..4b4f95d 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -3,7 +3,10 @@ import importlib.metadata from pathlib import Path -import tomllib # type: ignore[import-untyped] +try: + import tomllib # type: ignore[import-not-found, import-untyped, unused-ignore] +except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10 + import tomli as tomllib # type: ignore[import-not-found, unused-ignore] import agentcommunity