aigp-mcp 1.0.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.
- aigp_mcp-1.0.0/.gitignore +56 -0
- aigp_mcp-1.0.0/PKG-INFO +76 -0
- aigp_mcp-1.0.0/README.md +61 -0
- aigp_mcp-1.0.0/SPEC.md +340 -0
- aigp_mcp-1.0.0/aigp_mcp/__init__.py +33 -0
- aigp_mcp-1.0.0/aigp_mcp/client.py +222 -0
- aigp_mcp-1.0.0/aigp_mcp/config.py +59 -0
- aigp_mcp-1.0.0/aigp_mcp/evidence.py +267 -0
- aigp_mcp-1.0.0/aigp_mcp/models.py +66 -0
- aigp_mcp-1.0.0/aigp_mcp/scope.py +46 -0
- aigp_mcp-1.0.0/pyproject.toml +20 -0
- aigp_mcp-1.0.0/tests/__init__.py +0 -0
- aigp_mcp-1.0.0/tests/test_scope.py +53 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
dist/
|
|
6
|
+
build/
|
|
7
|
+
.venv/
|
|
8
|
+
.eggs/
|
|
9
|
+
|
|
10
|
+
# Node
|
|
11
|
+
node_modules/
|
|
12
|
+
dist/
|
|
13
|
+
|
|
14
|
+
# Go
|
|
15
|
+
bin/
|
|
16
|
+
|
|
17
|
+
# Rust
|
|
18
|
+
target/
|
|
19
|
+
|
|
20
|
+
# .NET
|
|
21
|
+
bin/
|
|
22
|
+
obj/
|
|
23
|
+
|
|
24
|
+
# Java
|
|
25
|
+
*.class
|
|
26
|
+
out/
|
|
27
|
+
|
|
28
|
+
# IDE
|
|
29
|
+
.idea/
|
|
30
|
+
.vscode/
|
|
31
|
+
.kiro/
|
|
32
|
+
*.swp
|
|
33
|
+
|
|
34
|
+
# OS
|
|
35
|
+
.DS_Store
|
|
36
|
+
Thumbs.db
|
|
37
|
+
|
|
38
|
+
# Secrets (never commit)
|
|
39
|
+
.env
|
|
40
|
+
*.pem
|
|
41
|
+
*.key
|
|
42
|
+
*.pdf
|
|
43
|
+
BACKLOG/
|
|
44
|
+
patent/
|
|
45
|
+
docs/.archive/
|
|
46
|
+
review/
|
|
47
|
+
|
|
48
|
+
# Generated navigable spec split — derived from the monoliths by
|
|
49
|
+
# specification/tools/spec_split.py; the manifest + monoliths are the
|
|
50
|
+
# source of truth, not this output. Same convention as Mars (spec/*/*/).
|
|
51
|
+
specification/*/*/
|
|
52
|
+
specification/split-index.md
|
|
53
|
+
|
|
54
|
+
# Local rendering/preview site — self-contained, not part of the
|
|
55
|
+
# published specification, never pushed.
|
|
56
|
+
site/
|
aigp_mcp-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: aigp-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: AIGP governance adapter for MCP tool invocations — policy, scope, consent, evidence
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Provides-Extra: all
|
|
7
|
+
Requires-Dist: boto3; extra == 'all'
|
|
8
|
+
Provides-Extra: aws
|
|
9
|
+
Requires-Dist: boto3; extra == 'aws'
|
|
10
|
+
Provides-Extra: dynamodb
|
|
11
|
+
Requires-Dist: boto3; extra == 'dynamodb'
|
|
12
|
+
Provides-Extra: s3
|
|
13
|
+
Requires-Dist: boto3; extra == 's3'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# aigp-mcp
|
|
17
|
+
|
|
18
|
+
AIGP governance for MCP (Model Context Protocol) tool invocations.
|
|
19
|
+
|
|
20
|
+
Part of **MAIGP** — the Mediated AI Governance Protocol: a runtime consent / scope / evidence layer for AI systems. Every governed call is **CHECK**ed before it runs (policy, scope, jurisdiction, circuit-breaker) and **RECORD**ed + **TRACE**d as tamper-evident evidence after.
|
|
21
|
+
|
|
22
|
+
## What it governs
|
|
23
|
+
|
|
24
|
+
Governs MCP tool calls — policy, scope, consent, classification, and evidence.
|
|
25
|
+
|
|
26
|
+
## Governance model
|
|
27
|
+
|
|
28
|
+
This adapter maps the framework's lifecycle onto the AIGP agent-governance loop provided by [`maigp-agent-core`](https://pypi.org/project/maigp-agent-core/):
|
|
29
|
+
|
|
30
|
+
| Framework event | AIGP action |
|
|
31
|
+
|---|---|
|
|
32
|
+
| run / invocation start | **CHECK** (`pre_invoke`) — allowed? under what authority? |
|
|
33
|
+
| each model reply | token accounting (`on_model_call`) |
|
|
34
|
+
| each tool / function call | tool governance (`on_tool_call`) |
|
|
35
|
+
| run complete | **RECORD** + **TRACE** (`post_invoke`) |
|
|
36
|
+
| failure | `on_error` (recorded, then re-raised) |
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
## Install
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
pip install aigp-mcp
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Pulls in [`maigp-agent-core`](https://pypi.org/project/maigp-agent-core/) (shared lifecycle) and, transitively, [`maigp-client`](https://pypi.org/project/maigp-client/).
|
|
46
|
+
|
|
47
|
+
## Quick start
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from aigp_mcp import GovernedMCPClient, MCPGovernanceConfig
|
|
51
|
+
|
|
52
|
+
config = MCPGovernanceConfig(
|
|
53
|
+
mode="ENFORCE",
|
|
54
|
+
gov_url="https://gov.example.com",
|
|
55
|
+
app_id="mcp-gateway",
|
|
56
|
+
hmac_secret="...",
|
|
57
|
+
)
|
|
58
|
+
governed = GovernedMCPClient(mcp_client, config)
|
|
59
|
+
|
|
60
|
+
# Pre-check → execute only if allowed → post-record evidence
|
|
61
|
+
result = await governed.call_tool("wiz", "listVulnerabilities",
|
|
62
|
+
{"severity": "HIGH"})
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Modes & exports
|
|
66
|
+
|
|
67
|
+
`mode="ENFORCE"` blocks disallowed calls (raising `GovernanceDenied`); monitor-only modes record without blocking. Exports include `AccessDecision`, `GovernanceDenied`, and `ToolEvidence`.
|
|
68
|
+
|
|
69
|
+
## Links
|
|
70
|
+
|
|
71
|
+
- Protocol, specs & full adapter catalog: https://github.com/owner-spec/aigp-protocol
|
|
72
|
+
- Issues: https://github.com/owner-spec/aigp-protocol/issues
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
License: Proprietary. © Kanjani AI Research & Causum.
|
aigp_mcp-1.0.0/README.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# aigp-mcp
|
|
2
|
+
|
|
3
|
+
AIGP governance for MCP (Model Context Protocol) tool invocations.
|
|
4
|
+
|
|
5
|
+
Part of **MAIGP** — the Mediated AI Governance Protocol: a runtime consent / scope / evidence layer for AI systems. Every governed call is **CHECK**ed before it runs (policy, scope, jurisdiction, circuit-breaker) and **RECORD**ed + **TRACE**d as tamper-evident evidence after.
|
|
6
|
+
|
|
7
|
+
## What it governs
|
|
8
|
+
|
|
9
|
+
Governs MCP tool calls — policy, scope, consent, classification, and evidence.
|
|
10
|
+
|
|
11
|
+
## Governance model
|
|
12
|
+
|
|
13
|
+
This adapter maps the framework's lifecycle onto the AIGP agent-governance loop provided by [`maigp-agent-core`](https://pypi.org/project/maigp-agent-core/):
|
|
14
|
+
|
|
15
|
+
| Framework event | AIGP action |
|
|
16
|
+
|---|---|
|
|
17
|
+
| run / invocation start | **CHECK** (`pre_invoke`) — allowed? under what authority? |
|
|
18
|
+
| each model reply | token accounting (`on_model_call`) |
|
|
19
|
+
| each tool / function call | tool governance (`on_tool_call`) |
|
|
20
|
+
| run complete | **RECORD** + **TRACE** (`post_invoke`) |
|
|
21
|
+
| failure | `on_error` (recorded, then re-raised) |
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install aigp-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Pulls in [`maigp-agent-core`](https://pypi.org/project/maigp-agent-core/) (shared lifecycle) and, transitively, [`maigp-client`](https://pypi.org/project/maigp-client/).
|
|
31
|
+
|
|
32
|
+
## Quick start
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from aigp_mcp import GovernedMCPClient, MCPGovernanceConfig
|
|
36
|
+
|
|
37
|
+
config = MCPGovernanceConfig(
|
|
38
|
+
mode="ENFORCE",
|
|
39
|
+
gov_url="https://gov.example.com",
|
|
40
|
+
app_id="mcp-gateway",
|
|
41
|
+
hmac_secret="...",
|
|
42
|
+
)
|
|
43
|
+
governed = GovernedMCPClient(mcp_client, config)
|
|
44
|
+
|
|
45
|
+
# Pre-check → execute only if allowed → post-record evidence
|
|
46
|
+
result = await governed.call_tool("wiz", "listVulnerabilities",
|
|
47
|
+
{"severity": "HIGH"})
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Modes & exports
|
|
51
|
+
|
|
52
|
+
`mode="ENFORCE"` blocks disallowed calls (raising `GovernanceDenied`); monitor-only modes record without blocking. Exports include `AccessDecision`, `GovernanceDenied`, and `ToolEvidence`.
|
|
53
|
+
|
|
54
|
+
## Links
|
|
55
|
+
|
|
56
|
+
- Protocol, specs & full adapter catalog: https://github.com/owner-spec/aigp-protocol
|
|
57
|
+
- Issues: https://github.com/owner-spec/aigp-protocol/issues
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
License: Proprietary. © Kanjani AI Research & Causum.
|
aigp_mcp-1.0.0/SPEC.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# SPEC: aigp_mcp — AIGP Adapter for MCP Tool Governance
|
|
2
|
+
|
|
3
|
+
**Date:** 2026-07-09
|
|
4
|
+
**Status:** Spec
|
|
5
|
+
**Location:** `aigp-protocol/sdks/python/adapters/mcp/`
|
|
6
|
+
**Purpose:** Govern MCP tool invocations with AIGP — policy enforcement, consent gating, evidence recording.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. What It Does
|
|
11
|
+
|
|
12
|
+
Wraps any MCP tool call with AIGP governance:
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from aigp_mcp import GovernedMCPClient
|
|
16
|
+
|
|
17
|
+
# Without governance (current):
|
|
18
|
+
result = await mcp_client.call_tool("listVulnerabilities", {"severity": "HIGH"})
|
|
19
|
+
|
|
20
|
+
# With governance (aigp_mcp):
|
|
21
|
+
governed = GovernedMCPClient(mcp_client, aigp_config)
|
|
22
|
+
result = await governed.call_tool("wiz", "listVulnerabilities", {"severity": "HIGH"})
|
|
23
|
+
# → PRE: checks policy (is this agent allowed to call wiz tools?)
|
|
24
|
+
# → PRE: checks consent (does TIER_2 consent exist for this data?)
|
|
25
|
+
# → PRE: checks scope (is read:vulnerabilities in the allowed scope?)
|
|
26
|
+
# → EXECUTE: calls the MCP tool
|
|
27
|
+
# → POST: records AIGP evidence (who, what, when, under what authority)
|
|
28
|
+
# → POST: hashes input/output for tamper detection
|
|
29
|
+
# → RETURNS: result (or GovernanceDenied exception)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. Architecture
|
|
35
|
+
|
|
36
|
+
```
|
|
37
|
+
Agent
|
|
38
|
+
│
|
|
39
|
+
▼
|
|
40
|
+
aigp_mcp.GovernedMCPClient
|
|
41
|
+
│
|
|
42
|
+
├── PRE-CHECK ──────────────────────────┐
|
|
43
|
+
│ • Policy evaluation (GOV_APP) │
|
|
44
|
+
│ • Scope enforcement (allowed tools) │ ← AIGP governance
|
|
45
|
+
│ • Consent verification (tier check) │
|
|
46
|
+
│ • Data classification match │
|
|
47
|
+
├───────────────────────────────────────┘
|
|
48
|
+
│
|
|
49
|
+
├── EXECUTE ────────────────────────────┐
|
|
50
|
+
│ • MCP Helper call_tool() │ ← Actual tool invocation
|
|
51
|
+
├───────────────────────────────────────┘
|
|
52
|
+
│
|
|
53
|
+
├── POST-RECORD ────────────────────────┐
|
|
54
|
+
│ • Evidence record (trace store) │
|
|
55
|
+
│ • Input/output hash │ ← AIGP evidence
|
|
56
|
+
│ • Duration, status, errors │
|
|
57
|
+
│ • Evidence chain linkage │
|
|
58
|
+
├───────────────────────────────────────┘
|
|
59
|
+
│
|
|
60
|
+
▼
|
|
61
|
+
Result (or GovernanceDenied)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 3. Module Structure
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
aigp-protocol/sdks/python/adapters/mcp/
|
|
70
|
+
├── pyproject.toml
|
|
71
|
+
├── aigp_mcp/
|
|
72
|
+
│ ├── __init__.py # Exports: GovernedMCPClient, MCPGovernanceConfig
|
|
73
|
+
│ ├── client.py # GovernedMCPClient — wraps MCP calls
|
|
74
|
+
│ ├── policy.py # Policy evaluation (calls GOV_APP or local rules)
|
|
75
|
+
│ ├── consent.py # Consent tier verification
|
|
76
|
+
│ ├── scope.py # Scope enforcement (which tools are allowed)
|
|
77
|
+
│ ├── evidence.py # AIGP evidence recording
|
|
78
|
+
│ ├── models.py # Pydantic models (ToolInvocation, Evidence, Decision)
|
|
79
|
+
│ └── config.py # MCPGovernanceConfig
|
|
80
|
+
└── tests/
|
|
81
|
+
├── test_client.py
|
|
82
|
+
├── test_policy.py
|
|
83
|
+
└── test_evidence.py
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## 4. Core API
|
|
89
|
+
|
|
90
|
+
### 4.1 GovernedMCPClient
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
class GovernedMCPClient:
|
|
94
|
+
"""Wraps MCP tool calls with AIGP governance."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
mcp_client, # The actual MCP client/helper
|
|
99
|
+
config: MCPGovernanceConfig, # Governance configuration
|
|
100
|
+
):
|
|
101
|
+
...
|
|
102
|
+
|
|
103
|
+
async def call_tool(
|
|
104
|
+
self,
|
|
105
|
+
server_id: str, # Which MCP server (e.g. "wiz")
|
|
106
|
+
tool_name: str, # Which tool (e.g. "listVulnerabilities")
|
|
107
|
+
arguments: dict, # Tool arguments
|
|
108
|
+
*,
|
|
109
|
+
agent_id: str = "", # Calling agent identity
|
|
110
|
+
session_id: str = "", # AIGP session ID (for chaining)
|
|
111
|
+
consent_reference: str = "", # Consent record ID (if pre-obtained)
|
|
112
|
+
) -> ToolResult:
|
|
113
|
+
"""Call an MCP tool with full AIGP governance."""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
async def list_tools(
|
|
117
|
+
self,
|
|
118
|
+
agent_id: str = "",
|
|
119
|
+
classification: str = "",
|
|
120
|
+
) -> list[GovernedTool]:
|
|
121
|
+
"""List available tools filtered by agent's permissions."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
async def check_access(
|
|
125
|
+
self,
|
|
126
|
+
server_id: str,
|
|
127
|
+
tool_name: str,
|
|
128
|
+
agent_id: str,
|
|
129
|
+
) -> AccessDecision:
|
|
130
|
+
"""Pre-check if an agent can call a specific tool (without calling it)."""
|
|
131
|
+
...
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 4.2 MCPGovernanceConfig
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
class MCPGovernanceConfig:
|
|
138
|
+
"""Configuration for MCP governance."""
|
|
139
|
+
|
|
140
|
+
# Governance mode
|
|
141
|
+
mode: str = "REPORT" # REPORT | ENFORCE | TRACE
|
|
142
|
+
|
|
143
|
+
# Policy source
|
|
144
|
+
gov_url: str = "" # GOV_APP URL for policy evaluation
|
|
145
|
+
local_policy: dict = {} # Or: inline policy rules (for offline)
|
|
146
|
+
|
|
147
|
+
# Evidence store
|
|
148
|
+
evidence_store: str = "s3" # s3 | dynamodb | local
|
|
149
|
+
evidence_bucket: str = "" # S3 bucket for evidence
|
|
150
|
+
|
|
151
|
+
# Scope rules (which agents can use which servers/tools)
|
|
152
|
+
scope_rules: dict = {}
|
|
153
|
+
# Example:
|
|
154
|
+
# {
|
|
155
|
+
# "cpg_agent_v2": {"allowed_servers": ["wiz", "aws-security-agent"], "denied_tools": []},
|
|
156
|
+
# "graph_query_agent": {"allowed_servers": ["aws-documentation", "oscal"], "denied_tools": ["*__delete*"]},
|
|
157
|
+
# "*": {"allowed_servers": ["aws-documentation"], "denied_tools": ["*__write*", "*__delete*"]}
|
|
158
|
+
# }
|
|
159
|
+
|
|
160
|
+
# Classification requirements
|
|
161
|
+
classification_rules: dict = {}
|
|
162
|
+
# Example:
|
|
163
|
+
# {
|
|
164
|
+
# "wiz": "HIGH",
|
|
165
|
+
# "aws-documentation": "LOW",
|
|
166
|
+
# "servicenow": "HIGH",
|
|
167
|
+
# }
|
|
168
|
+
|
|
169
|
+
# Consent tiers
|
|
170
|
+
consent_tiers: dict = {}
|
|
171
|
+
# Example:
|
|
172
|
+
# {
|
|
173
|
+
# "HIGH": "TIER_2", # HIGH-classified tools need TIER_2 consent
|
|
174
|
+
# "MEDIUM": "TIER_1", # MEDIUM needs TIER_1
|
|
175
|
+
# "LOW": "NONE", # LOW needs no consent
|
|
176
|
+
# }
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## 5. Governance Modes
|
|
182
|
+
|
|
183
|
+
| Mode | PRE-CHECK | EXECUTE | POST-RECORD | On Denial |
|
|
184
|
+
|---|---|---|---|---|
|
|
185
|
+
| **TRACE** | Log only (no block) | Always execute | Always record | Log warning, proceed |
|
|
186
|
+
| **REPORT** | Evaluate + log | Always execute | Always record | Log violation, proceed |
|
|
187
|
+
| **ENFORCE** | Evaluate + block | Only if allowed | Always record | Raise GovernanceDenied |
|
|
188
|
+
|
|
189
|
+
Start with REPORT for visibility, move to ENFORCE once policies are tuned.
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## 6. Evidence Record
|
|
194
|
+
|
|
195
|
+
Every governed tool call produces an evidence record:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{
|
|
199
|
+
"evidence_type": "MCP_TOOL_INVOCATION",
|
|
200
|
+
"evidence_id": "EVD-MCP-20260709-150000-001",
|
|
201
|
+
"timestamp": "2026-07-09T15:00:00Z",
|
|
202
|
+
|
|
203
|
+
"identity": {
|
|
204
|
+
"agent_id": "cpg_agent_v2",
|
|
205
|
+
"session_id": "sess-abc123",
|
|
206
|
+
"delegation_chain": ["user:admin@org.com", "agent:cpg_agent_v2"]
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
"action": {
|
|
210
|
+
"server_id": "wiz",
|
|
211
|
+
"tool_name": "listVulnerabilities",
|
|
212
|
+
"arguments_hash": "sha256:abc...",
|
|
213
|
+
"result_hash": "sha256:def...",
|
|
214
|
+
"status": "SUCCESS",
|
|
215
|
+
"duration_ms": 342
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
"governance": {
|
|
219
|
+
"mode": "ENFORCE",
|
|
220
|
+
"policy_id": "POL-MCP-001",
|
|
221
|
+
"decision": "ALLOW",
|
|
222
|
+
"scope_check": "PASSED",
|
|
223
|
+
"classification": "HIGH",
|
|
224
|
+
"consent_tier": "TIER_2",
|
|
225
|
+
"consent_reference": "CONSENT-2026-001",
|
|
226
|
+
"data_residency": "us-east-1"
|
|
227
|
+
},
|
|
228
|
+
|
|
229
|
+
"provenance": {
|
|
230
|
+
"aigp_version": "5.1.0",
|
|
231
|
+
"adapter": "aigp_mcp",
|
|
232
|
+
"evidence_chain_id": "CHAIN-MCP-WIZ-001",
|
|
233
|
+
"parent_evidence_id": "EVD-AGENT-20260709-145959-001"
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
---
|
|
239
|
+
|
|
240
|
+
## 7. Scope Enforcement Examples
|
|
241
|
+
|
|
242
|
+
### Allow: CPG agent calling Wiz for vulnerability scan
|
|
243
|
+
```
|
|
244
|
+
Agent: cpg_agent_v2
|
|
245
|
+
Server: wiz
|
|
246
|
+
Tool: listVulnerabilities
|
|
247
|
+
Rule: cpg_agent_v2 → allowed_servers: ["wiz"]
|
|
248
|
+
Decision: ALLOW
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### Deny: Query agent trying to write to ServiceNow
|
|
252
|
+
```
|
|
253
|
+
Agent: graph_query_agent
|
|
254
|
+
Server: servicenow
|
|
255
|
+
Tool: createIncident
|
|
256
|
+
Rule: graph_query_agent → denied_tools: ["*__create*", "*__write*", "*__delete*"]
|
|
257
|
+
Decision: DENY (scope violation: write operation not permitted for read-only agent)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Deny: Unregistered agent calling HIGH tool
|
|
261
|
+
```
|
|
262
|
+
Agent: unknown_agent
|
|
263
|
+
Server: wiz
|
|
264
|
+
Tool: deleteAsset
|
|
265
|
+
Rule: * → denied_tools: ["*__delete*"]
|
|
266
|
+
Decision: DENY (destructive operation blocked by default policy)
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 8. Integration with Managed/Unmanaged MCP
|
|
272
|
+
|
|
273
|
+
```python
|
|
274
|
+
# Unmanaged MCP (current — no governance):
|
|
275
|
+
result = await mcp_helper.call_tool("wiz", "listVulnerabilities", args)
|
|
276
|
+
|
|
277
|
+
# Managed MCP (with aigp_mcp):
|
|
278
|
+
governed = GovernedMCPClient(mcp_helper, config)
|
|
279
|
+
result = await governed.call_tool("wiz", "listVulnerabilities", args, agent_id="cpg_agent")
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The `GovernedMCPClient` is a **drop-in wrapper**. Existing code continues to work — you just wrap the client. No changes to MCP Helper internals.
|
|
283
|
+
|
|
284
|
+
For the AgentCore bridge, the wrapper sits at the bridge layer:
|
|
285
|
+
|
|
286
|
+
```
|
|
287
|
+
Agent → AgentCore → MCP Bridge → aigp_mcp (governance) → MCP Helper → MCP Server
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## 9. Package Definition
|
|
293
|
+
|
|
294
|
+
```toml
|
|
295
|
+
# pyproject.toml
|
|
296
|
+
[project]
|
|
297
|
+
name = "aigp-mcp"
|
|
298
|
+
version = "1.0.0"
|
|
299
|
+
description = "AIGP adapter for governed MCP tool invocations"
|
|
300
|
+
requires-python = ">=3.10"
|
|
301
|
+
dependencies = [
|
|
302
|
+
"maigp-client>=5.0.0",
|
|
303
|
+
"pydantic>=2.0",
|
|
304
|
+
"httpx>=0.24",
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
[project.optional-dependencies]
|
|
308
|
+
s3 = ["boto3"]
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
---
|
|
312
|
+
|
|
313
|
+
## 10. Implementation Estimate
|
|
314
|
+
|
|
315
|
+
| Task | Effort |
|
|
316
|
+
|---|---|
|
|
317
|
+
| `client.py` (GovernedMCPClient) | 1 day |
|
|
318
|
+
| `policy.py` (GOV_APP integration + local rules) | 1 day |
|
|
319
|
+
| `scope.py` (agent → server/tool permission matrix) | 0.5 day |
|
|
320
|
+
| `consent.py` (tier verification) | 0.5 day |
|
|
321
|
+
| `evidence.py` (S3/DynamoDB evidence writer) | 1 day |
|
|
322
|
+
| `config.py` + `models.py` | 0.5 day |
|
|
323
|
+
| Tests | 1 day |
|
|
324
|
+
| Integration with MCP Helper bridge | 1 day |
|
|
325
|
+
|
|
326
|
+
**Total: ~6.5 days**
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## 11. Relationship to Other Adapters
|
|
331
|
+
|
|
332
|
+
| Adapter | What It Governs | Where It Sits |
|
|
333
|
+
|---|---|---|
|
|
334
|
+
| `aigp_strands` | Strands agent actions | Inside agent code |
|
|
335
|
+
| `aigp_langchain` | LangChain tool calls | Inside agent code |
|
|
336
|
+
| `aigp_agentcore` | AgentCore invocations | AgentCore middleware |
|
|
337
|
+
| **`aigp_mcp`** | **MCP tool calls** | **MCP Helper bridge** |
|
|
338
|
+
| `aigp_a2a` *(also missing)* | Agent-to-agent delegation | AgentCore routing |
|
|
339
|
+
|
|
340
|
+
`aigp_mcp` is the **tool-layer governance** — it doesn't care about which framework called it. Whether a Strands agent, LangChain agent, or direct API call invokes an MCP tool, `aigp_mcp` governs it at the MCP Helper level.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""AIGP-MCP — Governance adapter for MCP tool invocations.
|
|
2
|
+
|
|
3
|
+
Wraps MCP tool calls with AIGP governance:
|
|
4
|
+
- Pre-check: policy, scope, consent, classification
|
|
5
|
+
- Execute: only if allowed (ENFORCE mode)
|
|
6
|
+
- Post-record: evidence (who, what, when, under what authority)
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
from aigp_mcp import GovernedMCPClient, MCPGovernanceConfig
|
|
10
|
+
|
|
11
|
+
config = MCPGovernanceConfig(
|
|
12
|
+
mode="ENFORCE",
|
|
13
|
+
gov_url="https://www.cyber-ai-gov.com",
|
|
14
|
+
app_id="mcp-gateway",
|
|
15
|
+
hmac_secret="...",
|
|
16
|
+
)
|
|
17
|
+
governed = GovernedMCPClient(mcp_client, config)
|
|
18
|
+
result = await governed.call_tool("wiz", "listVulnerabilities", {"severity": "HIGH"})
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
__version__ = "1.0.0"
|
|
22
|
+
|
|
23
|
+
from .client import GovernedMCPClient
|
|
24
|
+
from .config import MCPGovernanceConfig
|
|
25
|
+
from .models import AccessDecision, GovernanceDenied, ToolEvidence
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"GovernedMCPClient",
|
|
29
|
+
"MCPGovernanceConfig",
|
|
30
|
+
"AccessDecision",
|
|
31
|
+
"GovernanceDenied",
|
|
32
|
+
"ToolEvidence",
|
|
33
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""GovernedMCPClient — wraps MCP tool calls with AIGP governance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from .config import MCPGovernanceConfig
|
|
12
|
+
from .evidence import EvidenceWriter
|
|
13
|
+
from .models import AccessDecision, GovernanceDenied, ToolEvidence
|
|
14
|
+
from .scope import check_scope
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class MCPClientProtocol(Protocol):
|
|
20
|
+
"""Protocol for any MCP client that can call tools."""
|
|
21
|
+
|
|
22
|
+
async def call_tool(self, server_id: str, tool_name: str, arguments: dict) -> Any: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class GovernedMCPClient:
|
|
26
|
+
"""Wraps an MCP client with AIGP governance.
|
|
27
|
+
|
|
28
|
+
Every tool call goes through:
|
|
29
|
+
1. PRE: scope check, classification check, consent check
|
|
30
|
+
2. EXECUTE: actual tool call (only if allowed in ENFORCE mode)
|
|
31
|
+
3. POST: evidence record
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
from aigp_mcp import GovernedMCPClient, MCPGovernanceConfig
|
|
35
|
+
|
|
36
|
+
config = MCPGovernanceConfig(mode="ENFORCE", evidence_store="local")
|
|
37
|
+
governed = GovernedMCPClient(mcp_client, config)
|
|
38
|
+
result = await governed.call_tool("wiz", "listVulnerabilities", {}, agent_id="cpg_agent")
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, mcp_client: Any, config: MCPGovernanceConfig):
|
|
42
|
+
self._client = mcp_client
|
|
43
|
+
self._config = config
|
|
44
|
+
self._evidence_writer = EvidenceWriter.from_config(
|
|
45
|
+
config.evidence_store,
|
|
46
|
+
bucket=config.evidence_bucket,
|
|
47
|
+
path=config.evidence_path,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
async def call_tool(
|
|
51
|
+
self,
|
|
52
|
+
server_id: str,
|
|
53
|
+
tool_name: str,
|
|
54
|
+
arguments: dict | None = None,
|
|
55
|
+
*,
|
|
56
|
+
agent_id: str = "",
|
|
57
|
+
session_id: str = "",
|
|
58
|
+
consent_reference: str = "",
|
|
59
|
+
parent_evidence_id: str = "",
|
|
60
|
+
) -> Any:
|
|
61
|
+
"""Call an MCP tool with full AIGP governance.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
server_id: MCP server ID (e.g. "wiz")
|
|
65
|
+
tool_name: Tool name (e.g. "listVulnerabilities")
|
|
66
|
+
arguments: Tool arguments
|
|
67
|
+
agent_id: Calling agent's identity
|
|
68
|
+
session_id: AIGP session ID for chaining
|
|
69
|
+
consent_reference: Pre-obtained consent record ID
|
|
70
|
+
parent_evidence_id: Link to parent evidence (delegation chain)
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Tool result (passthrough from MCP server)
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
GovernanceDenied: If ENFORCE mode and policy denies the call
|
|
77
|
+
"""
|
|
78
|
+
arguments = arguments or {}
|
|
79
|
+
t0 = time.time()
|
|
80
|
+
evidence_id = f"EVD-MCP-{uuid.uuid4().hex[:12]}"
|
|
81
|
+
|
|
82
|
+
# ─── PRE-CHECK ─────────────────────────────────────────────
|
|
83
|
+
decision = self._pre_check(agent_id, server_id, tool_name, consent_reference)
|
|
84
|
+
|
|
85
|
+
if decision.decision == "DENY" and self._config.mode == "ENFORCE":
|
|
86
|
+
# Record denied evidence
|
|
87
|
+
await self._record_evidence(
|
|
88
|
+
evidence_id, agent_id, session_id, server_id, tool_name,
|
|
89
|
+
arguments, None, decision, 0, "DENIED", parent_evidence_id,
|
|
90
|
+
)
|
|
91
|
+
raise GovernanceDenied(
|
|
92
|
+
reason=decision.reason,
|
|
93
|
+
server_id=server_id,
|
|
94
|
+
tool_name=tool_name,
|
|
95
|
+
policy_id=decision.policy_id,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if decision.decision == "DENY" and self._config.mode in ("REPORT", "TRACE"):
|
|
99
|
+
logger.warning(
|
|
100
|
+
"GOVERNANCE VIOLATION (mode=%s, not blocking): %s.%s by %s — %s",
|
|
101
|
+
self._config.mode, server_id, tool_name, agent_id, decision.reason,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# ─── EXECUTE ───────────────────────────────────────────────
|
|
105
|
+
result = None
|
|
106
|
+
status = "SUCCESS"
|
|
107
|
+
try:
|
|
108
|
+
result = await self._client.call_tool(server_id, tool_name, arguments)
|
|
109
|
+
except Exception as e:
|
|
110
|
+
status = "ERROR"
|
|
111
|
+
duration_ms = int((time.time() - t0) * 1000)
|
|
112
|
+
await self._record_evidence(
|
|
113
|
+
evidence_id, agent_id, session_id, server_id, tool_name,
|
|
114
|
+
arguments, None, decision, duration_ms, status, parent_evidence_id,
|
|
115
|
+
)
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
# ─── POST-RECORD ──────────────────────────────────────────
|
|
119
|
+
duration_ms = int((time.time() - t0) * 1000)
|
|
120
|
+
await self._record_evidence(
|
|
121
|
+
evidence_id, agent_id, session_id, server_id, tool_name,
|
|
122
|
+
arguments, result, decision, duration_ms, status, parent_evidence_id,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return result
|
|
126
|
+
|
|
127
|
+
async def check_access(
|
|
128
|
+
self, server_id: str, tool_name: str, agent_id: str
|
|
129
|
+
) -> AccessDecision:
|
|
130
|
+
"""Pre-check if an agent can call a tool (without calling it)."""
|
|
131
|
+
return self._pre_check(agent_id, server_id, tool_name)
|
|
132
|
+
|
|
133
|
+
def _pre_check(
|
|
134
|
+
self, agent_id: str, server_id: str, tool_name: str, consent_reference: str = ""
|
|
135
|
+
) -> AccessDecision:
|
|
136
|
+
"""Evaluate governance rules. Returns AccessDecision."""
|
|
137
|
+
|
|
138
|
+
# 1. Scope check
|
|
139
|
+
allowed, reason = check_scope(self._config, agent_id, server_id, tool_name)
|
|
140
|
+
if not allowed:
|
|
141
|
+
return AccessDecision(
|
|
142
|
+
decision="DENY", reason=reason, scope_check="FAILED",
|
|
143
|
+
classification=self._config.classification_rules.get(server_id, ""),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# 2. Classification check
|
|
147
|
+
classification = self._config.classification_rules.get(server_id, "LOW")
|
|
148
|
+
required_tier = self._config.consent_tiers.get(classification, "NONE")
|
|
149
|
+
|
|
150
|
+
# 3. Consent check (if required)
|
|
151
|
+
consent_verified = True
|
|
152
|
+
if required_tier != "NONE" and not consent_reference:
|
|
153
|
+
# In ENFORCE mode, missing consent = deny
|
|
154
|
+
if self._config.mode == "ENFORCE":
|
|
155
|
+
consent_verified = False
|
|
156
|
+
return AccessDecision(
|
|
157
|
+
decision="DENY",
|
|
158
|
+
reason=f"Consent tier {required_tier} required for {classification} server '{server_id}' — no consent_reference provided",
|
|
159
|
+
scope_check="PASSED",
|
|
160
|
+
classification=classification,
|
|
161
|
+
consent_tier=required_tier,
|
|
162
|
+
consent_verified=False,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
# REPORT/TRACE: log but allow
|
|
166
|
+
logger.warning("Consent %s required for %s but not provided (mode=%s)",
|
|
167
|
+
required_tier, server_id, self._config.mode)
|
|
168
|
+
|
|
169
|
+
return AccessDecision(
|
|
170
|
+
decision="ALLOW",
|
|
171
|
+
scope_check="PASSED",
|
|
172
|
+
classification=classification,
|
|
173
|
+
consent_tier=required_tier,
|
|
174
|
+
consent_verified=consent_verified,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
async def _record_evidence(
|
|
178
|
+
self,
|
|
179
|
+
evidence_id: str,
|
|
180
|
+
agent_id: str,
|
|
181
|
+
session_id: str,
|
|
182
|
+
server_id: str,
|
|
183
|
+
tool_name: str,
|
|
184
|
+
arguments: dict | None,
|
|
185
|
+
result: Any,
|
|
186
|
+
decision: AccessDecision,
|
|
187
|
+
duration_ms: int,
|
|
188
|
+
status: str,
|
|
189
|
+
parent_evidence_id: str,
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Write evidence record."""
|
|
192
|
+
try:
|
|
193
|
+
evidence = ToolEvidence(
|
|
194
|
+
evidence_id=evidence_id,
|
|
195
|
+
agent_id=agent_id,
|
|
196
|
+
session_id=session_id,
|
|
197
|
+
server_id=server_id,
|
|
198
|
+
tool_name=tool_name,
|
|
199
|
+
arguments_hash=self._hash(arguments) if arguments else "",
|
|
200
|
+
result_hash=self._hash(result) if result else "",
|
|
201
|
+
status=status,
|
|
202
|
+
duration_ms=duration_ms,
|
|
203
|
+
mode=self._config.mode,
|
|
204
|
+
decision=decision.decision,
|
|
205
|
+
policy_id=decision.policy_id,
|
|
206
|
+
scope_check=decision.scope_check,
|
|
207
|
+
classification=decision.classification,
|
|
208
|
+
consent_tier=decision.consent_tier,
|
|
209
|
+
parent_evidence_id=parent_evidence_id,
|
|
210
|
+
)
|
|
211
|
+
await self._evidence_writer.write(evidence)
|
|
212
|
+
except Exception as e:
|
|
213
|
+
logger.warning("Evidence write failed (non-fatal): %s", e)
|
|
214
|
+
|
|
215
|
+
@staticmethod
|
|
216
|
+
def _hash(data: Any) -> str:
|
|
217
|
+
"""SHA-256 hash of data for tamper detection."""
|
|
218
|
+
try:
|
|
219
|
+
serialized = str(data).encode()
|
|
220
|
+
return f"sha256:{hashlib.sha256(serialized).hexdigest()[:16]}"
|
|
221
|
+
except Exception:
|
|
222
|
+
return ""
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""MCPGovernanceConfig — configuration for governed MCP tool calls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class MCPGovernanceConfig:
|
|
10
|
+
"""Configuration for MCP tool governance.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
mode: Governance mode — TRACE (log only), REPORT (log + evaluate), ENFORCE (block on deny)
|
|
14
|
+
gov_url: GOV_APP URL for policy evaluation (empty = local-only)
|
|
15
|
+
app_id: AIGP application ID for this gateway
|
|
16
|
+
hmac_secret: HMAC secret for AIGP authentication
|
|
17
|
+
scope_rules: Per-agent scope rules {agent_id: {allowed_servers: [...], denied_tools: [...]}}
|
|
18
|
+
classification_rules: Per-server data classification {server_id: "HIGH"|"MEDIUM"|"LOW"}
|
|
19
|
+
consent_tiers: Classification → required consent tier mapping
|
|
20
|
+
evidence_store: Where to write evidence — "s3", "dynamodb", "local", "none"
|
|
21
|
+
evidence_bucket: S3 bucket (if evidence_store=s3)
|
|
22
|
+
evidence_path: Local path (if evidence_store=local)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
# Governance mode
|
|
26
|
+
mode: str = "REPORT" # TRACE | REPORT | ENFORCE
|
|
27
|
+
|
|
28
|
+
# AIGP connection
|
|
29
|
+
gov_url: str = ""
|
|
30
|
+
app_id: str = "mcp-gateway"
|
|
31
|
+
hmac_secret: str = ""
|
|
32
|
+
|
|
33
|
+
# Scope enforcement
|
|
34
|
+
scope_rules: dict = field(default_factory=lambda: {
|
|
35
|
+
"*": {"allowed_servers": ["*"], "denied_tools": ["*__delete*"]},
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
# Data classification per server
|
|
39
|
+
classification_rules: dict = field(default_factory=lambda: {
|
|
40
|
+
"wiz": "HIGH",
|
|
41
|
+
"servicenow": "HIGH",
|
|
42
|
+
"aws-security-agent": "HIGH",
|
|
43
|
+
"aws-cloudtrail": "HIGH",
|
|
44
|
+
"cribl": "MEDIUM",
|
|
45
|
+
"aws-documentation": "LOW",
|
|
46
|
+
"oscal": "LOW",
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
# Consent tier requirements
|
|
50
|
+
consent_tiers: dict = field(default_factory=lambda: {
|
|
51
|
+
"HIGH": "TIER_2",
|
|
52
|
+
"MEDIUM": "TIER_1",
|
|
53
|
+
"LOW": "NONE",
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
# Evidence storage (cloud-agnostic)
|
|
57
|
+
evidence_store: str = "none" # s3 | dynamodb | local | none
|
|
58
|
+
evidence_bucket: str = ""
|
|
59
|
+
evidence_path: str = "./evidence"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Evidence Writer — factory pattern with pluggable storage backends.
|
|
2
|
+
|
|
3
|
+
Cloud-agnostic: register any storage backend. Ships with:
|
|
4
|
+
- LocalFileBackend (default, no dependencies)
|
|
5
|
+
- S3Backend (optional: pip install aigp-mcp[s3])
|
|
6
|
+
- DynamoDBBackend (optional: pip install aigp-mcp[dynamodb])
|
|
7
|
+
- LogOnlyBackend (no persistence, just logs)
|
|
8
|
+
|
|
9
|
+
Extend with custom backends:
|
|
10
|
+
from aigp_mcp.evidence import EvidenceWriterFactory, EvidenceBackend
|
|
11
|
+
|
|
12
|
+
class MyCustomBackend(EvidenceBackend):
|
|
13
|
+
async def write(self, evidence_id, record): ...
|
|
14
|
+
|
|
15
|
+
EvidenceWriterFactory.register("custom", MyCustomBackend)
|
|
16
|
+
writer = EvidenceWriterFactory.create("custom", **kwargs)
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
from abc import ABC, abstractmethod
|
|
25
|
+
from dataclasses import asdict
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from .models import ToolEvidence
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ─── Abstract Base ─────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class EvidenceBackend(ABC):
|
|
39
|
+
"""Abstract base for evidence storage backends."""
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def write(self, evidence_id: str, record: dict) -> str:
|
|
43
|
+
"""Write an evidence record. Returns storage location/key."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def read(self, evidence_id: str) -> dict | None:
|
|
48
|
+
"""Read an evidence record by ID. Returns None if not found."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ─── Built-in Backends ─────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class LogOnlyBackend(EvidenceBackend):
|
|
56
|
+
"""No persistence — logs evidence at INFO level."""
|
|
57
|
+
|
|
58
|
+
async def write(self, evidence_id: str, record: dict) -> str:
|
|
59
|
+
logger.info("Evidence [%s]: %s.%s → %s",
|
|
60
|
+
evidence_id, record.get("server_id"), record.get("tool_name"), record.get("decision"))
|
|
61
|
+
return evidence_id
|
|
62
|
+
|
|
63
|
+
async def read(self, evidence_id: str) -> dict | None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LocalFileBackend(EvidenceBackend):
|
|
68
|
+
"""Write evidence as JSON files to local filesystem."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, path: str = "./evidence"):
|
|
71
|
+
self._path = Path(path)
|
|
72
|
+
self._path.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
async def write(self, evidence_id: str, record: dict) -> str:
|
|
75
|
+
file_path = self._path / f"{evidence_id}.json"
|
|
76
|
+
file_path.write_text(json.dumps(record, indent=2, default=str))
|
|
77
|
+
logger.info("Evidence → %s", file_path)
|
|
78
|
+
return str(file_path)
|
|
79
|
+
|
|
80
|
+
async def read(self, evidence_id: str) -> dict | None:
|
|
81
|
+
file_path = self._path / f"{evidence_id}.json"
|
|
82
|
+
if file_path.exists():
|
|
83
|
+
return json.loads(file_path.read_text())
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class S3Backend(EvidenceBackend):
|
|
88
|
+
"""Write evidence to S3. Requires: pip install aigp-mcp[s3]"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, bucket: str, prefix: str = "aigp-evidence/mcp", region: str = ""):
|
|
91
|
+
self._bucket = bucket
|
|
92
|
+
self._prefix = prefix
|
|
93
|
+
self._region = region
|
|
94
|
+
|
|
95
|
+
async def write(self, evidence_id: str, record: dict) -> str:
|
|
96
|
+
import asyncio
|
|
97
|
+
import boto3
|
|
98
|
+
|
|
99
|
+
key = f"{self._prefix}/{evidence_id}.json"
|
|
100
|
+
|
|
101
|
+
def _upload():
|
|
102
|
+
kwargs = {"region_name": self._region} if self._region else {}
|
|
103
|
+
s3 = boto3.client("s3", **kwargs)
|
|
104
|
+
s3.put_object(
|
|
105
|
+
Bucket=self._bucket, Key=key,
|
|
106
|
+
Body=json.dumps(record, indent=2, default=str).encode(),
|
|
107
|
+
ContentType="application/json",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
await asyncio.to_thread(_upload)
|
|
111
|
+
logger.info("Evidence → s3://%s/%s", self._bucket, key)
|
|
112
|
+
return f"s3://{self._bucket}/{key}"
|
|
113
|
+
|
|
114
|
+
async def read(self, evidence_id: str) -> dict | None:
|
|
115
|
+
import asyncio
|
|
116
|
+
import boto3
|
|
117
|
+
|
|
118
|
+
key = f"{self._prefix}/{evidence_id}.json"
|
|
119
|
+
|
|
120
|
+
def _get():
|
|
121
|
+
kwargs = {"region_name": self._region} if self._region else {}
|
|
122
|
+
s3 = boto3.client("s3", **kwargs)
|
|
123
|
+
try:
|
|
124
|
+
resp = s3.get_object(Bucket=self._bucket, Key=key)
|
|
125
|
+
return json.loads(resp["Body"].read())
|
|
126
|
+
except Exception:
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
return await asyncio.to_thread(_get)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class DynamoDBBackend(EvidenceBackend):
|
|
133
|
+
"""Write evidence to DynamoDB. Requires: pip install aigp-mcp[dynamodb]"""
|
|
134
|
+
|
|
135
|
+
def __init__(self, table_name: str = "aigp-evidence", region: str = ""):
|
|
136
|
+
self._table_name = table_name
|
|
137
|
+
self._region = region
|
|
138
|
+
|
|
139
|
+
async def write(self, evidence_id: str, record: dict) -> str:
|
|
140
|
+
import asyncio
|
|
141
|
+
import boto3
|
|
142
|
+
|
|
143
|
+
def _put():
|
|
144
|
+
kwargs = {"region_name": self._region} if self._region else {}
|
|
145
|
+
ddb = boto3.resource("dynamodb", **kwargs)
|
|
146
|
+
table = ddb.Table(self._table_name)
|
|
147
|
+
table.put_item(Item={
|
|
148
|
+
"PK": f"EVIDENCE#{evidence_id}",
|
|
149
|
+
"SK": "META",
|
|
150
|
+
**{k: v for k, v in record.items() if v is not None and v != "" and v != []},
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
await asyncio.to_thread(_put)
|
|
154
|
+
logger.info("Evidence → DynamoDB:%s/%s", self._table_name, evidence_id)
|
|
155
|
+
return f"dynamodb://{self._table_name}/{evidence_id}"
|
|
156
|
+
|
|
157
|
+
async def read(self, evidence_id: str) -> dict | None:
|
|
158
|
+
import asyncio
|
|
159
|
+
import boto3
|
|
160
|
+
|
|
161
|
+
def _get():
|
|
162
|
+
kwargs = {"region_name": self._region} if self._region else {}
|
|
163
|
+
ddb = boto3.resource("dynamodb", **kwargs)
|
|
164
|
+
table = ddb.Table(self._table_name)
|
|
165
|
+
resp = table.get_item(Key={"PK": f"EVIDENCE#{evidence_id}", "SK": "META"})
|
|
166
|
+
return resp.get("Item")
|
|
167
|
+
|
|
168
|
+
return await asyncio.to_thread(_get)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ─── Factory ───────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class EvidenceWriterFactory:
|
|
175
|
+
"""Factory for creating evidence storage backends.
|
|
176
|
+
|
|
177
|
+
Built-in backends: none, local, s3, dynamodb
|
|
178
|
+
Register custom backends with: EvidenceWriterFactory.register("name", MyBackend)
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
_registry: dict[str, type[EvidenceBackend]] = {
|
|
182
|
+
"none": LogOnlyBackend,
|
|
183
|
+
"local": LocalFileBackend,
|
|
184
|
+
"s3": S3Backend,
|
|
185
|
+
"dynamodb": DynamoDBBackend,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@classmethod
|
|
189
|
+
def register(cls, name: str, backend_class: type[EvidenceBackend]) -> None:
|
|
190
|
+
"""Register a custom evidence backend.
|
|
191
|
+
|
|
192
|
+
Example:
|
|
193
|
+
class AzureBlobBackend(EvidenceBackend): ...
|
|
194
|
+
EvidenceWriterFactory.register("azure_blob", AzureBlobBackend)
|
|
195
|
+
"""
|
|
196
|
+
cls._registry[name] = backend_class
|
|
197
|
+
logger.info("Registered evidence backend: %s", name)
|
|
198
|
+
|
|
199
|
+
@classmethod
|
|
200
|
+
def create(cls, backend_type: str, **kwargs) -> EvidenceBackend:
|
|
201
|
+
"""Create an evidence backend by type.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
backend_type: One of "none", "local", "s3", "dynamodb", or any registered name.
|
|
205
|
+
**kwargs: Backend-specific configuration (bucket, path, table_name, region, etc.)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
An EvidenceBackend instance.
|
|
209
|
+
|
|
210
|
+
Raises:
|
|
211
|
+
ValueError: If backend_type is not registered.
|
|
212
|
+
"""
|
|
213
|
+
if backend_type not in cls._registry:
|
|
214
|
+
available = list(cls._registry.keys())
|
|
215
|
+
raise ValueError(
|
|
216
|
+
f"Unknown evidence backend: '{backend_type}'. "
|
|
217
|
+
f"Available: {available}. Register custom with EvidenceWriterFactory.register()"
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
backend_class = cls._registry[backend_type]
|
|
221
|
+
|
|
222
|
+
# Filter kwargs to only those the backend accepts
|
|
223
|
+
import inspect
|
|
224
|
+
sig = inspect.signature(backend_class.__init__)
|
|
225
|
+
valid_params = set(sig.parameters.keys()) - {"self"}
|
|
226
|
+
filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_params}
|
|
227
|
+
|
|
228
|
+
return backend_class(**filtered_kwargs)
|
|
229
|
+
|
|
230
|
+
@classmethod
|
|
231
|
+
def available(cls) -> list[str]:
|
|
232
|
+
"""List all registered backend types."""
|
|
233
|
+
return list(cls._registry.keys())
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ─── Convenience: EvidenceWriter wraps backend + serialization ─────
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class EvidenceWriter:
|
|
240
|
+
"""High-level evidence writer — serializes ToolEvidence and delegates to backend."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, backend: EvidenceBackend):
|
|
243
|
+
self._backend = backend
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_config(cls, evidence_store: str, **kwargs) -> "EvidenceWriter":
|
|
247
|
+
"""Create from config values.
|
|
248
|
+
|
|
249
|
+
Example:
|
|
250
|
+
writer = EvidenceWriter.from_config("s3", bucket="my-bucket", region="us-east-1")
|
|
251
|
+
"""
|
|
252
|
+
backend = EvidenceWriterFactory.create(evidence_store, **kwargs)
|
|
253
|
+
return cls(backend)
|
|
254
|
+
|
|
255
|
+
async def write(self, evidence: ToolEvidence) -> str:
|
|
256
|
+
"""Serialize and write evidence. Returns storage location."""
|
|
257
|
+
record = asdict(evidence)
|
|
258
|
+
return await self._backend.write(evidence.evidence_id, record)
|
|
259
|
+
|
|
260
|
+
async def read(self, evidence_id: str) -> ToolEvidence | None:
|
|
261
|
+
"""Read evidence by ID."""
|
|
262
|
+
record = await self._backend.read(evidence_id)
|
|
263
|
+
if record:
|
|
264
|
+
record.pop("PK", None)
|
|
265
|
+
record.pop("SK", None)
|
|
266
|
+
return ToolEvidence(**{k: v for k, v in record.items() if hasattr(ToolEvidence, k)})
|
|
267
|
+
return None
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Models for MCP governance — decisions, evidence, errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class GovernanceDenied(Exception):
|
|
10
|
+
"""Raised when a tool call is blocked by governance (ENFORCE mode)."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, reason: str, server_id: str = "", tool_name: str = "", policy_id: str = ""):
|
|
13
|
+
self.reason = reason
|
|
14
|
+
self.server_id = server_id
|
|
15
|
+
self.tool_name = tool_name
|
|
16
|
+
self.policy_id = policy_id
|
|
17
|
+
super().__init__(f"Tool '{server_id}.{tool_name}' denied: {reason}")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class AccessDecision:
|
|
22
|
+
"""Result of a governance pre-check."""
|
|
23
|
+
|
|
24
|
+
decision: str = "ALLOW" # ALLOW | DENY | WARN
|
|
25
|
+
reason: str = ""
|
|
26
|
+
policy_id: str = ""
|
|
27
|
+
scope_check: str = "PASSED" # PASSED | FAILED | SKIPPED
|
|
28
|
+
classification: str = ""
|
|
29
|
+
consent_tier: str = ""
|
|
30
|
+
consent_verified: bool = True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ToolEvidence:
|
|
35
|
+
"""AIGP evidence record for a single MCP tool invocation."""
|
|
36
|
+
|
|
37
|
+
evidence_id: str = ""
|
|
38
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
39
|
+
|
|
40
|
+
# Identity
|
|
41
|
+
agent_id: str = ""
|
|
42
|
+
session_id: str = ""
|
|
43
|
+
delegation_chain: list[str] = field(default_factory=list)
|
|
44
|
+
|
|
45
|
+
# Action
|
|
46
|
+
server_id: str = ""
|
|
47
|
+
tool_name: str = ""
|
|
48
|
+
arguments_hash: str = ""
|
|
49
|
+
result_hash: str = ""
|
|
50
|
+
status: str = "SUCCESS" # SUCCESS | ERROR | DENIED
|
|
51
|
+
duration_ms: int = 0
|
|
52
|
+
|
|
53
|
+
# Governance
|
|
54
|
+
mode: str = "REPORT"
|
|
55
|
+
decision: str = "ALLOW"
|
|
56
|
+
policy_id: str = ""
|
|
57
|
+
scope_check: str = "PASSED"
|
|
58
|
+
classification: str = ""
|
|
59
|
+
consent_tier: str = ""
|
|
60
|
+
consent_reference: str = ""
|
|
61
|
+
|
|
62
|
+
# Provenance
|
|
63
|
+
aigp_version: str = "5.1.0"
|
|
64
|
+
adapter: str = "aigp_mcp"
|
|
65
|
+
evidence_chain_id: str = ""
|
|
66
|
+
parent_evidence_id: str = ""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Scope enforcement — determines if an agent can call a specific tool."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import fnmatch
|
|
6
|
+
|
|
7
|
+
from .config import MCPGovernanceConfig
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def check_scope(
|
|
11
|
+
config: MCPGovernanceConfig,
|
|
12
|
+
agent_id: str,
|
|
13
|
+
server_id: str,
|
|
14
|
+
tool_name: str,
|
|
15
|
+
) -> tuple[bool, str]:
|
|
16
|
+
"""Check if agent_id is allowed to call server_id.tool_name.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
(allowed: bool, reason: str)
|
|
20
|
+
"""
|
|
21
|
+
# Find matching rule (specific agent first, then wildcard)
|
|
22
|
+
rule = config.scope_rules.get(agent_id) or config.scope_rules.get("*", {})
|
|
23
|
+
|
|
24
|
+
if not rule:
|
|
25
|
+
return True, "No scope rules configured (default allow)"
|
|
26
|
+
|
|
27
|
+
allowed_servers = rule.get("allowed_servers", ["*"])
|
|
28
|
+
denied_tools = rule.get("denied_tools", [])
|
|
29
|
+
|
|
30
|
+
# Check server allowlist
|
|
31
|
+
server_allowed = False
|
|
32
|
+
for pattern in allowed_servers:
|
|
33
|
+
if pattern == "*" or fnmatch.fnmatch(server_id, pattern):
|
|
34
|
+
server_allowed = True
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
if not server_allowed:
|
|
38
|
+
return False, f"Agent '{agent_id}' not authorized for server '{server_id}'"
|
|
39
|
+
|
|
40
|
+
# Check tool denylist
|
|
41
|
+
full_tool_name = f"{server_id}__{tool_name}"
|
|
42
|
+
for pattern in denied_tools:
|
|
43
|
+
if fnmatch.fnmatch(full_tool_name, pattern):
|
|
44
|
+
return False, f"Tool '{full_tool_name}' matches deny pattern '{pattern}'"
|
|
45
|
+
|
|
46
|
+
return True, "Scope check passed"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "aigp-mcp"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "AIGP governance adapter for MCP tool invocations — policy, scope, consent, evidence"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
dependencies = []
|
|
12
|
+
|
|
13
|
+
[project.optional-dependencies]
|
|
14
|
+
s3 = ["boto3"]
|
|
15
|
+
dynamodb = ["boto3"]
|
|
16
|
+
aws = ["boto3"]
|
|
17
|
+
all = ["boto3"]
|
|
18
|
+
|
|
19
|
+
[tool.hatch.build.targets.wheel]
|
|
20
|
+
packages = ["aigp_mcp"]
|
|
File without changes
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Tests for the AIGP MCP adapter — scope enforcement + config (pure logic, no MCP install)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from aigp_mcp.config import MCPGovernanceConfig
|
|
6
|
+
from aigp_mcp.scope import check_scope
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_default_config_denies_delete_tools():
|
|
10
|
+
cfg = MCPGovernanceConfig()
|
|
11
|
+
# default wildcard rule denies *__delete*
|
|
12
|
+
allowed, reason = check_scope(cfg, "agent-1", "wiz", "delete_finding")
|
|
13
|
+
assert allowed is False
|
|
14
|
+
assert "deny pattern" in reason
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_default_config_allows_read_tools():
|
|
18
|
+
cfg = MCPGovernanceConfig()
|
|
19
|
+
allowed, reason = check_scope(cfg, "agent-1", "wiz", "list_findings")
|
|
20
|
+
assert allowed is True
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_server_allowlist_enforced():
|
|
24
|
+
cfg = MCPGovernanceConfig(scope_rules={
|
|
25
|
+
"agent-2": {"allowed_servers": ["aws-*"], "denied_tools": []},
|
|
26
|
+
})
|
|
27
|
+
ok_allowed, _ = check_scope(cfg, "agent-2", "aws-cloudtrail", "lookup")
|
|
28
|
+
bad_allowed, reason = check_scope(cfg, "agent-2", "servicenow", "create")
|
|
29
|
+
assert ok_allowed is True
|
|
30
|
+
assert bad_allowed is False and "not authorized" in reason
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_specific_agent_rule_overrides_wildcard():
|
|
34
|
+
cfg = MCPGovernanceConfig(scope_rules={
|
|
35
|
+
"*": {"allowed_servers": ["*"], "denied_tools": ["*__delete*"]},
|
|
36
|
+
"restricted": {"allowed_servers": ["aws-documentation"], "denied_tools": []},
|
|
37
|
+
})
|
|
38
|
+
# restricted agent may only reach aws-documentation
|
|
39
|
+
allowed, _ = check_scope(cfg, "restricted", "wiz", "list_findings")
|
|
40
|
+
assert allowed is False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_no_rules_default_allow():
|
|
44
|
+
cfg = MCPGovernanceConfig(scope_rules={})
|
|
45
|
+
allowed, reason = check_scope(cfg, "any", "any-server", "any_tool")
|
|
46
|
+
assert allowed is True and "default allow" in reason
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_classification_defaults():
|
|
50
|
+
cfg = MCPGovernanceConfig()
|
|
51
|
+
assert cfg.classification_rules["wiz"] == "HIGH"
|
|
52
|
+
assert cfg.consent_tiers["HIGH"] == "TIER_2"
|
|
53
|
+
assert cfg.mode == "REPORT"
|