weaver-kernel 0.4.0__tar.gz → 0.5.0__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.
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/.github/workflows/ci.yml +29 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/AGENTS.md +4 -1
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/CHANGELOG.md +7 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/PKG-INFO +16 -2
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/README.md +13 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/agent-context/invariants.md +4 -1
- weaver_kernel-0.5.0/docs/integrations.md +130 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/pyproject.toml +3 -2
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/__init__.py +2 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/drivers/__init__.py +2 -1
- weaver_kernel-0.5.0/src/agent_kernel/drivers/mcp.py +236 -0
- weaver_kernel-0.5.0/src/agent_kernel/drivers/mcp_support.py +154 -0
- weaver_kernel-0.5.0/tests/test_mcp_driver.py +298 -0
- weaver_kernel-0.4.0/docs/integrations.md +0 -70
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/.claude/CLAUDE.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/.github/copilot-instructions.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/.github/workflows/publish.yml +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/.gitignore +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/CONTRIBUTING.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/LICENSE +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/Makefile +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/RELEASE.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/agent-context/architecture.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/agent-context/lessons-learned.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/agent-context/review-checklist.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/agent-context/workflows.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/architecture.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/capabilities.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/context_firewall.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/docs/security.md +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/examples/basic_cli.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/examples/billing_demo.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/examples/http_driver_demo.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/drivers/base.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/drivers/http.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/drivers/memory.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/enums.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/errors.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/firewall/__init__.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/firewall/budgets.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/firewall/redaction.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/firewall/summarize.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/firewall/transform.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/handles.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/kernel.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/models.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/policy.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/py.typed +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/registry.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/router.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/tokens.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/src/agent_kernel/trace.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/conftest.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_drivers.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_firewall.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_handles.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_kernel.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_logging.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_models.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_policy.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_redaction.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_registry.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_router.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_tokens.py +0 -0
- {weaver_kernel-0.4.0 → weaver_kernel-0.5.0}/tests/test_trace.py +0 -0
|
@@ -45,3 +45,32 @@ jobs:
|
|
|
45
45
|
python examples/basic_cli.py
|
|
46
46
|
python examples/billing_demo.py
|
|
47
47
|
python examples/http_driver_demo.py
|
|
48
|
+
|
|
49
|
+
conformance_stub:
|
|
50
|
+
name: "Weaver Spec Conformance Stub (v0.1.0)"
|
|
51
|
+
runs-on: ubuntu-latest
|
|
52
|
+
needs: test
|
|
53
|
+
permissions:
|
|
54
|
+
contents: read
|
|
55
|
+
|
|
56
|
+
steps:
|
|
57
|
+
- uses: actions/checkout@v4
|
|
58
|
+
|
|
59
|
+
- name: Set up Python
|
|
60
|
+
uses: actions/setup-python@v5
|
|
61
|
+
with:
|
|
62
|
+
python-version: "3.12"
|
|
63
|
+
|
|
64
|
+
- name: Install dependencies
|
|
65
|
+
run: pip install -e ".[dev]"
|
|
66
|
+
|
|
67
|
+
# Placeholder: activate once dgenio/weaver-spec#4 ships the conformance runner.
|
|
68
|
+
# weaver-spec and weaver-contracts are published on PyPI.
|
|
69
|
+
# weaver_contracts.conformance does not yet exist (dgenio/weaver-spec#4).
|
|
70
|
+
# Replace this step with:
|
|
71
|
+
# pip install weaver-contracts # PyPI dist name uses a hyphen
|
|
72
|
+
# python -m weaver_contracts.conformance --target agent_kernel
|
|
73
|
+
- name: weaver-spec conformance suite (stub)
|
|
74
|
+
run: |
|
|
75
|
+
echo "weaver-contracts 0.2.0 is on PyPI; weaver_contracts.conformance runner not yet available (dgenio/weaver-spec#4)."
|
|
76
|
+
echo "Stub passes. Activate when dgenio/weaver-spec#4 ships."
|
|
@@ -25,9 +25,12 @@ agent-kernel is part of the **Weaver ecosystem**:
|
|
|
25
25
|
|
|
26
26
|
This repo must conform to weaver-spec invariants. Key invariants (all equally critical):
|
|
27
27
|
- **I-01**: Every tool output must pass through a context boundary before reaching the LLM.
|
|
28
|
-
- **I-02**:
|
|
28
|
+
- **I-02**: Every execution must be authorized and auditable (preceded by a policy decision, followed by a trace event).
|
|
29
29
|
- **I-06**: Tokens must bind principal + capability + constraints; no reuse across principals.
|
|
30
30
|
|
|
31
|
+
Note: Budget enforcement (size, depth, field count) is an agent-kernel implementation
|
|
32
|
+
constraint that satisfies I-01 — it is not a separate weaver-spec invariant number.
|
|
33
|
+
|
|
31
34
|
Full spec: [dgenio/weaver-spec](https://github.com/dgenio/weaver-spec)
|
|
32
35
|
|
|
33
36
|
## Domain vocabulary
|
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.5.0] - 2026-04-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Built-in `MCPDriver` with stdio and Streamable HTTP transports, tool auto-discovery, normalized MCP result handling, and optional dependency guardrails.
|
|
14
|
+
- Declared weaver-spec v0.1.0 compatibility in README: invariants I-01 (firewall), I-02 (authorization + audit), and I-06 (scoped tokens) are satisfied.
|
|
15
|
+
- Added placeholder `conformance_stub` CI job that will activate once the weaver-spec conformance suite ships (dgenio/weaver-spec#4).
|
|
16
|
+
|
|
10
17
|
## [0.4.0] - 2026-03-14
|
|
11
18
|
|
|
12
19
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: weaver-kernel
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: Capability-based security kernel for AI agents operating in large tool ecosystems
|
|
5
5
|
Project-URL: Homepage, https://github.com/dgenio/agent-kernel
|
|
6
6
|
Project-URL: Repository, https://github.com/dgenio/agent-kernel
|
|
@@ -223,13 +223,14 @@ Requires-Python: >=3.10
|
|
|
223
223
|
Requires-Dist: httpx>=0.27
|
|
224
224
|
Provides-Extra: dev
|
|
225
225
|
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
226
|
+
Requires-Dist: mcp>=1.6; extra == 'dev'
|
|
226
227
|
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
227
228
|
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
228
229
|
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
229
230
|
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
230
231
|
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
231
232
|
Provides-Extra: mcp
|
|
232
|
-
Requires-Dist: mcp>=1.
|
|
233
|
+
Requires-Dist: mcp>=1.6; extra == 'mcp'
|
|
233
234
|
Provides-Extra: otel
|
|
234
235
|
Requires-Dist: opentelemetry-api>=1.20; extra == 'otel'
|
|
235
236
|
Description-Content-Type: text/markdown
|
|
@@ -346,6 +347,19 @@ asyncio.run(main())
|
|
|
346
347
|
|
|
347
348
|
`agent-kernel` sits **above** `contextweaver` (context compilation) and **above** raw tool execution. It provides the authorization, execution, and audit layer.
|
|
348
349
|
|
|
350
|
+
## Weaver Spec Compatibility: v0.1.0
|
|
351
|
+
|
|
352
|
+
agent-kernel is a compliant implementation of [weaver-spec v0.1.0](https://github.com/dgenio/weaver-spec).
|
|
353
|
+
The following invariants are satisfied:
|
|
354
|
+
|
|
355
|
+
| Invariant | Description | How agent-kernel satisfies it |
|
|
356
|
+
|-----------|-------------|-------------------------------|
|
|
357
|
+
| **I-01** | LLM never sees raw tool output by default | `Context Firewall` always transforms `RawResult → Frame`; raw driver output is not returned by default, and non-admin principals cannot obtain `raw` response mode |
|
|
358
|
+
| **I-02** | Every execution is authorized and auditable | `PolicyEngine` authorizes at grant time; a valid `CapabilityToken` (HMAC-verified on every `invoke()`) carries the authorization decision; `TraceStore` records every `ActionTrace` |
|
|
359
|
+
| **I-06** | CapabilityTokens are scoped | Tokens bind `principal_id + capability_id + constraints` with an explicit TTL; `revoke(token_id)` / `revoke_all(principal_id)` are supported |
|
|
360
|
+
|
|
361
|
+
See [docs/agent-context/invariants.md](docs/agent-context/invariants.md) for the full internal invariant list and [weaver-spec INVARIANTS.md](https://github.com/dgenio/weaver-spec/blob/main/docs/INVARIANTS.md) for the specification.
|
|
362
|
+
|
|
349
363
|
## Security disclaimers
|
|
350
364
|
|
|
351
365
|
> **v0.1 is not production-hardened for real authentication.**
|
|
@@ -110,6 +110,19 @@ asyncio.run(main())
|
|
|
110
110
|
|
|
111
111
|
`agent-kernel` sits **above** `contextweaver` (context compilation) and **above** raw tool execution. It provides the authorization, execution, and audit layer.
|
|
112
112
|
|
|
113
|
+
## Weaver Spec Compatibility: v0.1.0
|
|
114
|
+
|
|
115
|
+
agent-kernel is a compliant implementation of [weaver-spec v0.1.0](https://github.com/dgenio/weaver-spec).
|
|
116
|
+
The following invariants are satisfied:
|
|
117
|
+
|
|
118
|
+
| Invariant | Description | How agent-kernel satisfies it |
|
|
119
|
+
|-----------|-------------|-------------------------------|
|
|
120
|
+
| **I-01** | LLM never sees raw tool output by default | `Context Firewall` always transforms `RawResult → Frame`; raw driver output is not returned by default, and non-admin principals cannot obtain `raw` response mode |
|
|
121
|
+
| **I-02** | Every execution is authorized and auditable | `PolicyEngine` authorizes at grant time; a valid `CapabilityToken` (HMAC-verified on every `invoke()`) carries the authorization decision; `TraceStore` records every `ActionTrace` |
|
|
122
|
+
| **I-06** | CapabilityTokens are scoped | Tokens bind `principal_id + capability_id + constraints` with an explicit TTL; `revoke(token_id)` / `revoke_all(principal_id)` are supported |
|
|
123
|
+
|
|
124
|
+
See [docs/agent-context/invariants.md](docs/agent-context/invariants.md) for the full internal invariant list and [weaver-spec INVARIANTS.md](https://github.com/dgenio/weaver-spec/blob/main/docs/INVARIANTS.md) for the specification.
|
|
125
|
+
|
|
113
126
|
## Security disclaimers
|
|
114
127
|
|
|
115
128
|
> **v0.1 is not production-hardened for real authentication.**
|
|
@@ -11,9 +11,12 @@ All three are equally critical — there is no priority ordering.
|
|
|
11
11
|
| Invariant | Requirement | Where enforced |
|
|
12
12
|
|-----------|-------------|----------------|
|
|
13
13
|
| **I-01** | Every tool output must pass through a context boundary before reaching the LLM | `Firewall.transform()` in `firewall/transform.py` |
|
|
14
|
-
| **I-02** |
|
|
14
|
+
| **I-02** | Every execution must be authorized and auditable (CapabilityToken validated before execution; TraceEvent recorded after) | `HMACTokenProvider.verify()` + `TraceStore.record()` in `kernel.py`; `PolicyEngine.evaluate()` at grant time in `grant_capability()` |
|
|
15
15
|
| **I-06** | Tokens must bind principal + capability + constraints; no reuse across principals | `HMACTokenProvider.verify()` in `tokens.py` |
|
|
16
16
|
|
|
17
|
+
> **Budget enforcement** (size, depth, field count via `Budgets` in `firewall/budgets.py`) is an
|
|
18
|
+
> implementation constraint that strengthens I-01. It has no separate invariant number in weaver-spec.
|
|
19
|
+
|
|
17
20
|
## Forbidden shortcuts — "never do" list
|
|
18
21
|
|
|
19
22
|
These constraints are non-negotiable. Violating any one silently degrades security.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# Integrations
|
|
2
|
+
|
|
3
|
+
## MCP (Model Context Protocol)
|
|
4
|
+
|
|
5
|
+
The built-in `MCPDriver` supports both local stdio servers and remote Streamable HTTP servers.
|
|
6
|
+
|
|
7
|
+
Install the optional dependency first:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install "weaver-kernel[mcp]"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Stdio transport
|
|
14
|
+
|
|
15
|
+
```python
|
|
16
|
+
import asyncio
|
|
17
|
+
|
|
18
|
+
from agent_kernel import CapabilityRegistry, Kernel, StaticRouter
|
|
19
|
+
from agent_kernel.drivers.mcp import MCPDriver
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def main() -> None:
|
|
23
|
+
registry = CapabilityRegistry()
|
|
24
|
+
router = StaticRouter(fallback=[])
|
|
25
|
+
kernel = Kernel(registry=registry, router=router)
|
|
26
|
+
|
|
27
|
+
# Connect to a local MCP server process.
|
|
28
|
+
driver = MCPDriver.from_stdio(
|
|
29
|
+
command="python",
|
|
30
|
+
args=["-m", "my_mcp_server"],
|
|
31
|
+
server_name="local-tools",
|
|
32
|
+
)
|
|
33
|
+
kernel.register_driver(driver)
|
|
34
|
+
|
|
35
|
+
# Discover tools and register them as capabilities.
|
|
36
|
+
capabilities = await driver.discover(namespace="local")
|
|
37
|
+
registry.register_many(capabilities)
|
|
38
|
+
|
|
39
|
+
# Route each discovered capability to this MCP driver.
|
|
40
|
+
for capability in capabilities:
|
|
41
|
+
router.add_route(capability.capability_id, [driver.driver_id])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
asyncio.run(main())
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Streamable HTTP transport
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
import asyncio
|
|
51
|
+
|
|
52
|
+
from agent_kernel import CapabilityRegistry, Kernel, StaticRouter
|
|
53
|
+
from agent_kernel.drivers.mcp import MCPDriver
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def main() -> None:
|
|
57
|
+
registry = CapabilityRegistry()
|
|
58
|
+
router = StaticRouter(fallback=[])
|
|
59
|
+
kernel = Kernel(registry=registry, router=router)
|
|
60
|
+
|
|
61
|
+
# Connect to a remote Streamable HTTP MCP server.
|
|
62
|
+
# Note: max_retries > 0 creates at-least-once delivery semantics for
|
|
63
|
+
# tools/call — if a connection drops after the server processes the
|
|
64
|
+
# request but before the response arrives, the call will be repeated.
|
|
65
|
+
# Ensure target tools are idempotent, or set max_retries=0 for
|
|
66
|
+
# WRITE/DESTRUCTIVE capabilities.
|
|
67
|
+
driver = MCPDriver.from_http(
|
|
68
|
+
url="https://example.com/mcp",
|
|
69
|
+
server_name="remote-tools",
|
|
70
|
+
max_retries=1,
|
|
71
|
+
)
|
|
72
|
+
kernel.register_driver(driver)
|
|
73
|
+
|
|
74
|
+
# Discover tools and register them as capabilities.
|
|
75
|
+
capabilities = await driver.discover(namespace="remote")
|
|
76
|
+
registry.register_many(capabilities)
|
|
77
|
+
|
|
78
|
+
# Route each discovered capability to this MCP driver.
|
|
79
|
+
for capability in capabilities:
|
|
80
|
+
router.add_route(capability.capability_id, [driver.driver_id])
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
asyncio.run(main())
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Notes
|
|
87
|
+
|
|
88
|
+
- `discover()` converts `tools/list` results into `Capability` objects.
|
|
89
|
+
- `execute()` calls `tools/call` and normalizes MCP content blocks for the firewall.
|
|
90
|
+
- MCP `isError` responses raise `DriverError` with the server-provided detail.
|
|
91
|
+
- If `mcp` is not installed, factory methods raise a helpful `ImportError`.
|
|
92
|
+
|
|
93
|
+
## HTTPDriver
|
|
94
|
+
|
|
95
|
+
The built-in `HTTPDriver` supports GET, POST, PUT, DELETE:
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from agent_kernel.drivers.http import HTTPDriver, HTTPEndpoint
|
|
99
|
+
|
|
100
|
+
driver = HTTPDriver(driver_id="my_api")
|
|
101
|
+
driver.register_endpoint("users.list", HTTPEndpoint(
|
|
102
|
+
url="https://api.example.com/users",
|
|
103
|
+
method="GET",
|
|
104
|
+
headers={"Authorization": "Bearer ..."},
|
|
105
|
+
))
|
|
106
|
+
kernel.register_driver(driver)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Custom drivers
|
|
110
|
+
|
|
111
|
+
Any object implementing the `Driver` protocol can be registered:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
class Driver(Protocol):
|
|
115
|
+
@property
|
|
116
|
+
def driver_id(self) -> str: ...
|
|
117
|
+
async def execute(self, ctx: ExecutionContext) -> RawResult: ...
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Capability mapping
|
|
121
|
+
|
|
122
|
+
When mapping MCP tools to capabilities, prefer task-shaped names:
|
|
123
|
+
|
|
124
|
+
| MCP tool | Capability ID | Safety class |
|
|
125
|
+
|----------|--------------|--------------|
|
|
126
|
+
| `list_files` | `fs.list_files` | READ |
|
|
127
|
+
| `read_file` | `fs.read_file` | READ |
|
|
128
|
+
| `write_file` | `fs.write_file` | WRITE |
|
|
129
|
+
| `delete_file` | `fs.delete_file` | DESTRUCTIVE |
|
|
130
|
+
| `execute_code` | `sandbox.run_code` | DESTRUCTIVE |
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "weaver-kernel"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.5.0"
|
|
8
8
|
description = "Capability-based security kernel for AI agents operating in large tool ecosystems"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
@@ -38,8 +38,9 @@ dev = [
|
|
|
38
38
|
"ruff>=0.4",
|
|
39
39
|
"mypy>=1.10",
|
|
40
40
|
"httpx>=0.27",
|
|
41
|
+
"mcp>=1.6",
|
|
41
42
|
]
|
|
42
|
-
mcp = ["mcp>=1.
|
|
43
|
+
mcp = ["mcp>=1.6"]
|
|
43
44
|
otel = ["opentelemetry-api>=1.20"]
|
|
44
45
|
|
|
45
46
|
[tool.hatch.build.targets.wheel]
|
|
@@ -37,6 +37,7 @@ Errors::
|
|
|
37
37
|
|
|
38
38
|
from .drivers.base import Driver, ExecutionContext
|
|
39
39
|
from .drivers.http import HTTPDriver
|
|
40
|
+
from .drivers.mcp import MCPDriver
|
|
40
41
|
from .drivers.memory import InMemoryDriver, make_billing_driver
|
|
41
42
|
from .enums import SafetyClass, SensitivityTag
|
|
42
43
|
from .errors import (
|
|
@@ -129,6 +130,7 @@ __all__ = [
|
|
|
129
130
|
"ExecutionContext",
|
|
130
131
|
"InMemoryDriver",
|
|
131
132
|
"HTTPDriver",
|
|
133
|
+
"MCPDriver",
|
|
132
134
|
"make_billing_driver",
|
|
133
135
|
# firewall
|
|
134
136
|
"Firewall",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from .base import Driver, ExecutionContext
|
|
4
4
|
from .http import HTTPDriver
|
|
5
|
+
from .mcp import MCPDriver
|
|
5
6
|
from .memory import InMemoryDriver
|
|
6
7
|
|
|
7
|
-
__all__ = ["Driver", "ExecutionContext", "HTTPDriver", "InMemoryDriver"]
|
|
8
|
+
__all__ = ["Driver", "ExecutionContext", "HTTPDriver", "MCPDriver", "InMemoryDriver"]
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""MCP driver: execute capabilities against Model Context Protocol servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..enums import SafetyClass
|
|
9
|
+
from ..errors import DriverError
|
|
10
|
+
from ..models import Capability, ImplementationRef, RawResult
|
|
11
|
+
from .base import ExecutionContext
|
|
12
|
+
from .mcp_support import (
|
|
13
|
+
SessionFactory,
|
|
14
|
+
ToolSpec,
|
|
15
|
+
build_http_session_factory,
|
|
16
|
+
build_stdio_session_factory,
|
|
17
|
+
call_tool,
|
|
18
|
+
extract_tool_specs,
|
|
19
|
+
normalize_call_result,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# Lazy import of McpError — only available when the mcp optional dep is installed.
|
|
23
|
+
# If mcp is absent, factory methods raise ImportError before any session is created,
|
|
24
|
+
# so _McpError will never be None on a live driver instance.
|
|
25
|
+
try:
|
|
26
|
+
from mcp.shared.exceptions import McpError as _McpError
|
|
27
|
+
except ImportError: # pragma: no cover
|
|
28
|
+
_McpError = None # type: ignore[assignment,misc]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _infer_safety_class(spec: ToolSpec) -> SafetyClass:
|
|
32
|
+
"""Infer a SafetyClass from MCP ToolAnnotations hints.
|
|
33
|
+
|
|
34
|
+
Uses a conservative default of READ when annotations are absent.
|
|
35
|
+
The caller's safety_class_map takes precedence over the inferred value.
|
|
36
|
+
"""
|
|
37
|
+
if spec.destructive_hint:
|
|
38
|
+
return SafetyClass.DESTRUCTIVE
|
|
39
|
+
if spec.read_only_hint:
|
|
40
|
+
return SafetyClass.READ
|
|
41
|
+
return SafetyClass.READ
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MCPDriver:
|
|
45
|
+
"""A driver that invokes capabilities via MCP tools/call."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
*,
|
|
50
|
+
driver_id: str,
|
|
51
|
+
session_factory: SessionFactory,
|
|
52
|
+
server_name: str,
|
|
53
|
+
transport: str,
|
|
54
|
+
max_http_retries: int = 1,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._driver_id = driver_id
|
|
57
|
+
self._session_factory = session_factory
|
|
58
|
+
self._server_name = server_name
|
|
59
|
+
self._transport = transport
|
|
60
|
+
self._max_http_retries = max(max_http_retries, 0)
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def driver_id(self) -> str:
|
|
64
|
+
"""Unique identifier for this driver instance."""
|
|
65
|
+
return self._driver_id
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_stdio(
|
|
69
|
+
cls,
|
|
70
|
+
command: str,
|
|
71
|
+
args: list[str] | None = None,
|
|
72
|
+
*,
|
|
73
|
+
server_name: str = "stdio",
|
|
74
|
+
) -> MCPDriver:
|
|
75
|
+
"""Create an MCP driver using stdio transport.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ImportError: If the optional ``mcp`` dependency is not installed.
|
|
79
|
+
"""
|
|
80
|
+
session_factory = build_stdio_session_factory(command=command, args=args or [])
|
|
81
|
+
return cls(
|
|
82
|
+
driver_id=f"mcp:{server_name}",
|
|
83
|
+
session_factory=session_factory,
|
|
84
|
+
server_name=server_name,
|
|
85
|
+
transport="stdio",
|
|
86
|
+
max_http_retries=0,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_http(
|
|
91
|
+
cls,
|
|
92
|
+
url: str,
|
|
93
|
+
*,
|
|
94
|
+
server_name: str = "http",
|
|
95
|
+
max_retries: int = 1,
|
|
96
|
+
) -> MCPDriver:
|
|
97
|
+
"""Create an MCP driver using Streamable HTTP transport.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
ImportError: If the optional ``mcp`` dependency is not installed.
|
|
101
|
+
"""
|
|
102
|
+
session_factory = build_http_session_factory(url=url)
|
|
103
|
+
return cls(
|
|
104
|
+
driver_id=f"mcp:{server_name}",
|
|
105
|
+
session_factory=session_factory,
|
|
106
|
+
server_name=server_name,
|
|
107
|
+
transport="http",
|
|
108
|
+
max_http_retries=max_retries,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
async def discover(
|
|
112
|
+
self,
|
|
113
|
+
*,
|
|
114
|
+
namespace: str | None = None,
|
|
115
|
+
safety_class_map: dict[str, SafetyClass] | None = None,
|
|
116
|
+
) -> list[Capability]:
|
|
117
|
+
"""Discover MCP tools across all pages and convert them to capabilities."""
|
|
118
|
+
tools = await self._run_with_retry(
|
|
119
|
+
operation_name="tools/list",
|
|
120
|
+
action=self._fetch_all_tools,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
capabilities: list[Capability] = []
|
|
124
|
+
for spec in extract_tool_specs(tools):
|
|
125
|
+
capability_id = f"{namespace}.{spec.name}" if namespace else spec.name
|
|
126
|
+
inferred = _infer_safety_class(spec)
|
|
127
|
+
safety_class = (
|
|
128
|
+
safety_class_map.get(spec.name, inferred)
|
|
129
|
+
if safety_class_map is not None
|
|
130
|
+
else inferred
|
|
131
|
+
)
|
|
132
|
+
capabilities.append(
|
|
133
|
+
Capability(
|
|
134
|
+
capability_id=capability_id,
|
|
135
|
+
name=spec.name,
|
|
136
|
+
description=spec.description,
|
|
137
|
+
safety_class=safety_class,
|
|
138
|
+
tags=["mcp", self._server_name],
|
|
139
|
+
impl=ImplementationRef(
|
|
140
|
+
driver_id=self._driver_id,
|
|
141
|
+
operation=spec.name,
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
return capabilities
|
|
146
|
+
|
|
147
|
+
async def _fetch_all_tools(self, session: Any) -> list[Any]:
|
|
148
|
+
"""Paginate tools/list to exhaustion and return a flat list of Tool objects."""
|
|
149
|
+
all_tools: list[Any] = []
|
|
150
|
+
cursor: str | None = None
|
|
151
|
+
while True:
|
|
152
|
+
result = await session.list_tools(cursor=cursor)
|
|
153
|
+
all_tools.extend(getattr(result, "tools", []) or [])
|
|
154
|
+
cursor = getattr(result, "nextCursor", None)
|
|
155
|
+
if not cursor:
|
|
156
|
+
break
|
|
157
|
+
return all_tools
|
|
158
|
+
|
|
159
|
+
async def execute(self, ctx: ExecutionContext) -> RawResult:
|
|
160
|
+
"""Execute an MCP tool call for the given capability context."""
|
|
161
|
+
operation = str(ctx.args.get("operation", ctx.capability_id))
|
|
162
|
+
params = {k: v for k, v in ctx.args.items() if k != "operation"}
|
|
163
|
+
|
|
164
|
+
# Apply policy constraints as default arguments, without overriding explicit args.
|
|
165
|
+
# read_timeout_seconds is an SDK control parameter — applied to the session call
|
|
166
|
+
# directly rather than forwarded to the tool as an argument.
|
|
167
|
+
read_timeout_seconds_raw = ctx.constraints.get("read_timeout_seconds")
|
|
168
|
+
for key, value in ctx.constraints.items():
|
|
169
|
+
if key != "read_timeout_seconds":
|
|
170
|
+
params.setdefault(key, value)
|
|
171
|
+
|
|
172
|
+
read_timeout_seconds: float | None = (
|
|
173
|
+
float(read_timeout_seconds_raw) if read_timeout_seconds_raw is not None else None
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
result = await self._run_with_retry(
|
|
177
|
+
operation_name=f"tools/call:{operation}",
|
|
178
|
+
action=lambda session: call_tool(
|
|
179
|
+
session,
|
|
180
|
+
operation=operation,
|
|
181
|
+
params=params,
|
|
182
|
+
read_timeout_seconds=read_timeout_seconds,
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
data = normalize_call_result(
|
|
187
|
+
result,
|
|
188
|
+
operation=operation,
|
|
189
|
+
driver_id=self._driver_id,
|
|
190
|
+
)
|
|
191
|
+
return RawResult(
|
|
192
|
+
capability_id=ctx.capability_id,
|
|
193
|
+
data=data,
|
|
194
|
+
metadata={
|
|
195
|
+
"driver_id": self._driver_id,
|
|
196
|
+
"transport": self._transport,
|
|
197
|
+
"operation": operation,
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
async def _run_with_retry(
|
|
202
|
+
self,
|
|
203
|
+
*,
|
|
204
|
+
operation_name: str,
|
|
205
|
+
action: Callable[[Any], Awaitable[Any]],
|
|
206
|
+
) -> Any:
|
|
207
|
+
attempts = 1 + self._max_http_retries if self._transport == "http" else 1
|
|
208
|
+
last_exc: Exception | None = None
|
|
209
|
+
|
|
210
|
+
for _attempt in range(attempts):
|
|
211
|
+
try:
|
|
212
|
+
async with self._session_factory() as session:
|
|
213
|
+
return await action(session)
|
|
214
|
+
except DriverError:
|
|
215
|
+
raise
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
# McpError is a protocol-level rejection (tool not found, auth
|
|
218
|
+
# failure, invalid params) — the server processed and rejected the
|
|
219
|
+
# request. It is not retryable; surface it immediately as DriverError.
|
|
220
|
+
if _McpError is not None and isinstance(exc, _McpError):
|
|
221
|
+
raise DriverError(
|
|
222
|
+
f"MCPDriver '{self._driver_id}' received a protocol error "
|
|
223
|
+
f"during {operation_name}: {exc}"
|
|
224
|
+
) from exc
|
|
225
|
+
# All other exceptions are session/transport failures (connection
|
|
226
|
+
# refused, EOF, timeout) and are retryable for HTTP transport.
|
|
227
|
+
# Note: HTTP retries create at-least-once delivery semantics for
|
|
228
|
+
# tools/call. Callers using WRITE/DESTRUCTIVE capabilities over HTTP
|
|
229
|
+
# should ensure the target tool is idempotent, or set max_retries=0.
|
|
230
|
+
last_exc = exc
|
|
231
|
+
|
|
232
|
+
reason = str(last_exc) if last_exc is not None else "unknown transport failure"
|
|
233
|
+
raise DriverError(
|
|
234
|
+
f"MCPDriver '{self._driver_id}' failed during {operation_name} over "
|
|
235
|
+
f"{self._transport}: {reason}"
|
|
236
|
+
) from last_exc
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Internal helpers for MCP transport wiring and result normalization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from collections.abc import AsyncIterator, Callable
|
|
7
|
+
from contextlib import AbstractAsyncContextManager, asynccontextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from ..errors import DriverError
|
|
13
|
+
|
|
14
|
+
SessionFactory = Callable[[], AbstractAsyncContextManager[Any]]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(slots=True)
|
|
18
|
+
class ToolSpec:
|
|
19
|
+
"""Normalized MCP tool metadata for capability conversion."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
description: str
|
|
23
|
+
read_only_hint: bool = False
|
|
24
|
+
destructive_hint: bool = False
|
|
25
|
+
idempotent_hint: bool = False
|
|
26
|
+
output_schema: dict[str, Any] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
async def call_tool(
|
|
30
|
+
session: Any,
|
|
31
|
+
*,
|
|
32
|
+
operation: str,
|
|
33
|
+
params: dict[str, Any],
|
|
34
|
+
read_timeout_seconds: float | None = None,
|
|
35
|
+
) -> Any:
|
|
36
|
+
"""Call an MCP tool via tools/call."""
|
|
37
|
+
timeout = timedelta(seconds=read_timeout_seconds) if read_timeout_seconds is not None else None
|
|
38
|
+
return await session.call_tool(operation, arguments=params, read_timeout_seconds=timeout)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def extract_tool_specs(tools: list[Any]) -> list[ToolSpec]:
|
|
42
|
+
"""Extract tool metadata from a flat list of MCP Tool objects."""
|
|
43
|
+
if not isinstance(tools, list):
|
|
44
|
+
return []
|
|
45
|
+
specs: list[ToolSpec] = []
|
|
46
|
+
for tool in tools:
|
|
47
|
+
name = getattr(tool, "name", None)
|
|
48
|
+
if not isinstance(name, str) or not name:
|
|
49
|
+
continue
|
|
50
|
+
ann = getattr(tool, "annotations", None)
|
|
51
|
+
specs.append(
|
|
52
|
+
ToolSpec(
|
|
53
|
+
name=name,
|
|
54
|
+
description=str(getattr(tool, "description", "") or ""),
|
|
55
|
+
read_only_hint=bool(getattr(ann, "readOnlyHint", False)),
|
|
56
|
+
destructive_hint=bool(getattr(ann, "destructiveHint", False)),
|
|
57
|
+
idempotent_hint=bool(getattr(ann, "idempotentHint", False)),
|
|
58
|
+
output_schema=getattr(tool, "outputSchema", None),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return specs
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def normalize_call_result(result: Any, *, operation: str, driver_id: str) -> Any:
|
|
65
|
+
"""Normalize an MCP CallToolResult into plain Python data."""
|
|
66
|
+
is_error = bool(getattr(result, "isError", False))
|
|
67
|
+
content = [_normalize_content_item(c) for c in (getattr(result, "content", None) or [])]
|
|
68
|
+
|
|
69
|
+
if is_error:
|
|
70
|
+
detail = next(
|
|
71
|
+
(b["text"] for b in content if b.get("type") == "text" and b.get("text", "").strip()),
|
|
72
|
+
"MCP server returned isError=true",
|
|
73
|
+
)
|
|
74
|
+
raise DriverError(
|
|
75
|
+
f"MCPDriver '{driver_id}' tool '{operation}' returned an error: {detail}"
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
structured: dict[str, Any] | None = getattr(result, "structuredContent", None)
|
|
79
|
+
if structured is not None and content:
|
|
80
|
+
return {"structured_content": structured, "content": content}
|
|
81
|
+
if structured is not None:
|
|
82
|
+
return structured
|
|
83
|
+
return content
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def import_optional(module_name: str) -> Any:
|
|
87
|
+
"""Import optional MCP SDK module with a consistent guidance message."""
|
|
88
|
+
try:
|
|
89
|
+
return importlib.import_module(module_name)
|
|
90
|
+
except ModuleNotFoundError as exc:
|
|
91
|
+
raise ImportError(
|
|
92
|
+
"MCP support requires the optional dependency 'mcp>=1.0'. "
|
|
93
|
+
"Install it with: pip install 'weaver-kernel[mcp]'"
|
|
94
|
+
) from exc
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def build_stdio_session_factory(*, command: str, args: list[str]) -> SessionFactory:
|
|
98
|
+
"""Build a stdio-backed MCP session factory."""
|
|
99
|
+
stdio_mod = import_optional("mcp.client.stdio")
|
|
100
|
+
session_mod = import_optional("mcp.client.session")
|
|
101
|
+
|
|
102
|
+
stdio_client = stdio_mod.stdio_client
|
|
103
|
+
server_params_cls = stdio_mod.StdioServerParameters
|
|
104
|
+
session_cls = session_mod.ClientSession
|
|
105
|
+
|
|
106
|
+
@asynccontextmanager
|
|
107
|
+
async def factory() -> AsyncIterator[Any]:
|
|
108
|
+
params = server_params_cls(command=command, args=args)
|
|
109
|
+
async with stdio_client(params) as streams:
|
|
110
|
+
read_stream, write_stream = streams
|
|
111
|
+
async with session_cls(read_stream, write_stream) as session:
|
|
112
|
+
await session.initialize()
|
|
113
|
+
yield session
|
|
114
|
+
|
|
115
|
+
return factory
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def build_http_session_factory(*, url: str) -> SessionFactory:
|
|
119
|
+
"""Build a Streamable HTTP-backed MCP session factory."""
|
|
120
|
+
streamable_mod = import_optional("mcp.client.streamable_http")
|
|
121
|
+
session_mod = import_optional("mcp.client.session")
|
|
122
|
+
|
|
123
|
+
streamable_http_client = streamable_mod.streamable_http_client
|
|
124
|
+
session_cls = session_mod.ClientSession
|
|
125
|
+
|
|
126
|
+
@asynccontextmanager
|
|
127
|
+
async def factory() -> AsyncIterator[Any]:
|
|
128
|
+
async with streamable_http_client(url) as streams:
|
|
129
|
+
read_stream, write_stream = streams
|
|
130
|
+
async with session_cls(read_stream, write_stream) as session:
|
|
131
|
+
await session.initialize()
|
|
132
|
+
yield session
|
|
133
|
+
|
|
134
|
+
return factory
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _normalize_content_item(item: Any) -> dict[str, Any]:
|
|
138
|
+
item_type = str(getattr(item, "type", "")).lower()
|
|
139
|
+
if item_type == "text":
|
|
140
|
+
return {"type": "text", "text": str(getattr(item, "text", ""))}
|
|
141
|
+
if item_type == "image":
|
|
142
|
+
return {
|
|
143
|
+
"type": "image",
|
|
144
|
+
"data": getattr(item, "data", None),
|
|
145
|
+
"mime_type": getattr(item, "mimeType", None),
|
|
146
|
+
}
|
|
147
|
+
if item_type in {"resource", "resourcelink"}:
|
|
148
|
+
resource = getattr(item, "resource", item)
|
|
149
|
+
return {
|
|
150
|
+
"type": "resource",
|
|
151
|
+
"resource": resource.model_dump() if hasattr(resource, "model_dump") else resource,
|
|
152
|
+
}
|
|
153
|
+
# AudioContent or any future type - fall back to model_dump
|
|
154
|
+
return item.model_dump() if hasattr(item, "model_dump") else {"type": "value", "value": item}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Tests for the built-in MCPDriver."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
7
|
+
from typing import Any
|
|
8
|
+
from unittest.mock import patch
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from mcp.types import CallToolResult, ListToolsResult, TextContent, Tool
|
|
12
|
+
|
|
13
|
+
from agent_kernel import (
|
|
14
|
+
CapabilityRegistry,
|
|
15
|
+
CapabilityRequest,
|
|
16
|
+
DriverError,
|
|
17
|
+
Kernel,
|
|
18
|
+
MCPDriver,
|
|
19
|
+
Principal,
|
|
20
|
+
SafetyClass,
|
|
21
|
+
StaticRouter,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _FakeSession:
|
|
26
|
+
"""Small async session stub for MCPDriver tests."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
tools: list[Tool] | None = None,
|
|
32
|
+
call_result: CallToolResult | None = None,
|
|
33
|
+
call_error: Exception | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
self._tools = tools or []
|
|
36
|
+
self._call_result = call_result or CallToolResult(content=[])
|
|
37
|
+
self._call_error = call_error
|
|
38
|
+
self.calls: list[tuple[str, dict[str, Any]]] = []
|
|
39
|
+
|
|
40
|
+
async def list_tools(self, cursor: str | None = None) -> ListToolsResult:
|
|
41
|
+
return ListToolsResult(tools=self._tools)
|
|
42
|
+
|
|
43
|
+
async def call_tool(
|
|
44
|
+
self,
|
|
45
|
+
operation: str,
|
|
46
|
+
arguments: dict[str, Any],
|
|
47
|
+
read_timeout_seconds: Any = None,
|
|
48
|
+
) -> CallToolResult:
|
|
49
|
+
self.calls.append((operation, arguments))
|
|
50
|
+
if self._call_error is not None:
|
|
51
|
+
raise self._call_error
|
|
52
|
+
return self._call_result
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _reusable_factory(session: _FakeSession) -> Any:
|
|
56
|
+
@asynccontextmanager
|
|
57
|
+
async def _factory() -> AsyncIterator[_FakeSession]:
|
|
58
|
+
yield session
|
|
59
|
+
|
|
60
|
+
return _factory
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _sequence_factory(sessions: list[_FakeSession]) -> Any:
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def _factory() -> AsyncIterator[_FakeSession]:
|
|
66
|
+
if not sessions:
|
|
67
|
+
raise RuntimeError("no fake sessions left")
|
|
68
|
+
yield sessions.pop(0)
|
|
69
|
+
|
|
70
|
+
return _factory
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_from_stdio_missing_dependency_raises_helpful_import_error() -> None:
|
|
74
|
+
with patch("agent_kernel.drivers.mcp_support.importlib.import_module") as import_module:
|
|
75
|
+
import_module.side_effect = ModuleNotFoundError("No module named 'mcp'")
|
|
76
|
+
with pytest.raises(ImportError, match=r"weaver-kernel\[mcp\]"):
|
|
77
|
+
MCPDriver.from_stdio("python", ["server.py"])
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_from_http_missing_dependency_raises_helpful_import_error() -> None:
|
|
81
|
+
with patch("agent_kernel.drivers.mcp_support.importlib.import_module") as import_module:
|
|
82
|
+
import_module.side_effect = ModuleNotFoundError("No module named 'mcp'")
|
|
83
|
+
with pytest.raises(ImportError, match=r"weaver-kernel\[mcp\]"):
|
|
84
|
+
MCPDriver.from_http("http://localhost:8080/mcp")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_discover_converts_tools_to_capabilities() -> None:
|
|
89
|
+
session = _FakeSession(
|
|
90
|
+
tools=[
|
|
91
|
+
Tool(name="list_files", description="List files", inputSchema={}),
|
|
92
|
+
Tool(name="write_file", description="Write file", inputSchema={}),
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
driver = MCPDriver(
|
|
96
|
+
driver_id="mcp:local",
|
|
97
|
+
session_factory=_reusable_factory(session),
|
|
98
|
+
server_name="local",
|
|
99
|
+
transport="stdio",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
capabilities = await driver.discover(
|
|
103
|
+
namespace="fs", safety_class_map={"write_file": SafetyClass.WRITE}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
assert [cap.capability_id for cap in capabilities] == [
|
|
107
|
+
"fs.list_files",
|
|
108
|
+
"fs.write_file",
|
|
109
|
+
]
|
|
110
|
+
assert capabilities[0].safety_class == SafetyClass.READ
|
|
111
|
+
assert capabilities[1].safety_class == SafetyClass.WRITE
|
|
112
|
+
assert capabilities[0].impl is not None
|
|
113
|
+
assert capabilities[0].impl.driver_id == "mcp:local"
|
|
114
|
+
assert capabilities[0].impl.operation == "list_files"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.asyncio
|
|
118
|
+
async def test_execute_calls_tool_and_applies_constraints_defaults() -> None:
|
|
119
|
+
session = _FakeSession(
|
|
120
|
+
call_result=CallToolResult(content=[TextContent(type="text", text="ok")])
|
|
121
|
+
)
|
|
122
|
+
driver = MCPDriver(
|
|
123
|
+
driver_id="mcp:local",
|
|
124
|
+
session_factory=_reusable_factory(session),
|
|
125
|
+
server_name="local",
|
|
126
|
+
transport="stdio",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
from agent_kernel.drivers.base import ExecutionContext
|
|
130
|
+
|
|
131
|
+
ctx = ExecutionContext(
|
|
132
|
+
capability_id="fs.list_files",
|
|
133
|
+
principal_id="u1",
|
|
134
|
+
args={"operation": "list_files", "path": "/tmp", "max_rows": 5},
|
|
135
|
+
constraints={"max_rows": 2, "allowed_fields": ["name"]},
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
result = await driver.execute(ctx)
|
|
139
|
+
|
|
140
|
+
assert result.capability_id == "fs.list_files"
|
|
141
|
+
assert result.data == [{"type": "text", "text": "ok"}]
|
|
142
|
+
assert session.calls[0][0] == "list_files"
|
|
143
|
+
# Explicit args are preserved; missing constraints are merged in.
|
|
144
|
+
assert session.calls[0][1]["max_rows"] == 5
|
|
145
|
+
assert session.calls[0][1]["allowed_fields"] == ["name"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_execute_prefers_structured_content_when_available() -> None:
|
|
150
|
+
session = _FakeSession(
|
|
151
|
+
call_result=CallToolResult(
|
|
152
|
+
structuredContent={"total": 3},
|
|
153
|
+
content=[TextContent(type="text", text="computed")],
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
driver = MCPDriver(
|
|
157
|
+
driver_id="mcp:local",
|
|
158
|
+
session_factory=_reusable_factory(session),
|
|
159
|
+
server_name="local",
|
|
160
|
+
transport="stdio",
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
from agent_kernel.drivers.base import ExecutionContext
|
|
164
|
+
|
|
165
|
+
ctx = ExecutionContext(capability_id="math.sum", principal_id="u1")
|
|
166
|
+
result = await driver.execute(ctx)
|
|
167
|
+
|
|
168
|
+
assert result.data == {
|
|
169
|
+
"structured_content": {"total": 3},
|
|
170
|
+
"content": [{"type": "text", "text": "computed"}],
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.mark.asyncio
|
|
175
|
+
async def test_execute_raises_driver_error_on_mcp_is_error() -> None:
|
|
176
|
+
session = _FakeSession(
|
|
177
|
+
call_result=CallToolResult(
|
|
178
|
+
isError=True,
|
|
179
|
+
content=[TextContent(type="text", text="permission denied")],
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
driver = MCPDriver(
|
|
183
|
+
driver_id="mcp:local",
|
|
184
|
+
session_factory=_reusable_factory(session),
|
|
185
|
+
server_name="local",
|
|
186
|
+
transport="stdio",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
from agent_kernel.drivers.base import ExecutionContext
|
|
190
|
+
|
|
191
|
+
ctx = ExecutionContext(capability_id="secrets.read", principal_id="u1")
|
|
192
|
+
with pytest.raises(DriverError, match="permission denied"):
|
|
193
|
+
await driver.execute(ctx)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@pytest.mark.asyncio
|
|
197
|
+
async def test_http_transport_retries_after_connection_drop() -> None:
|
|
198
|
+
first = _FakeSession(call_error=RuntimeError("connection dropped"))
|
|
199
|
+
second = _FakeSession(
|
|
200
|
+
call_result=CallToolResult(content=[TextContent(type="text", text="ok")])
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
driver = MCPDriver(
|
|
204
|
+
driver_id="mcp:http",
|
|
205
|
+
session_factory=_sequence_factory([first, second]),
|
|
206
|
+
server_name="remote",
|
|
207
|
+
transport="http",
|
|
208
|
+
max_http_retries=1,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
from agent_kernel.drivers.base import ExecutionContext
|
|
212
|
+
|
|
213
|
+
ctx = ExecutionContext(capability_id="echo", principal_id="u1")
|
|
214
|
+
result = await driver.execute(ctx)
|
|
215
|
+
|
|
216
|
+
assert result.data == [{"type": "text", "text": "ok"}]
|
|
217
|
+
assert len(first.calls) == 1
|
|
218
|
+
assert len(second.calls) == 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@pytest.mark.asyncio
|
|
222
|
+
async def test_kernel_pipeline_with_discover_register_grant_invoke() -> None:
|
|
223
|
+
session = _FakeSession(
|
|
224
|
+
tools=[Tool(name="math.sum", description="Sum two values", inputSchema={})],
|
|
225
|
+
call_result=CallToolResult(structuredContent={"total": 3}, content=[]),
|
|
226
|
+
)
|
|
227
|
+
driver = MCPDriver(
|
|
228
|
+
driver_id="mcp:demo",
|
|
229
|
+
session_factory=_reusable_factory(session),
|
|
230
|
+
server_name="demo",
|
|
231
|
+
transport="stdio",
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
capabilities = await driver.discover()
|
|
235
|
+
registry = CapabilityRegistry()
|
|
236
|
+
registry.register_many(capabilities)
|
|
237
|
+
|
|
238
|
+
router = StaticRouter(routes={"math.sum": ["mcp:demo"]}, fallback=[])
|
|
239
|
+
kernel = Kernel(registry=registry, router=router)
|
|
240
|
+
kernel.register_driver(driver)
|
|
241
|
+
|
|
242
|
+
principal = Principal(principal_id="u1", roles=["reader"])
|
|
243
|
+
request = CapabilityRequest(capability_id="math.sum", goal="sum numbers")
|
|
244
|
+
|
|
245
|
+
token = kernel.get_token(request, principal, justification="")
|
|
246
|
+
frame = await kernel.invoke(
|
|
247
|
+
token,
|
|
248
|
+
principal=principal,
|
|
249
|
+
args={"operation": "math.sum", "a": 1, "b": 2},
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
assert frame.response_mode == "summary"
|
|
253
|
+
assert any("total" in fact.lower() for fact in frame.facts)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.asyncio
|
|
257
|
+
async def test_real_fastmcp_in_process_discover_and_execute() -> None:
|
|
258
|
+
"""Full discover→execute cycle driven by a real FastMCP server in-process."""
|
|
259
|
+
from mcp.client.session import ClientSession
|
|
260
|
+
from mcp.server.fastmcp import FastMCP
|
|
261
|
+
from mcp.shared.memory import create_connected_server_and_client_session
|
|
262
|
+
|
|
263
|
+
mcp_srv = FastMCP("math")
|
|
264
|
+
|
|
265
|
+
@mcp_srv.tool()
|
|
266
|
+
def add(a: int, b: int) -> int:
|
|
267
|
+
"""Add two integers."""
|
|
268
|
+
return a + b
|
|
269
|
+
|
|
270
|
+
@asynccontextmanager
|
|
271
|
+
async def in_memory_factory() -> AsyncIterator[ClientSession]:
|
|
272
|
+
async with create_connected_server_and_client_session(mcp_srv) as session:
|
|
273
|
+
yield session
|
|
274
|
+
|
|
275
|
+
driver = MCPDriver(
|
|
276
|
+
driver_id="mcp:math",
|
|
277
|
+
session_factory=in_memory_factory,
|
|
278
|
+
server_name="math",
|
|
279
|
+
transport="stdio",
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
capabilities = await driver.discover(namespace="math")
|
|
283
|
+
assert any(cap.capability_id == "math.add" for cap in capabilities)
|
|
284
|
+
add_cap = next(c for c in capabilities if c.capability_id == "math.add")
|
|
285
|
+
assert add_cap.impl is not None
|
|
286
|
+
assert add_cap.impl.operation == "add"
|
|
287
|
+
|
|
288
|
+
from agent_kernel.drivers.base import ExecutionContext
|
|
289
|
+
|
|
290
|
+
ctx = ExecutionContext(
|
|
291
|
+
capability_id="math.add",
|
|
292
|
+
principal_id="u1",
|
|
293
|
+
args={"operation": "add", "a": 3, "b": 4},
|
|
294
|
+
)
|
|
295
|
+
result = await driver.execute(ctx)
|
|
296
|
+
assert isinstance(result.data, dict)
|
|
297
|
+
assert result.data["structured_content"]["result"] == 7
|
|
298
|
+
assert result.data["content"][0]["text"] == "7"
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# Integrations
|
|
2
|
-
|
|
3
|
-
## MCP (Model Context Protocol)
|
|
4
|
-
|
|
5
|
-
To integrate with an MCP server, implement a custom driver that wraps the MCP client:
|
|
6
|
-
|
|
7
|
-
```python
|
|
8
|
-
from agent_kernel.drivers.base import Driver, ExecutionContext
|
|
9
|
-
from agent_kernel.models import RawResult
|
|
10
|
-
|
|
11
|
-
class MCPDriver:
|
|
12
|
-
def __init__(self, mcp_client, driver_id: str = "mcp"):
|
|
13
|
-
self._client = mcp_client
|
|
14
|
-
self._driver_id = driver_id
|
|
15
|
-
|
|
16
|
-
@property
|
|
17
|
-
def driver_id(self) -> str:
|
|
18
|
-
return self._driver_id
|
|
19
|
-
|
|
20
|
-
async def execute(self, ctx: ExecutionContext) -> RawResult:
|
|
21
|
-
operation = ctx.args.get("operation", ctx.capability_id)
|
|
22
|
-
result = await self._client.call_tool(operation, ctx.args)
|
|
23
|
-
return RawResult(capability_id=ctx.capability_id, data=result)
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
Then register it:
|
|
27
|
-
|
|
28
|
-
```python
|
|
29
|
-
kernel.register_driver(MCPDriver(mcp_client))
|
|
30
|
-
router.add_route("mcp.my_tool", ["mcp"])
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
## HTTPDriver
|
|
34
|
-
|
|
35
|
-
The built-in `HTTPDriver` supports GET, POST, PUT, DELETE:
|
|
36
|
-
|
|
37
|
-
```python
|
|
38
|
-
from agent_kernel.drivers.http import HTTPDriver, HTTPEndpoint
|
|
39
|
-
|
|
40
|
-
driver = HTTPDriver(driver_id="my_api")
|
|
41
|
-
driver.register_endpoint("users.list", HTTPEndpoint(
|
|
42
|
-
url="https://api.example.com/users",
|
|
43
|
-
method="GET",
|
|
44
|
-
headers={"Authorization": "Bearer ..."},
|
|
45
|
-
))
|
|
46
|
-
kernel.register_driver(driver)
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
## Custom drivers
|
|
50
|
-
|
|
51
|
-
Any object implementing the `Driver` protocol can be registered:
|
|
52
|
-
|
|
53
|
-
```python
|
|
54
|
-
class Driver(Protocol):
|
|
55
|
-
@property
|
|
56
|
-
def driver_id(self) -> str: ...
|
|
57
|
-
async def execute(self, ctx: ExecutionContext) -> RawResult: ...
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Capability mapping
|
|
61
|
-
|
|
62
|
-
When mapping MCP tools to capabilities, prefer task-shaped names:
|
|
63
|
-
|
|
64
|
-
| MCP tool | Capability ID | Safety class |
|
|
65
|
-
|----------|--------------|--------------|
|
|
66
|
-
| `list_files` | `fs.list_files` | READ |
|
|
67
|
-
| `read_file` | `fs.read_file` | READ |
|
|
68
|
-
| `write_file` | `fs.write_file` | WRITE |
|
|
69
|
-
| `delete_file` | `fs.delete_file` | DESTRUCTIVE |
|
|
70
|
-
| `execute_code` | `sandbox.run_code` | DESTRUCTIVE |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|