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.
- jep_mcp_wrapper-0.1.1/PKG-INFO +110 -0
- jep_mcp_wrapper-0.1.1/README.md +96 -0
- jep_mcp_wrapper-0.1.1/pyproject.toml +26 -0
- jep_mcp_wrapper-0.1.1/setup.cfg +4 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/__init__.py +26 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/archive.py +111 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/events.py +152 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/runtime.py +85 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/tracer.py +41 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/verifier.py +117 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper/wrapper.py +189 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper.egg-info/PKG-INFO +110 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper.egg-info/SOURCES.txt +17 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper.egg-info/dependency_links.txt +1 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper.egg-info/requires.txt +1 -0
- jep_mcp_wrapper-0.1.1/src/jep_mcp_wrapper.egg-info/top_level.txt +1 -0
- jep_mcp_wrapper-0.1.1/tests/test_archive_input.py +8 -0
- jep_mcp_wrapper-0.1.1/tests/test_hardening.py +45 -0
- jep_mcp_wrapper-0.1.1/tests/test_wrapper.py +114 -0
|
@@ -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,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
|
+
|
|
@@ -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)
|