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.
@@ -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/
@@ -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.
@@ -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"