Skip to content

Commit edb3e4e

Browse files
committed
fix(adapters): wrap redacted MCP output in CallToolResult
When mcp_check_output returns redacted_data, the interceptor was returning a plain str. langchain-mcp-adapters expects MCPToolCallResult (CallToolResult | ToolMessage | Command) and calls .content on the return value, causing AttributeError at runtime. Now wraps the redacted string in a CallToolResult with a single TextContent block, which is the correct return type for the interceptor protocol. Also adds mcp>=1.0.0 as a new 'langgraph' optional extra and to dev dependencies, with a lazy import that surfaces a clear error message if mcp is not installed. Fixes #119
1 parent da0709b commit edb3e4e

10 files changed

Lines changed: 78 additions & 61 deletions

File tree

axonflow/adapters/langgraph.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@
3434

3535
import asyncio
3636
import json
37+
from collections.abc import Callable
3738
from dataclasses import dataclass, field
38-
from typing import TYPE_CHECKING, Any, Callable
39+
from typing import TYPE_CHECKING, Any
40+
41+
from mcp.types import CallToolResult, TextContent
3942

4043
from axonflow.exceptions import PolicyViolationError
4144
from axonflow.workflow import (
@@ -573,7 +576,9 @@ async def _interceptor(request: Any, handler: Callable[..., Any]) -> Any:
573576
output_check.block_reason or "Tool result blocked by policy"
574577
)
575578
if output_check.redacted_data is not None:
576-
return output_check.redacted_data
579+
return CallToolResult(
580+
content=[TextContent(type="text", text=output_check.redacted_data)]
581+
)
577582

578583
return result
579584

axonflow/interceptors/anthropic.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525
from __future__ import annotations
2626

2727
import asyncio
28+
from collections.abc import Callable
2829
from functools import wraps
29-
from typing import TYPE_CHECKING, Any, Callable, TypeVar
30+
from typing import TYPE_CHECKING, Any, TypeVar
3031

3132
from axonflow.exceptions import PolicyViolationError
3233
from axonflow.interceptors.base import BaseInterceptor

axonflow/interceptors/bedrock.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,9 @@
2828

2929
import asyncio
3030
import json
31+
from collections.abc import Callable
3132
from functools import wraps
32-
from typing import TYPE_CHECKING, Any, Callable, TypeVar
33+
from typing import TYPE_CHECKING, Any, TypeVar
3334

3435
from axonflow.exceptions import PolicyViolationError
3536
from axonflow.interceptors.base import BaseInterceptor

axonflow/interceptors/gemini.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,9 @@
2222
from __future__ import annotations
2323

2424
import asyncio
25+
from collections.abc import Callable
2526
from functools import wraps
26-
from typing import TYPE_CHECKING, Any, Callable, TypeVar
27+
from typing import TYPE_CHECKING, Any, TypeVar
2728

2829
from axonflow.exceptions import PolicyViolationError
2930
from axonflow.interceptors.base import BaseInterceptor

axonflow/interceptors/ollama.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@
2727
from __future__ import annotations
2828

2929
import asyncio
30+
from collections.abc import Callable
3031
from functools import wraps
31-
from typing import TYPE_CHECKING, Any, Callable, TypeVar
32+
from typing import TYPE_CHECKING, Any, TypeVar
3233

3334
from axonflow.exceptions import PolicyViolationError
3435
from axonflow.interceptors.base import BaseInterceptor

axonflow/interceptors/openai.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,9 @@
2424
from __future__ import annotations
2525

2626
import asyncio
27+
from collections.abc import Callable
2728
from functools import wraps
28-
from typing import TYPE_CHECKING, Any, Callable, TypeVar
29+
from typing import TYPE_CHECKING, Any, TypeVar
2930

3031
from axonflow.exceptions import PolicyViolationError
3132
from axonflow.interceptors.base import BaseInterceptor

axonflow/masfeat.py

Lines changed: 47 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from dataclasses import dataclass, field
1212
from datetime import datetime
1313
from enum import Enum
14-
from typing import Any, Optional
14+
from typing import Any
1515

1616
# Python's datetime.fromisoformat requires exactly 6 fractional digits
1717
_MICROSECOND_PRECISION = 6
@@ -121,8 +121,8 @@ class Finding:
121121
category: str
122122
description: str
123123
status: FindingStatus
124-
remediation: Optional[str] = None
125-
due_date: Optional[datetime] = None
124+
remediation: str | None = None
125+
due_date: datetime | None = None
126126

127127

128128
# ===========================================================================
@@ -140,18 +140,18 @@ class AISystemRegistry:
140140
system_name: str
141141
use_case: AISystemUseCase
142142
owner_team: str
143-
customer_impact: Optional[int]
144-
model_complexity: Optional[int]
145-
human_reliance: Optional[int]
143+
customer_impact: int | None
144+
model_complexity: int | None
145+
human_reliance: int | None
146146
materiality: MaterialityClassification
147147
status: SystemStatus
148-
created_at: Optional[datetime]
149-
updated_at: Optional[datetime]
150-
description: Optional[str] = None
151-
technical_owner: Optional[str] = None
152-
business_owner: Optional[str] = None
153-
metadata: Optional[dict[str, Any]] = None
154-
created_by: Optional[str] = None
148+
created_at: datetime | None
149+
updated_at: datetime | None
150+
description: str | None = None
151+
technical_owner: str | None = None
152+
business_owner: str | None = None
153+
metadata: dict[str, Any] | None = None
154+
created_by: str | None = None
155155

156156

157157
@dataclass
@@ -181,25 +181,25 @@ class FEATAssessment:
181181
system_id: str
182182
assessment_type: str
183183
status: FEATAssessmentStatus
184-
assessment_date: Optional[datetime]
185-
created_at: Optional[datetime]
186-
updated_at: Optional[datetime]
187-
valid_until: Optional[datetime] = None
188-
fairness_score: Optional[int] = None
189-
ethics_score: Optional[int] = None
190-
accountability_score: Optional[int] = None
191-
transparency_score: Optional[int] = None
192-
overall_score: Optional[int] = None
193-
fairness_details: Optional[dict[str, Any]] = None
194-
ethics_details: Optional[dict[str, Any]] = None
195-
accountability_details: Optional[dict[str, Any]] = None
196-
transparency_details: Optional[dict[str, Any]] = None
197-
findings: Optional[list[Finding]] = None
198-
recommendations: Optional[list[str]] = None
199-
assessors: Optional[list[str]] = None
200-
approved_by: Optional[str] = None
201-
approved_at: Optional[datetime] = None
202-
created_by: Optional[str] = None
184+
assessment_date: datetime | None
185+
created_at: datetime | None
186+
updated_at: datetime | None
187+
valid_until: datetime | None = None
188+
fairness_score: int | None = None
189+
ethics_score: int | None = None
190+
accountability_score: int | None = None
191+
transparency_score: int | None = None
192+
overall_score: int | None = None
193+
fairness_details: dict[str, Any] | None = None
194+
ethics_details: dict[str, Any] | None = None
195+
accountability_details: dict[str, Any] | None = None
196+
transparency_details: dict[str, Any] | None = None
197+
findings: list[Finding] | None = None
198+
recommendations: list[str] | None = None
199+
assessors: list[str] | None = None
200+
approved_by: str | None = None
201+
approved_at: datetime | None = None
202+
created_by: str | None = None
203203

204204

205205
# ===========================================================================
@@ -216,16 +216,16 @@ class KillSwitch:
216216
system_id: str
217217
status: KillSwitchStatus
218218
auto_trigger_enabled: bool
219-
created_at: Optional[datetime]
220-
updated_at: Optional[datetime]
221-
accuracy_threshold: Optional[float] = None
222-
bias_threshold: Optional[float] = None
223-
error_rate_threshold: Optional[float] = None
224-
triggered_at: Optional[datetime] = None
225-
triggered_by: Optional[str] = None
226-
triggered_reason: Optional[str] = None
227-
restored_at: Optional[datetime] = None
228-
restored_by: Optional[str] = None
219+
created_at: datetime | None
220+
updated_at: datetime | None
221+
accuracy_threshold: float | None = None
222+
bias_threshold: float | None = None
223+
error_rate_threshold: float | None = None
224+
triggered_at: datetime | None = None
225+
triggered_by: str | None = None
226+
triggered_reason: str | None = None
227+
restored_at: datetime | None = None
228+
restored_by: str | None = None
229229

230230

231231
@dataclass
@@ -235,17 +235,17 @@ class KillSwitchEvent:
235235
id: str
236236
kill_switch_id: str
237237
event_type: KillSwitchEventType
238-
created_at: Optional[datetime]
239-
event_data: Optional[dict[str, Any]] = None
240-
created_by: Optional[str] = None
238+
created_at: datetime | None
239+
event_data: dict[str, Any] | None = None
240+
created_by: str | None = None
241241

242242

243243
# ===========================================================================
244244
# Helper Functions
245245
# ===========================================================================
246246

247247

248-
def _parse_datetime(value: Any) -> Optional[datetime]:
248+
def _parse_datetime(value: Any) -> datetime | None:
249249
"""Parse datetime from API response."""
250250
if value is None:
251251
return None
@@ -358,7 +358,7 @@ def finding_to_dict(finding: Finding) -> dict[str, Any]:
358358
return result
359359

360360

361-
def _parse_findings(data: Optional[list[dict[str, Any]]]) -> Optional[list[Finding]]:
361+
def _parse_findings(data: list[dict[str, Any]] | None) -> list[Finding] | None:
362362
"""Parse list of findings from API response."""
363363
if data is None:
364364
return None

axonflow/utils/retry.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22

33
from __future__ import annotations
44

5-
from typing import Any, Callable, TypeVar
5+
from collections.abc import Callable
6+
from typing import Any, TypeVar
67

78
from tenacity import (
89
RetryCallState,

pyproject.toml

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,22 +24,20 @@ classifiers = [
2424
"License :: OSI Approved :: MIT License",
2525
"Operating System :: OS Independent",
2626
"Programming Language :: Python :: 3",
27-
"Programming Language :: Python :: 3.9",
2827
"Programming Language :: Python :: 3.10",
2928
"Programming Language :: Python :: 3.11",
3029
"Programming Language :: Python :: 3.12",
3130
"Topic :: Software Development :: Libraries :: Python Modules",
3231
"Topic :: Scientific/Engineering :: Artificial Intelligence",
3332
"Typing :: Typed",
3433
]
35-
requires-python = ">=3.9"
34+
requires-python = ">=3.10"
3635
dependencies = [
3736
"httpx>=0.25.0",
3837
"pydantic>=2.0.0",
3938
"tenacity>=8.0.0",
4039
"structlog>=23.0.0",
4140
"cachetools>=5.0.0",
42-
"eval_type_backport>=0.2.0; python_version < '3.10'",
4341
]
4442

4543
[project.optional-dependencies]
@@ -54,6 +52,7 @@ dev = [
5452
"black>=23.0.0",
5553
"isort>=5.12.0",
5654
"pre-commit>=3.0.0",
55+
"mcp>=1.0.0",
5756
]
5857
docs = [
5958
"sphinx>=7.0.0",
@@ -63,9 +62,11 @@ docs = [
6362
]
6463
openai = ["openai>=1.0.0"]
6564
anthropic = ["anthropic>=0.18.0"]
65+
langgraph = ["mcp>=1.0.0"]
6666
all = [
6767
"openai>=1.0.0",
6868
"anthropic>=0.18.0",
69+
"mcp>=1.0.0",
6970
]
7071

7172
[project.urls]
@@ -83,7 +84,7 @@ include = ["axonflow*"]
8384
axonflow = ["py.typed"]
8485

8586
[tool.ruff]
86-
target-version = "py39"
87+
target-version = "py310"
8788
line-length = 100
8889

8990
[tool.ruff.lint]
@@ -148,7 +149,7 @@ ignore = [
148149
known-first-party = ["axonflow"]
149150

150151
[tool.mypy]
151-
python_version = "3.9"
152+
python_version = "3.10"
152153
strict = true
153154
warn_return_any = true
154155
warn_unused_configs = true
@@ -169,6 +170,7 @@ show_column_numbers = true
169170
module = "tests.*"
170171
disallow_untyped_defs = false
171172

173+
172174
[tool.pytest.ini_options]
173175
asyncio_mode = "auto"
174176
testpaths = ["tests"]

tests/test_langgraph_adapter.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from unittest.mock import AsyncMock, MagicMock
88

99
import pytest
10+
from mcp.types import CallToolResult, TextContent
1011

1112
from axonflow import AxonFlow
1213
from axonflow.adapters.langgraph import AxonFlowLangGraphAdapter, MCPInterceptorOptions
@@ -192,7 +193,10 @@ async def test_returns_redacted_data_when_present(
192193

193194
result = await adapter.mcp_tool_interceptor()(MagicMock(), handler)
194195

195-
assert result == "[REDACTED]"
196+
assert isinstance(result, CallToolResult)
197+
assert len(result.content) == 1
198+
assert isinstance(result.content[0], TextContent)
199+
assert result.content[0].text == "[REDACTED]"
196200

197201
@pytest.mark.asyncio
198202
async def test_returns_original_result_when_no_redaction(

0 commit comments

Comments
 (0)