diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..a83171b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + 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@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/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/ + 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..58049af --- /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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + 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..10bb433 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,68 @@ +name: Publish + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + steps: + - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.14" + cache: pip + - 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/prepare-wheelhouse.sh wheelhouse + - run: bash scripts/verify-artifacts.sh dist wheelhouse + + publish: + needs: verify + runs-on: ubuntu-latest + environment: pypi + permissions: + contents: read + id-token: write + steps: + - 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@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + 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..d6c10ee --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# 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/* +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 +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..1e173b4 --- /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/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/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/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..5d45889 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-agentcommunity-python-sdk.md @@ -0,0 +1,435 @@ +# 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, 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` + +--- + +## 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/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. +- 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` +- Create: `tests/test_mcp_lifecycle_integration.py` +- Modify: `pyproject.toml` + +### 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 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. + +### 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 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. +- 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 tests/test_mcp_lifecycle_integration.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. +- 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. + +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 tests/test_mcp_lifecycle_integration.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 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" +``` + +## 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: 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. +- 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. + +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 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. `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 + +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/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. + +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. 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..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 @@ -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 @@ -48,7 +51,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 +70,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. @@ -93,19 +98,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 +138,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 @@ -168,7 +192,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 @@ -193,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 @@ -218,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. @@ -234,4 +266,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. - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..17f9b61 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,83 @@ +[build-system] +requires = ["hatchling>=1.30.1,<1.31"] +build-backend = "hatchling.build" + +[project] +name = "agentcommunity" +version = "0.1.0" +description = "Typed Python client for the Agent Community MCP endpoint" +readme = "README.md" +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 = [ + "jsonschema>=4.20,<5", + "mcp>=2.1.1,<3", + "pydantic>=2.12,<3", +] + +[project.urls] +Homepage = "https://agentcommunity.org" +Documentation = "https://agentcommunity.org/docs/mcp-server" +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", + "tomli>=2,<3; python_version < '3.11'", + "twine>=6.1,<7", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/agentcommunity"] + +[tool.hatch.build.targets.sdist] +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"] +pythonpath = ["src"] +markers = [ + "production: tests that call the live Agent Community endpoint", +] + +[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/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 new file mode 100755 index 0000000..454afd3 --- /dev/null +++ b/scripts/verify-artifacts.sh @@ -0,0 +1,401 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -ne 2 ]]; then + echo "usage: $0 ARTIFACT_DIRECTORY WHEELHOUSE_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 + +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) +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 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", + "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}" + ) + + +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])) + 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 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()) + 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 + 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()) +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" + local artifact_kind="$3" + "$python_bin" -m venv "$environment" + 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' +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, + CertificateVerification, + CommunityStats, + MemberLookup, +) +from mcp.types import CallToolResult + +if sys.flags.optimize: + raise SystemExit("PYTHONOPTIMIZE must be disabled for artifact verification") + +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/docs/mcp-server", + "Source, https://github.com/agentcommunity/agentcommunity-python", + "Issues, https://github.com/agentcommunity/agentcommunity-python/issues", +} + + +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 {})) + 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() + + +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() + 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", {}), + ("lookup_member", {"query": "Example Agent"}), + ("verify_certificate", {"certificate_id": "invalid"}), +] +PY + ) +} + +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/src/agentcommunity/__init__.py b/src/agentcommunity/__init__.py new file mode 100644 index 0000000..a7c2de0 --- /dev/null +++ b/src/agentcommunity/__init__.py @@ -0,0 +1,28 @@ +from agentcommunity.client import AgentCommunityClient +from agentcommunity.errors import ( + AgentCommunityError, + AgentCommunityProtocolError, + AgentCommunityToolError, + AgentCommunityTransportError, +) +from agentcommunity.models import ( + CertificateVerification, + CommunityStats, + MemberLookup, + MemberMatch, +) + +__version__ = "0.1.0" + +__all__ = [ # noqa: RUF022 - public contract order is intentional + "__version__", + "AgentCommunityClient", + "CommunityStats", + "MemberMatch", + "MemberLookup", + "CertificateVerification", + "AgentCommunityError", + "AgentCommunityTransportError", + "AgentCommunityProtocolError", + "AgentCommunityToolError", +] diff --git a/src/agentcommunity/_transport.py b/src/agentcommunity/_transport.py new file mode 100644 index 0000000..766b770 --- /dev/null +++ b/src/agentcommunity/_transport.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +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 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 ( + 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, + *, + read_timeout_seconds: float | None = None, + ) -> CallToolResult: ... + + +_ClientFactory = Callable[[str, float], _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, 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: + 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 + 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 in { + _LifecycleState.CONNECTING, + _LifecycleState.OPEN, + _LifecycleState.CALLING, + _LifecycleState.CLOSING, + } + + async def connect(self) -> None: + 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 teardown failed and this instance cannot be reused" + ) + 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 + lifecycle = _client_lifecycle( + self._endpoint, self._timeout, self._client_factory + ) + self._lifecycle = lifecycle + try: + client = await lifecycle.__aenter__() + except TimeoutError as error: + 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 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" + ) from error + else: + self._client = client + self._state = _LifecycleState.OPEN + finally: + if self._state is _LifecycleState.CONNECTING: + self._state = previous_state + self._owner_task_id = None + self._lifecycle = None + + async def close( + self, + exc_type: type[BaseException] | None = None, + exc_value: BaseException | None = None, + traceback: TracebackType | None = None, + ) -> None: + 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") + 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" + ) + + lifecycle = self._lifecycle + if lifecycle is None: + raise AgentCommunityProtocolError("Client lifecycle state is inconsistent") + self._state = _LifecycleState.CLOSING + + try: + await lifecycle.__aexit__(exc_type, exc_value, traceback) + except Exception as error: + raise AgentCommunityTransportError( + "Failed to close the Agent Community MCP connection" + ) from error + else: + 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: + 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 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: + 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: + 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: + 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}" + ) 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 new file mode 100644 index 0000000..172b54f --- /dev/null +++ b/src/agentcommunity/client.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +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 +from pydantic import BaseModel, ValidationError + +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 +_MAX_TOOL_ERROR_TEXT = 500 +_HEX_DIGITS = frozenset("0123456789abcdefABCDEF") +_UNSAFE_UNICODE_CATEGORIES = frozenset({"Cc", "Cf", "Cs"}) + +_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: + 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 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, + 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 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 + ): + 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 _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") + 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: + 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: + if character.isspace() or category(character) in _UNSAFE_UNICODE_CATEGORIES: + 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 len(output) == _MAX_TOOL_ERROR_TEXT: + truncated = True + break + if truncated: + break + diagnostic = "".join(output) + if truncated: + return f"{diagnostic[: _MAX_TOOL_ERROR_TEXT - 3]}..." + return diagnostic 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..3a90a0c --- /dev/null +++ b/src/agentcommunity/models.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import datetime +import re +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): + 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 + + +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/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/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..190a9e1 --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from mcp import MCPError +from mcp.types import REQUEST_TIMEOUT, CallToolResult, TextContent + + +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.call_timeouts: list[float | None] = [] + 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] = [] + + 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_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, + *, + 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: + try: + await asyncio.wait_for( + self.call_gate.wait(), timeout=read_timeout_seconds + ) + except asyncio.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: + 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.timeouts: list[float] = [] + self.clients: list[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 + + +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/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 new file mode 100644 index 0000000..86c2000 --- /dev/null +++ b/tests/test_build_artifacts.py @@ -0,0 +1,187 @@ +import os +import re +import shutil +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path, PurePosixPath + +import pytest + + +@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", "--outdir", str(artifact_dir)], + check=True, + capture_output=True, + text=True, + ) + 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() + 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 + ) + assert all( + "tests" 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 + + +def test_verifier_fails_closed_when_python_optimization_is_enabled( + built_artifacts: Path, tmp_path: Path +) -> None: + wheelhouse = tmp_path / "wheelhouse" + wheelhouse.mkdir() + result = subprocess.run( + [ + "bash", + "scripts/verify-artifacts.sh", + str(built_artifacts), + str(wheelhouse), + ], + 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) + 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 "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_client_errors.py b/tests/test_client_errors.py new file mode 100644 index 0000000..4e3d002 --- /dev/null +++ b/tests/test_client_errors.py @@ -0,0 +1,251 @@ +from __future__ import annotations + +import asyncio + +import pytest +from fakes import ( + FakeMCPClient, + RecordingClientFactory, + error_result, + successful_result, +) +from mcp import MCPError +from mcp.types import CallToolResult, ImageContent, TextContent +from pydantic import ValidationError + +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + AgentCommunityToolError, + AgentCommunityTransportError, +) +from agentcommunity.client import _safe_tool_error_text +from agentcommunity.models import CommunityStats + + +@pytest.fixture +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") + 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_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: + 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 = ConnectionError("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_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() + 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__, MCPError) + + +@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_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_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() + 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..00d454c --- /dev/null +++ b/tests/test_client_lifecycle.py @@ -0,0 +1,365 @@ +from __future__ import annotations + +import asyncio +import math + +import pytest +from fakes import FakeMCPClient, RecordingClientFactory, successful_result + +from agentcommunity import ( + AgentCommunityClient, + AgentCommunityProtocolError, + AgentCommunityTransportError, +) +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", + "https://%zz/mcp", + "https://example.com/%zz", + "https://example.com/mcp?cursor=%0x", + ], +) +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.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() + 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.timeouts == [15.0] + 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", {})] + assert fake.call_timeouts == [15.0, 15.0] + + +@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_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() + 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_close_is_not_limited_by_request_timeout() -> None: + fake = FakeMCPClient() + close_gate = asyncio.Event() + fake.close_gate = close_gate + client = AgentCommunityClient( + timeout=0.01, _client_factory=RecordingClientFactory(lambda: fake) + ) + + async def release_close() -> None: + await asyncio.sleep(0.03) + close_gate.set() + + async with client: + release = asyncio.create_task(release_close()) + await release + await client.close() + + assert fake.exit_count == 1 + assert fake.successful_exit_count == 1 + + +@pytest.mark.anyio +async def test_close_failure_is_terminal_and_subsequent_close_is_noop() -> 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 + 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 == 1 + assert fake.successful_exit_count == 0 + + +@pytest.mark.anyio +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() + 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 client.close() + + task = asyncio.create_task(own_lifecycle()) + await entered.wait() + begin_close.set() + while fake.exit_count == 0: + await asyncio.sleep(0) + task.cancel() + await cancellation_seen.wait() + + await task + + assert fake.exit_count == 1 + assert fake.successful_exit_count == 0 + + +@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() + 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 + + +@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", {})] 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..a1d2660 --- /dev/null +++ b/tests/test_contract_compatibility.py @@ -0,0 +1,340 @@ +from __future__ import annotations + +import datetime +import inspect +from copy import deepcopy +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"]} + + +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], +) -> 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.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"), + [ + ( + "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": "Example Agent 2", + "member_since": "2026-08-27", + "profile_url": "https://agentcommunity.org/m/example-agent-2", + } + ], + }, + ], + ), + ( + "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_public_scalar_compatibility(tools) + + lookup_schema = tools["lookup_member"]["outputSchema"] + assert set(get_args(MemberLookup.model_fields["status"].annotation)) == set( + lookup_schema["properties"]["status"]["enum"] + ) + 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"]) + + certificate_schema = tools["verify_certificate"]["outputSchema"] + assert set( + get_args(CertificateVerification.model_fields["status"].annotation) + ) == set(certificate_schema["properties"]["status"]["enum"]) + 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"] + + +@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( + 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_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") diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..cfa6c4d --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,32 @@ +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__ == [ + "__version__", + "AgentCommunityClient", + "CommunityStats", + "MemberMatch", + "MemberLookup", + "CertificateVerification", + "AgentCommunityError", + "AgentCommunityTransportError", + "AgentCommunityProtocolError", + "AgentCommunityToolError", + ] + assert not hasattr(agentcommunity, "register_agent") diff --git a/tests/test_mcp_integration.py b/tests/test_mcp_integration.py new file mode 100644 index 0000000..20d7801 --- /dev/null +++ b/tests/test_mcp_integration.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +import anyio +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 +): + 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) + 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} + + connection_count = 0 + + def factory(endpoint: str, timeout: float) -> Client: + nonlocal connection_count + del endpoint + connection_count += 1 + return Client(server, read_timeout_seconds=timeout) + + 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 connection_count == 1 + assert cleanup_complete.is_set() diff --git a/tests/test_mcp_lifecycle_integration.py b/tests/test_mcp_lifecycle_integration.py new file mode 100644 index 0000000..342baee --- /dev/null +++ b/tests/test_mcp_lifecycle_integration.py @@ -0,0 +1,247 @@ +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) + + +@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) diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..9a38290 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,290 @@ +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( + "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"), + [ + ("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", + } + ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..4b4f95d --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import importlib.metadata +from pathlib import Path + +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 + +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/docs/mcp-server", + "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..fe0f16f --- /dev/null +++ b/tests/test_readme_examples.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from fakes import FakeMCPClient, RecordingClientFactory +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: + 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 = _inject_offline_factory(match.group("code")) + + 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 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..7de5159 --- /dev/null +++ b/tests/test_workflows.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +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") + uses_pattern = re.compile( + r"^\s*-?\s*uses:\s*([^\s#]+)(?:\s+#\s*(.+))?$", re.MULTILINE + ) + + 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 + 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_actions == set(AUDITED_ACTION_PINS)