Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
auto-formatting. `AGENTS.md`, `docs/agent-context/workflows.md`,
`docs/agent-context/review-checklist.md`, `CONTRIBUTING.md`, and the
`README.md` development section are updated to describe the new chain. (#88)
- `agent_kernel.__version__` is now derived from the installed distribution
metadata (`importlib.metadata.version("weaver-kernel")`) instead of a
hand-maintained literal, so it can no longer drift from `pyproject.toml`
(it previously reported `0.5.0` while the package shipped `0.7.0`/`0.8.0`).
Falls back to `0.0.0+local` in a source tree without dist metadata. (#85)
- The `mcp.shared.exceptions.McpError` optional import in `drivers/mcp.py`
no longer triggers a mypy `[no-redef]` error when the `mcp` extra is not
installed. The lazy import is resolved through a `_load_mcp_error()` helper
(mirroring `mcp_support.import_optional`), declaring the `_McpError` global
exactly once. (#87)

## [0.8.0] - 2026-05-22

Expand Down
11 changes: 10 additions & 1 deletion src/agent_kernel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@
)
"""

from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version

from .adapters import AnthropicMiddleware, OpenAIMiddleware
from .drivers.base import Driver, ExecutionContext
from .drivers.http import HTTPDriver
Expand Down Expand Up @@ -139,7 +142,13 @@
from .tokens import CapabilityToken, HMACTokenProvider
from .trace import TraceStore

__version__ = "0.5.0"
# Single source of truth: read the version from the installed distribution
# metadata (the PyPI dist name is ``weaver-kernel``, distinct from the import
# name ``agent_kernel``) so it never drifts from ``pyproject.toml``.
try:
__version__ = _pkg_version("weaver-kernel")
except PackageNotFoundError: # pragma: no cover - source tree without dist metadata
__version__ = "0.0.0+local"

__all__ = [
# version
Expand Down
39 changes: 30 additions & 9 deletions src/agent_kernel/drivers/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import importlib
from collections.abc import Awaitable, Callable
from typing import Any

Expand All @@ -19,15 +20,35 @@
normalize_call_result,
)

# Lazy import of McpError — only available when the mcp optional dep is installed.
# If mcp is absent, factory methods raise ImportError before any session is created,
# so _McpError will never be None on a live driver instance. The explicit annotation
# keeps mypy --strict happy across the try/except branches.
_McpError: type[BaseException] | None
try:
from mcp.shared.exceptions import McpError as _McpError
except ImportError: # pragma: no cover
_McpError = None

def _load_mcp_error() -> type[BaseException] | None:
"""Return the ``McpError`` type, or ``None`` when ``mcp`` is unavailable.

Imported via ``importlib`` (matching ``mcp_support.import_optional``) so the
optional-dependency branch is testable. Resolving the type into a separate,
explicitly annotated module global avoids the mypy ``[no-redef]`` that the
old ``import ... as _McpError`` pattern produced when ``mcp`` was absent and
the import resolved to ``Any`` under ``ignore_missing_imports``.
"""
try:
exceptions = importlib.import_module("mcp.shared.exceptions")
except ImportError:
return None
# Guard attribute access too: if the module imports but ``McpError`` is
# missing or renamed, degrade to ``None`` (matching the old
# ``from ... import McpError`` ImportError fallback) rather than raising
# AttributeError at import time and breaking the whole driver module.
error_type = getattr(exceptions, "McpError", None)
if isinstance(error_type, type) and issubclass(error_type, BaseException):
return error_type
return None


# Resolved once at import time and used in _run_with_retries to classify
# protocol-level rejections as non-retryable. None when the optional ``mcp``
# dependency is not installed (factory methods raise ImportError first, so this
# is never None on a live driver instance).
_McpError: type[BaseException] | None = _load_mcp_error()


def _infer_safety_class(spec: ToolSpec) -> SafetyClass:
Expand Down
34 changes: 34 additions & 0 deletions tests/test_mcp_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,40 @@ def test_from_http_missing_dependency_raises_helpful_import_error() -> None:
MCPDriver.from_http("http://localhost:8080/mcp")


def test_load_mcp_error_returns_type_when_mcp_available() -> None:
"""_load_mcp_error resolves the real McpError type when mcp is installed."""
from mcp.shared.exceptions import McpError

from agent_kernel.drivers.mcp import _load_mcp_error

assert _load_mcp_error() is McpError


def test_load_mcp_error_returns_none_when_mcp_unavailable() -> None:
"""_load_mcp_error returns None when the optional mcp dep is missing (issue #87)."""
from agent_kernel.drivers.mcp import _load_mcp_error

with patch("agent_kernel.drivers.mcp.importlib.import_module") as import_module:
import_module.side_effect = ImportError("No module named 'mcp'")
assert _load_mcp_error() is None


def test_load_mcp_error_returns_none_when_attribute_missing() -> None:
"""_load_mcp_error degrades to None if the module imports but McpError is gone.

Guards against an AttributeError at import time (and a broken driver module)
when a future/renamed mcp release no longer exposes ``McpError``.
"""
from types import ModuleType

from agent_kernel.drivers.mcp import _load_mcp_error

empty_module = ModuleType("mcp.shared.exceptions")
with patch("agent_kernel.drivers.mcp.importlib.import_module") as import_module:
import_module.return_value = empty_module
assert _load_mcp_error() is None


@pytest.mark.asyncio
async def test_discover_converts_tools_to_capabilities() -> None:
session = _FakeSession(
Expand Down
66 changes: 66 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Version-consistency tests.

Pins the contract that ``agent_kernel.__version__`` is derived from the
installed distribution metadata rather than a hand-maintained literal, so it
can never drift from ``pyproject.toml`` again (issue #85). The PyPI
distribution name is ``weaver-kernel``, distinct from the import name
``agent_kernel``.
"""

from __future__ import annotations

import re
from importlib.metadata import version as pkg_version
from pathlib import Path

import pytest

import agent_kernel

_PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml"


def _declared_pyproject_version() -> str | None:
"""Return ``[project].version`` from ``pyproject.toml``, or None if absent.

Parsed with a small regex rather than ``tomllib`` so the test runs
unchanged on Python 3.10 (where ``tomllib`` is unavailable) without adding
a ``tomli`` dependency. Returns None when the source tree — and thus
``pyproject.toml`` — is not present (e.g. a wheel-only install).
"""
if not _PYPROJECT.is_file():
return None
text = _PYPROJECT.read_text(encoding="utf-8")
# Scope to the [project] table: from its header up to the next table header.
section = re.search(r"(?ms)^\[project\]\s*$(.*?)(?=^\[)", text)
body = section.group(1) if section else text
match = re.search(r'(?m)^\s*version\s*=\s*"([^"]+)"', body)
return match.group(1) if match else None


def test_version_matches_distribution_metadata() -> None:
"""``__version__`` equals the installed ``weaver-kernel`` dist version.

Pins that ``__version__`` is *derived from* dist metadata (not a literal);
cross-checking against ``pyproject.toml`` is done separately below.
"""
assert agent_kernel.__version__ == pkg_version("weaver-kernel")


def test_version_matches_pyproject_declaration() -> None:
"""``__version__`` matches the version declared in ``pyproject.toml``.

Directly enforces issue #85's acceptance criterion ("matches
``pyproject.toml``'s ``[project].version``") rather than only re-deriving
from the same metadata source, so a release that bumps ``pyproject.toml``
without refreshing the installed/editable metadata is caught.
"""
declared = _declared_pyproject_version()
if declared is None:
pytest.skip("pyproject.toml not present (installed without source tree)")
assert agent_kernel.__version__ == declared


def test_version_is_exported() -> None:
"""``__version__`` stays part of the public API surface."""
assert "__version__" in agent_kernel.__all__
Loading