jep-mcp-wrapper 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: jep-mcp-wrapper
3
+ Version: 0.1.1
4
+ Summary: Verifiable accountability wrapper for MCP tool execution
5
+ Author: JEP MCP Wrapper Contributors
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: filelock<4,>=3.12
14
+
15
+ # jep-mcp-wrapper
16
+
17
+ `jep-mcp-wrapper` adds verifiable accountability semantics to MCP tool execution without changing the MCP protocol. Existing tool callables are wrapped in a side-channel JEP runtime that emits deterministic, append-only events for each execution lifecycle step.
18
+
19
+ ## What it provides
20
+
21
+ - `JEPMCPWrapper` wraps sync and async MCP tool callables.
22
+ - `MCPExecutionTracer` writes lifecycle events (`requested`, `running`, `succeeded`, `failed`).
23
+ - `ToolDelegationRuntime` tracks the active actor, delegation lineage, parent context, and authority scope across nested tool calls.
24
+ - `ReplayVerifier` replays archived execution chains, verifies lineage, validates deterministic hashes, and detects archive tampering.
25
+ - `AppendOnlyEventArchive` stores JSONL events as an append-only hash chain.
26
+
27
+ The wrapper records:
28
+
29
+ - `tool_name`
30
+ - `actor`
31
+ - delegation lineage
32
+ - authority scope
33
+ - execution state
34
+ - parent event linkage
35
+ - deterministic event hash and previous hash
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from pathlib import Path
41
+ from jep_mcp_wrapper import JEPMCPWrapper, ReplayVerifier
42
+
43
+ archive = "jep-events.jsonl"
44
+ wrapper = JEPMCPWrapper(
45
+ archive,
46
+ default_actor="agent:file-reader",
47
+ default_authority_scope={"filesystem": "read-only"},
48
+ )
49
+
50
+ def read_file(path: str) -> str:
51
+ return Path(path).read_text(encoding="utf-8")
52
+
53
+ read_file = wrapper.wrap_tool("filesystem.read_file", read_file)
54
+ print(read_file("README.md"))
55
+
56
+ replay = ReplayVerifier(archive).replay()
57
+ assert replay.verified
58
+ ```
59
+
60
+ ## Chained delegation
61
+
62
+ Nested wrapped calls automatically extend lineage. A `search.query` tool that calls a wrapped `browser.fetch` tool produces two execution chains: one with `("search.query",)` and one with `("search.query", "browser.fetch")`.
63
+
64
+ ```python
65
+ from jep_mcp_wrapper import JEPMCPWrapper
66
+
67
+ wrapper = JEPMCPWrapper("events.jsonl", default_actor="agent:researcher")
68
+
69
+ def browser_fetch(url: str) -> str:
70
+ return f"page:{url}"
71
+
72
+ def search(query: str, fetch) -> str:
73
+ return fetch(f"https://example.test?q={query}")
74
+
75
+ fetch = wrapper.wrap_tool("browser.fetch", browser_fetch, authority_scope={"network": "example.test"})
76
+ search = wrapper.wrap_tool("search.query", search, authority_scope={"purpose": "research"})
77
+ search("accountability", fetch=fetch)
78
+ ```
79
+
80
+ ## Replay and tamper detection
81
+
82
+ ```python
83
+ from jep_mcp_wrapper import ReplayVerifier
84
+
85
+ verifier = ReplayVerifier("events.jsonl")
86
+ result = verifier.replay()
87
+ print(result.verified)
88
+ print(result.lineage_by_call)
89
+ print(verifier.detect_tampering())
90
+ ```
91
+
92
+ Replay verification checks:
93
+
94
+ 1. deterministic hash equality for every event,
95
+ 2. previous-hash continuity across the append-only archive,
96
+ 3. monotonic event sequence numbers,
97
+ 4. valid tool lifecycle transitions,
98
+ 5. stable lineage for every tool call,
99
+ 6. parent/child lineage consistency when parent links are present.
100
+
101
+ ## Examples
102
+
103
+ - `examples/filesystem_tool.py` wraps a filesystem read tool.
104
+ - `examples/browser_search_chain.py` wraps browser and search tools with chained delegation.
105
+
106
+ ## Non-goals
107
+
108
+ - It does not modify MCP protocol schemas or wire semantics.
109
+ - It does not implement an orchestration framework.
110
+ - It does not decide whether a tool is authorized; it records the declared authority scope so execution can be audited and replayed.
@@ -0,0 +1,96 @@
1
+ # jep-mcp-wrapper
2
+
3
+ `jep-mcp-wrapper` adds verifiable accountability semantics to MCP tool execution without changing the MCP protocol. Existing tool callables are wrapped in a side-channel JEP runtime that emits deterministic, append-only events for each execution lifecycle step.
4
+
5
+ ## What it provides
6
+
7
+ - `JEPMCPWrapper` wraps sync and async MCP tool callables.
8
+ - `MCPExecutionTracer` writes lifecycle events (`requested`, `running`, `succeeded`, `failed`).
9
+ - `ToolDelegationRuntime` tracks the active actor, delegation lineage, parent context, and authority scope across nested tool calls.
10
+ - `ReplayVerifier` replays archived execution chains, verifies lineage, validates deterministic hashes, and detects archive tampering.
11
+ - `AppendOnlyEventArchive` stores JSONL events as an append-only hash chain.
12
+
13
+ The wrapper records:
14
+
15
+ - `tool_name`
16
+ - `actor`
17
+ - delegation lineage
18
+ - authority scope
19
+ - execution state
20
+ - parent event linkage
21
+ - deterministic event hash and previous hash
22
+
23
+ ## Quick start
24
+
25
+ ```python
26
+ from pathlib import Path
27
+ from jep_mcp_wrapper import JEPMCPWrapper, ReplayVerifier
28
+
29
+ archive = "jep-events.jsonl"
30
+ wrapper = JEPMCPWrapper(
31
+ archive,
32
+ default_actor="agent:file-reader",
33
+ default_authority_scope={"filesystem": "read-only"},
34
+ )
35
+
36
+ def read_file(path: str) -> str:
37
+ return Path(path).read_text(encoding="utf-8")
38
+
39
+ read_file = wrapper.wrap_tool("filesystem.read_file", read_file)
40
+ print(read_file("README.md"))
41
+
42
+ replay = ReplayVerifier(archive).replay()
43
+ assert replay.verified
44
+ ```
45
+
46
+ ## Chained delegation
47
+
48
+ Nested wrapped calls automatically extend lineage. A `search.query` tool that calls a wrapped `browser.fetch` tool produces two execution chains: one with `("search.query",)` and one with `("search.query", "browser.fetch")`.
49
+
50
+ ```python
51
+ from jep_mcp_wrapper import JEPMCPWrapper
52
+
53
+ wrapper = JEPMCPWrapper("events.jsonl", default_actor="agent:researcher")
54
+
55
+ def browser_fetch(url: str) -> str:
56
+ return f"page:{url}"
57
+
58
+ def search(query: str, fetch) -> str:
59
+ return fetch(f"https://example.test?q={query}")
60
+
61
+ fetch = wrapper.wrap_tool("browser.fetch", browser_fetch, authority_scope={"network": "example.test"})
62
+ search = wrapper.wrap_tool("search.query", search, authority_scope={"purpose": "research"})
63
+ search("accountability", fetch=fetch)
64
+ ```
65
+
66
+ ## Replay and tamper detection
67
+
68
+ ```python
69
+ from jep_mcp_wrapper import ReplayVerifier
70
+
71
+ verifier = ReplayVerifier("events.jsonl")
72
+ result = verifier.replay()
73
+ print(result.verified)
74
+ print(result.lineage_by_call)
75
+ print(verifier.detect_tampering())
76
+ ```
77
+
78
+ Replay verification checks:
79
+
80
+ 1. deterministic hash equality for every event,
81
+ 2. previous-hash continuity across the append-only archive,
82
+ 3. monotonic event sequence numbers,
83
+ 4. valid tool lifecycle transitions,
84
+ 5. stable lineage for every tool call,
85
+ 6. parent/child lineage consistency when parent links are present.
86
+
87
+ ## Examples
88
+
89
+ - `examples/filesystem_tool.py` wraps a filesystem read tool.
90
+ - `examples/browser_search_chain.py` wraps browser and search tools with chained delegation.
91
+
92
+ ## Non-goals
93
+
94
+ - It does not modify MCP protocol schemas or wire semantics.
95
+ - It does not implement an orchestration framework.
96
+ - It does not decide whether a tool is authorized; it records the declared authority scope so execution can be audited and replayed.
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "jep-mcp-wrapper"
7
+ version = "0.1.1"
8
+ description = "Verifiable accountability wrapper for MCP tool execution"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ dependencies = ["filelock>=3.12,<4"]
12
+ license = { text = "MIT" }
13
+ authors = [{ name = "JEP MCP Wrapper Contributors" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ ]
20
+
21
+ [tool.setuptools.packages.find]
22
+ where = ["src"]
23
+
24
+ [tool.pytest.ini_options]
25
+ pythonpath = ["src"]
26
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,26 @@
1
+ """JEP accountability semantics for MCP tool execution.
2
+
3
+ The package wraps existing MCP tool callables without changing the MCP protocol.
4
+ """
5
+
6
+ from .archive import AppendOnlyEventArchive, ArchiveTamperError
7
+ from .events import JEPEvent, ToolExecutionState, deterministic_hash
8
+ from .runtime import DelegationContext, ToolDelegationRuntime
9
+ from .tracer import MCPExecutionTracer
10
+ from .verifier import ReplayResult, ReplayVerifier
11
+ from .wrapper import JEPMCPWrapper, ToolCallResult
12
+
13
+ __all__ = [
14
+ "AppendOnlyEventArchive",
15
+ "ArchiveTamperError",
16
+ "DelegationContext",
17
+ "JEPEvent",
18
+ "JEPMCPWrapper",
19
+ "MCPExecutionTracer",
20
+ "ReplayResult",
21
+ "ReplayVerifier",
22
+ "ToolCallResult",
23
+ "ToolDelegationRuntime",
24
+ "ToolExecutionState",
25
+ "deterministic_hash",
26
+ ]
@@ -0,0 +1,111 @@
1
+ """Append-only event archive for JEP MCP wrapper events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from filelock import FileLock
8
+ from typing import Iterable
9
+
10
+ from .events import JEPEvent
11
+
12
+
13
+ class ArchiveTamperError(RuntimeError):
14
+ """Raised when an archive's existing hash chain is invalid."""
15
+
16
+
17
+ class AppendOnlyEventArchive:
18
+ """JSONL archive that only appends events and validates existing chains."""
19
+
20
+ def __init__(self, path: str | Path):
21
+ self.path = Path(path).resolve()
22
+ self.path.parent.mkdir(parents=True, exist_ok=True)
23
+ self._lock = FileLock(str(self.path) + ".lock", timeout=30)
24
+ self._last_hash: str | None = None
25
+ self._next_sequence = 0
26
+ with self._lock:
27
+ self._load_and_validate()
28
+
29
+ @property
30
+ def last_hash(self) -> str | None:
31
+ return self._last_hash
32
+
33
+ @property
34
+ def next_sequence(self) -> int:
35
+ return self._next_sequence
36
+
37
+ def append_new(self, **fields) -> JEPEvent:
38
+ """Assign sequence/hash and append under one cross-process lock."""
39
+ with self._lock:
40
+ self._load_and_validate()
41
+ event = JEPEvent.create(sequence=self._next_sequence, prev_hash=self._last_hash, **fields)
42
+ return self.append(event)
43
+
44
+ def append(self, event: JEPEvent) -> JEPEvent:
45
+ """Append a single event after enforcing sequence and hash continuity."""
46
+
47
+ with self._lock:
48
+ self._load_and_validate()
49
+ if event.sequence != self._next_sequence:
50
+ raise ArchiveTamperError(
51
+ f"event sequence {event.sequence} does not match next sequence {self._next_sequence}"
52
+ )
53
+ if event.prev_hash != self._last_hash:
54
+ raise ArchiveTamperError("event prev_hash does not match archive tail")
55
+ sealed = event.with_hash()
56
+ with self.path.open("a", encoding="utf-8") as stream:
57
+ stream.write(json.dumps(sealed.to_record(), sort_keys=True, ensure_ascii=False) + "\n")
58
+ self._last_hash = sealed.event_hash
59
+ self._next_sequence += 1
60
+ return sealed
61
+
62
+ def read_events(self) -> list[JEPEvent]:
63
+ """Read all events from the archive."""
64
+
65
+ with self._lock:
66
+ return self._read_events()
67
+
68
+ def _read_events(self) -> list[JEPEvent]:
69
+ if not self.path.exists():
70
+ return []
71
+ events: list[JEPEvent] = []
72
+ with self.path.open("r", encoding="utf-8") as stream:
73
+ for line_number, line in enumerate(stream, start=1):
74
+ line = line.strip()
75
+ if not line:
76
+ continue
77
+ try:
78
+ events.append(JEPEvent.from_record(json.loads(line)))
79
+ except (KeyError, TypeError, json.JSONDecodeError, ValueError) as exc:
80
+ raise ArchiveTamperError(f"invalid archive record at line {line_number}") from exc
81
+ return events
82
+
83
+ def _load_and_validate(self) -> None:
84
+ previous_hash: str | None = None
85
+ expected_sequence = 0
86
+ for event in self.read_events():
87
+ _validate_event(event, expected_sequence, previous_hash)
88
+ previous_hash = event.event_hash
89
+ expected_sequence += 1
90
+ self._last_hash = previous_hash
91
+ self._next_sequence = expected_sequence
92
+
93
+
94
+ def _validate_event(event: JEPEvent, expected_sequence: int, previous_hash: str | None) -> None:
95
+ if event.sequence != expected_sequence:
96
+ raise ArchiveTamperError(
97
+ f"event sequence {event.sequence} does not match expected sequence {expected_sequence}"
98
+ )
99
+ if event.prev_hash != previous_hash:
100
+ raise ArchiveTamperError("event prev_hash breaks archive hash chain")
101
+ if event.event_hash != event.compute_hash():
102
+ raise ArchiveTamperError("event_hash does not match deterministic event payload")
103
+
104
+
105
+ def validate_events(events: Iterable[JEPEvent]) -> None:
106
+ """Validate sequence numbers, event hashes, and hash-chain continuity."""
107
+
108
+ previous_hash: str | None = None
109
+ for expected_sequence, event in enumerate(events):
110
+ _validate_event(event, expected_sequence, previous_hash)
111
+ previous_hash = event.event_hash
@@ -0,0 +1,152 @@
1
+ """Deterministic JEP event model and hashing utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ import hashlib
8
+ import json
9
+ from typing import Any, Mapping
10
+ from uuid import uuid4
11
+
12
+
13
+ class ToolExecutionState(str, Enum):
14
+ """Lifecycle states recorded for MCP tool execution."""
15
+
16
+ REQUESTED = "requested"
17
+ RUNNING = "running"
18
+ SUCCEEDED = "succeeded"
19
+ FAILED = "failed"
20
+ DELEGATED = "delegated"
21
+
22
+
23
+ def _canonical_json(payload: Mapping[str, Any]) -> str:
24
+ """Serialize a mapping into deterministic JSON for stable event hashes."""
25
+
26
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
27
+
28
+
29
+ def deterministic_hash(payload: Mapping[str, Any]) -> str:
30
+ """Return a SHA-256 digest for a canonical JSON payload."""
31
+
32
+ return hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class JEPEvent:
37
+ """Append-only accountability event for an MCP tool execution step."""
38
+
39
+ event_id: str
40
+ tool_name: str
41
+ actor: str
42
+ delegation_lineage: tuple[str, ...]
43
+ authority_scope: Mapping[str, Any]
44
+ execution_state: ToolExecutionState
45
+ sequence: int
46
+ prev_hash: str | None = None
47
+ parent_event_id: str | None = None
48
+ call_id: str = field(default_factory=lambda: uuid4().hex)
49
+ metadata: Mapping[str, Any] = field(default_factory=dict)
50
+ event_hash: str | None = None
51
+
52
+ @classmethod
53
+ def create(
54
+ cls,
55
+ *,
56
+ tool_name: str,
57
+ actor: str,
58
+ delegation_lineage: tuple[str, ...],
59
+ authority_scope: Mapping[str, Any],
60
+ execution_state: ToolExecutionState,
61
+ sequence: int,
62
+ prev_hash: str | None,
63
+ parent_event_id: str | None = None,
64
+ call_id: str | None = None,
65
+ metadata: Mapping[str, Any] | None = None,
66
+ ) -> "JEPEvent":
67
+ """Create an event and deterministically seal it with an event hash."""
68
+
69
+ event = cls(
70
+ event_id=uuid4().hex,
71
+ tool_name=tool_name,
72
+ actor=actor,
73
+ delegation_lineage=delegation_lineage,
74
+ authority_scope=dict(authority_scope),
75
+ execution_state=execution_state,
76
+ sequence=sequence,
77
+ prev_hash=prev_hash,
78
+ parent_event_id=parent_event_id,
79
+ call_id=call_id or uuid4().hex,
80
+ metadata=dict(metadata or {}),
81
+ )
82
+ return event.with_hash()
83
+
84
+ def hash_payload(self) -> dict[str, Any]:
85
+ """Return the exact event fields covered by the deterministic hash."""
86
+
87
+ return {
88
+ "actor": self.actor,
89
+ "authority_scope": dict(self.authority_scope),
90
+ "call_id": self.call_id,
91
+ "delegation_lineage": list(self.delegation_lineage),
92
+ "event_id": self.event_id,
93
+ "execution_state": self.execution_state.value,
94
+ "metadata": dict(self.metadata),
95
+ "parent_event_id": self.parent_event_id,
96
+ "prev_hash": self.prev_hash,
97
+ "sequence": self.sequence,
98
+ "tool_name": self.tool_name,
99
+ }
100
+
101
+ def compute_hash(self) -> str:
102
+ """Compute this event's deterministic hash."""
103
+
104
+ return deterministic_hash(self.hash_payload())
105
+
106
+ def with_hash(self) -> "JEPEvent":
107
+ """Return a copy with its event_hash populated from sealed fields."""
108
+
109
+ return JEPEvent(
110
+ event_id=self.event_id,
111
+ tool_name=self.tool_name,
112
+ actor=self.actor,
113
+ delegation_lineage=self.delegation_lineage,
114
+ authority_scope=dict(self.authority_scope),
115
+ execution_state=self.execution_state,
116
+ sequence=self.sequence,
117
+ prev_hash=self.prev_hash,
118
+ parent_event_id=self.parent_event_id,
119
+ call_id=self.call_id,
120
+ metadata=dict(self.metadata),
121
+ event_hash=self.compute_hash(),
122
+ )
123
+
124
+ def to_record(self) -> dict[str, Any]:
125
+ """Serialize this event for JSONL archive storage."""
126
+
127
+ event_hash = self.event_hash or self.compute_hash()
128
+ return {**self.hash_payload(), "event_hash": event_hash}
129
+
130
+ @classmethod
131
+ def from_record(cls, record: Mapping[str, Any]) -> "JEPEvent":
132
+ """Deserialize an event record from JSONL archive storage."""
133
+
134
+ if not isinstance(record, Mapping) or type(record.get("sequence")) is not int:
135
+ raise ValueError("invalid archive event type")
136
+ for field in ("event_id", "tool_name", "actor", "call_id"):
137
+ if not isinstance(record.get(field), str):
138
+ raise ValueError("invalid archive string field")
139
+ return cls(
140
+ event_id=record["event_id"],
141
+ tool_name=str(record["tool_name"]),
142
+ actor=str(record["actor"]),
143
+ delegation_lineage=tuple(record.get("delegation_lineage", ())),
144
+ authority_scope=dict(record.get("authority_scope", {})),
145
+ execution_state=ToolExecutionState(record["execution_state"]),
146
+ sequence=record["sequence"],
147
+ prev_hash=record.get("prev_hash"),
148
+ parent_event_id=record.get("parent_event_id"),
149
+ call_id=str(record["call_id"]),
150
+ metadata=dict(record.get("metadata", {})),
151
+ event_hash=record.get("event_hash"),
152
+ )
@@ -0,0 +1,85 @@
1
+ """Delegation context runtime for accountable MCP tool calls."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from dataclasses import dataclass, field
8
+ from typing import Any, Iterator, Mapping
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class DelegationContext:
13
+ """Actor, lineage, and authority scope active for a tool call."""
14
+
15
+ actor: str
16
+ delegation_lineage: tuple[str, ...] = field(default_factory=tuple)
17
+ authority_scope: Mapping[str, Any] = field(default_factory=dict)
18
+ parent_event_id: str | None = None
19
+
20
+
21
+ _current_context: ContextVar[DelegationContext | None] = ContextVar("jep_mcp_delegation_context", default=None)
22
+
23
+
24
+ class ToolDelegationRuntime:
25
+ """Maintains nested tool delegation lineage without changing MCP payloads."""
26
+
27
+ def __init__(self, *, default_actor: str = "mcp-client", default_authority_scope: Mapping[str, Any] | None = None):
28
+ self.default_actor = default_actor
29
+ self.default_authority_scope = dict(default_authority_scope or {})
30
+
31
+ def current(self) -> DelegationContext:
32
+ """Return active context or a default root context."""
33
+
34
+ context = _current_context.get()
35
+ if context is not None:
36
+ return context
37
+ return DelegationContext(actor=self.default_actor, authority_scope=self.default_authority_scope)
38
+
39
+ @contextmanager
40
+ def as_actor(
41
+ self,
42
+ actor: str,
43
+ *,
44
+ authority_scope: Mapping[str, Any] | None = None,
45
+ parent_event_id: str | None = None,
46
+ ) -> Iterator[DelegationContext]:
47
+ """Run code under a root actor context."""
48
+
49
+ context = DelegationContext(
50
+ actor=actor,
51
+ authority_scope=dict(authority_scope or self.default_authority_scope),
52
+ parent_event_id=parent_event_id,
53
+ )
54
+ token = _current_context.set(context)
55
+ try:
56
+ yield context
57
+ finally:
58
+ _current_context.reset(token)
59
+
60
+ @contextmanager
61
+ def delegate(
62
+ self,
63
+ tool_name: str,
64
+ *,
65
+ actor: str | None = None,
66
+ authority_scope: Mapping[str, Any] | None = None,
67
+ parent_event_id: str | None = None,
68
+ ) -> Iterator[DelegationContext]:
69
+ """Enter a nested delegation context for chained tool execution."""
70
+
71
+ parent = self.current()
72
+ delegated_actor = actor or parent.actor
73
+ delegated_scope = dict(parent.authority_scope)
74
+ delegated_scope.update(dict(authority_scope or {}))
75
+ context = DelegationContext(
76
+ actor=delegated_actor,
77
+ delegation_lineage=(*parent.delegation_lineage, tool_name),
78
+ authority_scope=delegated_scope,
79
+ parent_event_id=parent_event_id or parent.parent_event_id,
80
+ )
81
+ token = _current_context.set(context)
82
+ try:
83
+ yield context
84
+ finally:
85
+ _current_context.reset(token)
@@ -0,0 +1,41 @@
1
+ """Event tracer for MCP tool execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Mapping
6
+
7
+ from .archive import AppendOnlyEventArchive
8
+ from .events import JEPEvent, ToolExecutionState
9
+ from .runtime import DelegationContext, ToolDelegationRuntime
10
+
11
+
12
+ class MCPExecutionTracer:
13
+ """Creates JEP events for tool lifecycle transitions."""
14
+
15
+ def __init__(self, archive: AppendOnlyEventArchive, runtime: ToolDelegationRuntime):
16
+ self.archive = archive
17
+ self.runtime = runtime
18
+
19
+ def record(
20
+ self,
21
+ *,
22
+ tool_name: str,
23
+ state: ToolExecutionState,
24
+ context: DelegationContext | None = None,
25
+ call_id: str | None = None,
26
+ parent_event_id: str | None = None,
27
+ metadata: Mapping[str, Any] | None = None,
28
+ ) -> JEPEvent:
29
+ """Create and append a lifecycle event to the archive."""
30
+
31
+ active = context or self.runtime.current()
32
+ return self.archive.append_new(
33
+ tool_name=tool_name,
34
+ actor=active.actor,
35
+ delegation_lineage=active.delegation_lineage,
36
+ authority_scope=active.authority_scope,
37
+ execution_state=state,
38
+ parent_event_id=parent_event_id or active.parent_event_id,
39
+ call_id=call_id,
40
+ metadata=metadata,
41
+ )
@@ -0,0 +1,117 @@
1
+ """Replay and verification for archived JEP MCP tool events."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+
8
+ from .archive import AppendOnlyEventArchive, ArchiveTamperError, validate_events
9
+ from .events import JEPEvent, ToolExecutionState
10
+
11
+
12
+ _VALID_TRANSITIONS = {
13
+ ToolExecutionState.REQUESTED: {ToolExecutionState.RUNNING},
14
+ ToolExecutionState.RUNNING: {ToolExecutionState.SUCCEEDED, ToolExecutionState.FAILED},
15
+ ToolExecutionState.SUCCEEDED: set(),
16
+ ToolExecutionState.FAILED: set(),
17
+ ToolExecutionState.DELEGATED: {ToolExecutionState.REQUESTED, ToolExecutionState.RUNNING},
18
+ }
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ReplayResult:
23
+ """Result of replaying and verifying an archive."""
24
+
25
+ verified: bool
26
+ events: tuple[JEPEvent, ...]
27
+ terminal_states: dict[str, ToolExecutionState]
28
+ lineage_by_call: dict[str, tuple[str, ...]]
29
+ errors: tuple[str, ...] = field(default_factory=tuple)
30
+
31
+
32
+ class ReplayVerifier:
33
+ """Replays execution chains, verifies lineages, and detects tampering."""
34
+
35
+ def __init__(self, archive: AppendOnlyEventArchive | str):
36
+ self.archive = archive
37
+
38
+ def replay(self) -> ReplayResult:
39
+ """Replay archive events and return chain state with validation errors."""
40
+
41
+ try:
42
+ archive = self.archive if isinstance(self.archive, AppendOnlyEventArchive) else AppendOnlyEventArchive(self.archive)
43
+ events = archive.read_events()
44
+ validate_events(events)
45
+ except ArchiveTamperError as exc:
46
+ return ReplayResult(
47
+ verified=False,
48
+ events=(),
49
+ terminal_states={},
50
+ lineage_by_call={},
51
+ errors=(f"tampering detected: {exc}",),
52
+ )
53
+
54
+ errors: list[str] = []
55
+ events_by_call: dict[str, list[JEPEvent]] = defaultdict(list)
56
+ for event in events:
57
+ events_by_call[event.call_id].append(event)
58
+
59
+ terminal_states: dict[str, ToolExecutionState] = {}
60
+ lineage_by_call: dict[str, tuple[str, ...]] = {}
61
+ for call_id, call_events in events_by_call.items():
62
+ previous_state: ToolExecutionState | None = None
63
+ if call_events[0].execution_state not in {ToolExecutionState.REQUESTED, ToolExecutionState.DELEGATED}:
64
+ errors.append(f"call {call_id} missing initial request")
65
+ expected_lineage = call_events[0].delegation_lineage
66
+ for event in call_events:
67
+ if event.delegation_lineage != expected_lineage:
68
+ errors.append(f"call {call_id} changed delegation lineage")
69
+ if previous_state is not None and event.execution_state not in _VALID_TRANSITIONS[previous_state]:
70
+ errors.append(
71
+ f"call {call_id} invalid transition {previous_state.value}->{event.execution_state.value}"
72
+ )
73
+ previous_state = event.execution_state
74
+ terminal_states[call_id] = call_events[-1].execution_state
75
+ lineage_by_call[call_id] = expected_lineage
76
+ if call_events[-1].execution_state not in {ToolExecutionState.SUCCEEDED, ToolExecutionState.FAILED}:
77
+ errors.append(f"call {call_id} did not reach a terminal state")
78
+
79
+ self._verify_parent_lineage(events, errors)
80
+ return ReplayResult(
81
+ verified=not errors,
82
+ events=tuple(events),
83
+ terminal_states=terminal_states,
84
+ lineage_by_call=lineage_by_call,
85
+ errors=tuple(errors),
86
+ )
87
+
88
+ def verify_tool_lineage(self, expected_prefix: tuple[str, ...] | None = None) -> bool:
89
+ """Return True when replay succeeds and every call matches an optional lineage prefix."""
90
+
91
+ result = self.replay()
92
+ if not result.verified:
93
+ return False
94
+ if expected_prefix is None:
95
+ return True
96
+ return all(lineage[: len(expected_prefix)] == expected_prefix for lineage in result.lineage_by_call.values())
97
+
98
+ def detect_tampering(self) -> bool:
99
+ """Return True when deterministic hashes or replay semantics fail."""
100
+
101
+ return not self.replay().verified
102
+
103
+ def _verify_parent_lineage(self, events: list[JEPEvent], errors: list[str]) -> None:
104
+ by_event_id = {event.event_id: event for event in events}
105
+ for event in events:
106
+ if event.parent_event_id is None:
107
+ continue
108
+ parent = by_event_id.get(event.parent_event_id)
109
+ if parent is None:
110
+ errors.append(f"event {event.event_id} references missing parent_event_id")
111
+ continue
112
+ if parent.sequence >= event.sequence:
113
+ errors.append(f"event {event.event_id} parent must precede child")
114
+ parent_lineage = parent.delegation_lineage
115
+ lineage = event.delegation_lineage
116
+ if lineage != parent_lineage and lineage[:-1] != parent_lineage:
117
+ errors.append(f"event {event.event_id} lineage does not descend from parent")
@@ -0,0 +1,189 @@
1
+ """Public wrapper for accountable MCP tool execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ from dataclasses import dataclass
7
+ from functools import wraps
8
+ from typing import Any, Awaitable, Callable, Mapping, TypeVar
9
+ from uuid import uuid4
10
+
11
+ from .archive import AppendOnlyEventArchive
12
+ from .events import JEPEvent, ToolExecutionState
13
+ from .runtime import ToolDelegationRuntime
14
+ from .tracer import MCPExecutionTracer
15
+
16
+ T = TypeVar("T")
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ToolCallResult:
21
+ """Tool result plus trace metadata emitted by the wrapper."""
22
+
23
+ value: Any
24
+ call_id: str
25
+ start_event: JEPEvent
26
+ end_event: JEPEvent
27
+
28
+
29
+ class JEPMCPWrapper:
30
+ """Wrap MCP tools with verifiable JEP accountability semantics.
31
+
32
+ The wrapper decorates existing callables and keeps all accountability state in
33
+ a side-channel event archive, so MCP protocol request/response schemas remain
34
+ untouched.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ archive: AppendOnlyEventArchive | str,
40
+ *,
41
+ default_actor: str = "mcp-client",
42
+ default_authority_scope: Mapping[str, Any] | None = None,
43
+ return_trace: bool = False,
44
+ ):
45
+ self.archive = archive if isinstance(archive, AppendOnlyEventArchive) else AppendOnlyEventArchive(archive)
46
+ self.runtime = ToolDelegationRuntime(
47
+ default_actor=default_actor,
48
+ default_authority_scope=default_authority_scope,
49
+ )
50
+ self.tracer = MCPExecutionTracer(self.archive, self.runtime)
51
+ self.return_trace = return_trace
52
+
53
+ def wrap_tool(
54
+ self,
55
+ tool_name: str,
56
+ tool: Callable[..., T],
57
+ *,
58
+ authority_scope: Mapping[str, Any] | None = None,
59
+ actor: str | None = None,
60
+ ) -> Callable[..., T | ToolCallResult] | Callable[..., Awaitable[T | ToolCallResult]]:
61
+ """Return a callable that records requested/running/succeeded/failed events."""
62
+
63
+ if inspect.iscoroutinefunction(tool):
64
+
65
+ @wraps(tool)
66
+ async def async_wrapped(*args: Any, **kwargs: Any) -> T | ToolCallResult:
67
+ return await self._execute_async(tool_name, tool, args, kwargs, authority_scope=authority_scope, actor=actor)
68
+
69
+ return async_wrapped
70
+
71
+ @wraps(tool)
72
+ def wrapped(*args: Any, **kwargs: Any) -> T | ToolCallResult:
73
+ return self._execute(tool_name, tool, args, kwargs, authority_scope=authority_scope, actor=actor)
74
+
75
+ return wrapped
76
+
77
+ def call_tool(
78
+ self,
79
+ tool_name: str,
80
+ tool: Callable[..., T],
81
+ *args: Any,
82
+ authority_scope: Mapping[str, Any] | None = None,
83
+ actor: str | None = None,
84
+ **kwargs: Any,
85
+ ) -> T | ToolCallResult:
86
+ """Execute a synchronous tool once under accountability tracing."""
87
+
88
+ return self._execute(tool_name, tool, args, kwargs, authority_scope=authority_scope, actor=actor)
89
+
90
+ async def call_tool_async(
91
+ self,
92
+ tool_name: str,
93
+ tool: Callable[..., Awaitable[T]],
94
+ *args: Any,
95
+ authority_scope: Mapping[str, Any] | None = None,
96
+ actor: str | None = None,
97
+ **kwargs: Any,
98
+ ) -> T | ToolCallResult:
99
+ """Execute an async tool once under accountability tracing."""
100
+
101
+ return await self._execute_async(tool_name, tool, args, kwargs, authority_scope=authority_scope, actor=actor)
102
+
103
+ def _execute(
104
+ self,
105
+ tool_name: str,
106
+ tool: Callable[..., T],
107
+ args: tuple[Any, ...],
108
+ kwargs: dict[str, Any],
109
+ *,
110
+ authority_scope: Mapping[str, Any] | None,
111
+ actor: str | None,
112
+ ) -> T | ToolCallResult:
113
+ call_id = uuid4().hex
114
+ with self.runtime.delegate(tool_name, actor=actor, authority_scope=authority_scope):
115
+ start_event = self.tracer.record(
116
+ tool_name=tool_name,
117
+ state=ToolExecutionState.REQUESTED,
118
+ call_id=call_id,
119
+ )
120
+ try:
121
+ running_event = self.tracer.record(
122
+ tool_name=tool_name,
123
+ state=ToolExecutionState.RUNNING,
124
+ call_id=call_id,
125
+ parent_event_id=start_event.event_id,
126
+ )
127
+ value = tool(*args, **kwargs)
128
+ end_event = self.tracer.record(
129
+ tool_name=tool_name,
130
+ state=ToolExecutionState.SUCCEEDED,
131
+ call_id=call_id,
132
+ parent_event_id=running_event.event_id,
133
+ )
134
+ except BaseException as exc:
135
+ end_event = self.tracer.record(
136
+ tool_name=tool_name,
137
+ state=ToolExecutionState.FAILED,
138
+ call_id=call_id,
139
+ parent_event_id=start_event.event_id,
140
+ metadata={"error_type": type(exc).__name__, "error": str(exc)},
141
+ )
142
+ raise
143
+ if self.return_trace:
144
+ return ToolCallResult(value=value, call_id=call_id, start_event=start_event, end_event=end_event)
145
+ return value
146
+
147
+ async def _execute_async(
148
+ self,
149
+ tool_name: str,
150
+ tool: Callable[..., Awaitable[T]],
151
+ args: tuple[Any, ...],
152
+ kwargs: dict[str, Any],
153
+ *,
154
+ authority_scope: Mapping[str, Any] | None,
155
+ actor: str | None,
156
+ ) -> T | ToolCallResult:
157
+ call_id = uuid4().hex
158
+ with self.runtime.delegate(tool_name, actor=actor, authority_scope=authority_scope):
159
+ start_event = self.tracer.record(
160
+ tool_name=tool_name,
161
+ state=ToolExecutionState.REQUESTED,
162
+ call_id=call_id,
163
+ )
164
+ try:
165
+ running_event = self.tracer.record(
166
+ tool_name=tool_name,
167
+ state=ToolExecutionState.RUNNING,
168
+ call_id=call_id,
169
+ parent_event_id=start_event.event_id,
170
+ )
171
+ value = await tool(*args, **kwargs)
172
+ end_event = self.tracer.record(
173
+ tool_name=tool_name,
174
+ state=ToolExecutionState.SUCCEEDED,
175
+ call_id=call_id,
176
+ parent_event_id=running_event.event_id,
177
+ )
178
+ except BaseException as exc:
179
+ end_event = self.tracer.record(
180
+ tool_name=tool_name,
181
+ state=ToolExecutionState.FAILED,
182
+ call_id=call_id,
183
+ parent_event_id=start_event.event_id,
184
+ metadata={"error_type": type(exc).__name__, "error": str(exc)},
185
+ )
186
+ raise
187
+ if self.return_trace:
188
+ return ToolCallResult(value=value, call_id=call_id, start_event=start_event, end_event=end_event)
189
+ return value
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: jep-mcp-wrapper
3
+ Version: 0.1.1
4
+ Summary: Verifiable accountability wrapper for MCP tool execution
5
+ Author: JEP MCP Wrapper Contributors
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3.10
9
+ Classifier: Programming Language :: Python :: 3.11
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Requires-Python: >=3.10
12
+ Description-Content-Type: text/markdown
13
+ Requires-Dist: filelock<4,>=3.12
14
+
15
+ # jep-mcp-wrapper
16
+
17
+ `jep-mcp-wrapper` adds verifiable accountability semantics to MCP tool execution without changing the MCP protocol. Existing tool callables are wrapped in a side-channel JEP runtime that emits deterministic, append-only events for each execution lifecycle step.
18
+
19
+ ## What it provides
20
+
21
+ - `JEPMCPWrapper` wraps sync and async MCP tool callables.
22
+ - `MCPExecutionTracer` writes lifecycle events (`requested`, `running`, `succeeded`, `failed`).
23
+ - `ToolDelegationRuntime` tracks the active actor, delegation lineage, parent context, and authority scope across nested tool calls.
24
+ - `ReplayVerifier` replays archived execution chains, verifies lineage, validates deterministic hashes, and detects archive tampering.
25
+ - `AppendOnlyEventArchive` stores JSONL events as an append-only hash chain.
26
+
27
+ The wrapper records:
28
+
29
+ - `tool_name`
30
+ - `actor`
31
+ - delegation lineage
32
+ - authority scope
33
+ - execution state
34
+ - parent event linkage
35
+ - deterministic event hash and previous hash
36
+
37
+ ## Quick start
38
+
39
+ ```python
40
+ from pathlib import Path
41
+ from jep_mcp_wrapper import JEPMCPWrapper, ReplayVerifier
42
+
43
+ archive = "jep-events.jsonl"
44
+ wrapper = JEPMCPWrapper(
45
+ archive,
46
+ default_actor="agent:file-reader",
47
+ default_authority_scope={"filesystem": "read-only"},
48
+ )
49
+
50
+ def read_file(path: str) -> str:
51
+ return Path(path).read_text(encoding="utf-8")
52
+
53
+ read_file = wrapper.wrap_tool("filesystem.read_file", read_file)
54
+ print(read_file("README.md"))
55
+
56
+ replay = ReplayVerifier(archive).replay()
57
+ assert replay.verified
58
+ ```
59
+
60
+ ## Chained delegation
61
+
62
+ Nested wrapped calls automatically extend lineage. A `search.query` tool that calls a wrapped `browser.fetch` tool produces two execution chains: one with `("search.query",)` and one with `("search.query", "browser.fetch")`.
63
+
64
+ ```python
65
+ from jep_mcp_wrapper import JEPMCPWrapper
66
+
67
+ wrapper = JEPMCPWrapper("events.jsonl", default_actor="agent:researcher")
68
+
69
+ def browser_fetch(url: str) -> str:
70
+ return f"page:{url}"
71
+
72
+ def search(query: str, fetch) -> str:
73
+ return fetch(f"https://example.test?q={query}")
74
+
75
+ fetch = wrapper.wrap_tool("browser.fetch", browser_fetch, authority_scope={"network": "example.test"})
76
+ search = wrapper.wrap_tool("search.query", search, authority_scope={"purpose": "research"})
77
+ search("accountability", fetch=fetch)
78
+ ```
79
+
80
+ ## Replay and tamper detection
81
+
82
+ ```python
83
+ from jep_mcp_wrapper import ReplayVerifier
84
+
85
+ verifier = ReplayVerifier("events.jsonl")
86
+ result = verifier.replay()
87
+ print(result.verified)
88
+ print(result.lineage_by_call)
89
+ print(verifier.detect_tampering())
90
+ ```
91
+
92
+ Replay verification checks:
93
+
94
+ 1. deterministic hash equality for every event,
95
+ 2. previous-hash continuity across the append-only archive,
96
+ 3. monotonic event sequence numbers,
97
+ 4. valid tool lifecycle transitions,
98
+ 5. stable lineage for every tool call,
99
+ 6. parent/child lineage consistency when parent links are present.
100
+
101
+ ## Examples
102
+
103
+ - `examples/filesystem_tool.py` wraps a filesystem read tool.
104
+ - `examples/browser_search_chain.py` wraps browser and search tools with chained delegation.
105
+
106
+ ## Non-goals
107
+
108
+ - It does not modify MCP protocol schemas or wire semantics.
109
+ - It does not implement an orchestration framework.
110
+ - It does not decide whether a tool is authorized; it records the declared authority scope so execution can be audited and replayed.
@@ -0,0 +1,17 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/jep_mcp_wrapper/__init__.py
4
+ src/jep_mcp_wrapper/archive.py
5
+ src/jep_mcp_wrapper/events.py
6
+ src/jep_mcp_wrapper/runtime.py
7
+ src/jep_mcp_wrapper/tracer.py
8
+ src/jep_mcp_wrapper/verifier.py
9
+ src/jep_mcp_wrapper/wrapper.py
10
+ src/jep_mcp_wrapper.egg-info/PKG-INFO
11
+ src/jep_mcp_wrapper.egg-info/SOURCES.txt
12
+ src/jep_mcp_wrapper.egg-info/dependency_links.txt
13
+ src/jep_mcp_wrapper.egg-info/requires.txt
14
+ src/jep_mcp_wrapper.egg-info/top_level.txt
15
+ tests/test_archive_input.py
16
+ tests/test_hardening.py
17
+ tests/test_wrapper.py
@@ -0,0 +1 @@
1
+ filelock<4,>=3.12
@@ -0,0 +1 @@
1
+ jep_mcp_wrapper
@@ -0,0 +1,8 @@
1
+ import pytest
2
+ from jep_mcp_wrapper.archive import AppendOnlyEventArchive, ArchiveTamperError
3
+
4
+
5
+ def test_non_object_archive_record_is_reported_as_corruption(tmp_path):
6
+ path = tmp_path / "bad.jsonl"
7
+ path.write_text("null\n")
8
+ with pytest.raises(ArchiveTamperError): AppendOnlyEventArchive(path)
@@ -0,0 +1,45 @@
1
+ import asyncio
2
+ from concurrent.futures import ThreadPoolExecutor
3
+ import pytest
4
+ from jep_mcp_wrapper import JEPMCPWrapper
5
+ from jep_mcp_wrapper.archive import validate_events
6
+
7
+
8
+ def test_multiple_instances_append_to_one_valid_archive(tmp_path):
9
+ path = str(tmp_path / "events.jsonl")
10
+ wrappers = [JEPMCPWrapper(path) for _ in range(4)]
11
+ with ThreadPoolExecutor(max_workers=4) as pool:
12
+ assert list(pool.map(lambda w: w.call_tool("read", lambda: 42), wrappers)) == [42] * 4
13
+ events = wrappers[0].archive.read_events()
14
+ assert len(events) == 12
15
+ validate_events(events)
16
+
17
+
18
+ @pytest.mark.asyncio
19
+ async def test_cancelled_call_records_terminal_failure(tmp_path):
20
+ wrapper = JEPMCPWrapper(str(tmp_path / "events.jsonl"))
21
+ started = asyncio.Event()
22
+
23
+ async def tool():
24
+ started.set()
25
+ await asyncio.Event().wait()
26
+
27
+ task = asyncio.create_task(wrapper.call_tool_async("wait", tool))
28
+ await started.wait()
29
+ task.cancel()
30
+ with pytest.raises(asyncio.CancelledError):
31
+ await task
32
+ events = wrapper.archive.read_events()
33
+ assert events[-1].execution_state.value == "failed"
34
+ assert events[-1].metadata["error_type"] == "CancelledError"
35
+ validate_events(events)
36
+
37
+
38
+ def test_terminal_only_call_does_not_verify(tmp_path):
39
+ from jep_mcp_wrapper.events import ToolExecutionState
40
+ from jep_mcp_wrapper.verifier import ReplayVerifier
41
+ wrapper = JEPMCPWrapper(str(tmp_path / "events.jsonl"))
42
+ wrapper.tracer.record(tool_name="tool", state=ToolExecutionState.SUCCEEDED, call_id="orphan")
43
+ result = ReplayVerifier(wrapper.archive).replay()
44
+ assert result.verified is False
45
+ assert any("missing initial request" in error for error in result.errors)
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from pathlib import Path
6
+
7
+ import pytest
8
+
9
+ from jep_mcp_wrapper import JEPMCPWrapper, ReplayVerifier, ToolExecutionState
10
+
11
+
12
+ def test_wrap_tool_records_accountability_events(tmp_path: Path) -> None:
13
+ archive = tmp_path / "events.jsonl"
14
+ wrapper = JEPMCPWrapper(
15
+ archive,
16
+ default_actor="agent:test",
17
+ default_authority_scope={"filesystem": "read-only"},
18
+ return_trace=True,
19
+ )
20
+
21
+ def read_file(path: str) -> str:
22
+ return Path(path).read_text(encoding="utf-8")
23
+
24
+ target = tmp_path / "note.txt"
25
+ target.write_text("hello", encoding="utf-8")
26
+ result = wrapper.wrap_tool("filesystem.read_file", read_file)(str(target))
27
+
28
+ assert result.value == "hello"
29
+ replay = ReplayVerifier(archive).replay()
30
+ assert replay.verified, replay.errors
31
+ assert len(replay.events) == 3
32
+ assert [event.execution_state for event in replay.events] == [
33
+ ToolExecutionState.REQUESTED,
34
+ ToolExecutionState.RUNNING,
35
+ ToolExecutionState.SUCCEEDED,
36
+ ]
37
+ assert all(event.tool_name == "filesystem.read_file" for event in replay.events)
38
+ assert all(event.actor == "agent:test" for event in replay.events)
39
+ assert all(event.delegation_lineage == ("filesystem.read_file",) for event in replay.events)
40
+ assert all(event.authority_scope["filesystem"] == "read-only" for event in replay.events)
41
+
42
+
43
+ def test_chained_tool_delegation_replays_lineage(tmp_path: Path) -> None:
44
+ archive = tmp_path / "events.jsonl"
45
+ wrapper = JEPMCPWrapper(archive, default_actor="agent:researcher")
46
+
47
+ def browser_fetch(url: str) -> str:
48
+ return f"page:{url}"
49
+
50
+ def search(query: str, fetch) -> str:
51
+ return fetch(f"https://example.test?q={query}")
52
+
53
+ fetch = wrapper.wrap_tool("browser.fetch", browser_fetch, authority_scope={"network": "example.test"})
54
+ accountable_search = wrapper.wrap_tool("search.query", search, authority_scope={"purpose": "test"})
55
+
56
+ assert accountable_search("jep", fetch=fetch) == "page:https://example.test?q=jep"
57
+ replay = ReplayVerifier(archive).replay()
58
+
59
+ assert replay.verified, replay.errors
60
+ lineages = set(replay.lineage_by_call.values())
61
+ assert ("search.query",) in lineages
62
+ assert ("search.query", "browser.fetch") in lineages
63
+ assert ReplayVerifier(archive).verify_tool_lineage(("search.query",))
64
+
65
+
66
+ def test_failed_tool_records_failure_state(tmp_path: Path) -> None:
67
+ archive = tmp_path / "events.jsonl"
68
+ wrapper = JEPMCPWrapper(archive)
69
+
70
+ def broken() -> None:
71
+ raise ValueError("boom")
72
+
73
+ with pytest.raises(ValueError):
74
+ wrapper.call_tool("broken.tool", broken)
75
+
76
+ replay = ReplayVerifier(archive).replay()
77
+ assert replay.verified, replay.errors
78
+ assert replay.events[-1].execution_state == ToolExecutionState.FAILED
79
+ assert replay.events[-1].metadata["error_type"] == "ValueError"
80
+
81
+
82
+ def test_tampering_is_detected_by_deterministic_hash(tmp_path: Path) -> None:
83
+ archive = tmp_path / "events.jsonl"
84
+ wrapper = JEPMCPWrapper(archive)
85
+ wrapper.call_tool("search.query", lambda: "ok")
86
+
87
+ records = archive.read_text(encoding="utf-8").splitlines()
88
+ first = json.loads(records[0])
89
+ first["actor"] = "attacker"
90
+ records[0] = json.dumps(first, sort_keys=True)
91
+ archive.write_text("\n".join(records) + "\n", encoding="utf-8")
92
+
93
+ replay = ReplayVerifier(archive).replay()
94
+ assert not replay.verified
95
+ assert ReplayVerifier(archive).detect_tampering()
96
+ assert "tampering detected" in replay.errors[0]
97
+
98
+
99
+ def test_async_tool_is_wrapped(tmp_path: Path) -> None:
100
+ archive = tmp_path / "events.jsonl"
101
+ wrapper = JEPMCPWrapper(archive, default_actor="agent:async")
102
+
103
+ async def browser_fetch(url: str) -> str:
104
+ return f"async:{url}"
105
+
106
+ async def run_wrapped() -> str:
107
+ wrapped = wrapper.wrap_tool("browser.fetch", browser_fetch)
108
+ return await wrapped("https://example.test")
109
+
110
+ assert asyncio.run(run_wrapped()) == "async:https://example.test"
111
+
112
+ replay = ReplayVerifier(archive).replay()
113
+ assert replay.verified, replay.errors
114
+ assert all(event.actor == "agent:async" for event in replay.events)